Merge branch 'NuvioMedia:cmp-rewrite' into introdb

This commit is contained in:
paregi12 2026-05-02 14:27:19 +05:30 committed by GitHub
commit 68a82962da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 3166 additions and 848 deletions

View file

@ -168,6 +168,24 @@ internal actual object DownloadsPlatformDownloader {
if (!tempFile.exists()) return true if (!tempFile.exists()) return true
return runCatching { tempFile.delete() }.getOrDefault(false) return runCatching { tempFile.delete() }.getOrDefault(false)
} }
actual fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? {
localFileUri
?.toLocalFileOrNull()
?.takeIf { it.exists() }
?.let { return it.toURI().toString() }
val context = appContext ?: return null
val fileName = destinationFileName.trim().takeIf { it.isNotBlank() }
?: localFileUri
?.toLocalFileOrNull()
?.name
?.takeIf { it.isNotBlank() }
?: return null
val downloadsDir = File(context.filesDir, "downloads")
val localFile = File(downloadsDir, fileName)
return localFile.takeIf { it.exists() }?.toURI()?.toString()
}
} }
private class AndroidDownloadsTaskHandle( private class AndroidDownloadsTaskHandle(

View file

@ -3,7 +3,7 @@
<locale android:name="en"/> <locale android:name="en"/>
<locale android:name="fr"/> <locale android:name="fr"/>
<locale android:name="es"/> <locale android:name="es"/>
<locale android:name="pt-PT"/> <locale android:name="pt"/>
<locale android:name="tr"/> <locale android:name="tr"/>
<locale android:name="it"/> <locale android:name="it"/>
<locale android:name="el"/> <locale android:name="el"/>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="about_supporters_contributors_subtitle">Reconnaissance ouverte et crédits du projet</string> <string name="about_supporters_contributors_subtitle">Reconnaissance et crédits du projet</string>
<string name="action_back">Retour</string> <string name="action_back">Retour</string>
<string name="action_cancel">Annuler</string> <string name="action_cancel">Annuler</string>
<string name="action_close">Fermer</string> <string name="action_close">Fermer</string>
@ -26,29 +26,29 @@
<string name="addons_badge_refreshing">Actualisation</string> <string name="addons_badge_refreshing">Actualisation</string>
<string name="addons_badge_resources">%1$d ressources</string> <string name="addons_badge_resources">%1$d ressources</string>
<string name="addons_badge_unavailable">Indisponible</string> <string name="addons_badge_unavailable">Indisponible</string>
<string name="addons_configure">Configurer l\'addon</string> <string name="addons_configure">Configurer l'addon</string>
<string name="addons_delete">Supprimer l\'addon</string> <string name="addons_delete">Supprimer l'addon</string>
<string name="addons_empty_subtitle">Ajoutez une URL de manifeste pour commencer à charger des catalogues, métadonnées, streams ou sous-titres dans Nuvio.</string> <string name="addons_empty_subtitle">Ajoutez une URL de manifeste pour commencer à charger des catalogues, métadonnées, streams ou sous-titres dans Nuvio.</string>
<string name="addons_empty_title">Aucune addons installée.</string> <string name="addons_empty_title">Aucun addon installé.</string>
<string name="addons_error_enter_url">Veuillez saisir une URL d\'addon.</string> <string name="addons_error_enter_url">Veuillez saisir une URL d'addon.</string>
<string name="addons_input_placeholder">URL de l\'addon</string> <string name="addons_input_placeholder">URL de l'addon</string>
<string name="addons_install_button">Installer l\'addon</string> <string name="addons_install_button">Installer l'addon</string>
<string name="addons_loading_manifest_details">Chargement des détails du manifeste…</string> <string name="addons_loading_manifest_details">Chargement des détails du manifeste…</string>
<string name="addons_modal_checking_message">Validation de l\'URL du manifeste et chargement des détails de l\'addon avant installation.</string> <string name="addons_modal_checking_message">Validation de l'URL du manifeste et chargement des détails de l'addon avant installation.</string>
<string name="addons_modal_checking_title">Vérification de l\'addon</string> <string name="addons_modal_checking_title">Vérification de l'addon</string>
<string name="addons_modal_failure_title">Échec de l\'installation</string> <string name="addons_modal_failure_title">Échec de l'installation</string>
<string name="addons_modal_success_message">%1$s a été validé et ajouté avec succès.</string> <string name="addons_modal_success_message">%1$s a été validé et ajouté avec succès.</string>
<string name="addons_modal_success_title">Addon installée</string> <string name="addons_modal_success_title">Addon installé</string>
<string name="addons_move_down">Déplacer l\'addon vers le bas</string> <string name="addons_move_down">Déplacer l'addon vers le bas</string>
<string name="addons_move_up">Déplacer l\'addon vers le haut</string> <string name="addons_move_up">Déplacer l'addon vers le haut</string>
<string name="addons_overview_active">Actif</string> <string name="addons_overview_active">Actif</string>
<string name="addons_overview_addons">Addons</string> <string name="addons_overview_addons">Addons</string>
<string name="addons_overview_catalogs">Catalogues</string> <string name="addons_overview_catalogs">Catalogues</string>
<string name="addons_refresh">Actualiser l\'addon</string> <string name="addons_refresh">Actualiser l'addon</string>
<string name="addons_section_add_addon">Ajouter une addon</string> <string name="addons_section_add_addon">Ajouter un addon</string>
<string name="addons_section_installed">Addons installées</string> <string name="addons_section_installed">Addons installés</string>
<string name="addons_section_overview">Aperçu</string> <string name="addons_section_overview">Aperçu</string>
<string name="addons_summary_id_rules">%1$d règles d\'ID</string> <string name="addons_summary_id_rules">%1$d règles d'ID</string>
<string name="addons_version_format">Version %1$s</string> <string name="addons_version_format">Version %1$s</string>
<string name="cd_selected">Sélectionné</string> <string name="cd_selected">Sélectionné</string>
<string name="collections_copy_json">Copier le JSON</string> <string name="collections_copy_json">Copier le JSON</string>
@ -58,28 +58,28 @@
<string name="collections_editor_add_catalog">Ajouter un catalogue</string> <string name="collections_editor_add_catalog">Ajouter un catalogue</string>
<string name="collections_editor_add_folder">Ajouter un dossier</string> <string name="collections_editor_add_folder">Ajouter un dossier</string>
<string name="collections_editor_all_genres">Tous les genres</string> <string name="collections_editor_all_genres">Tous les genres</string>
<string name="collections_editor_catalog_sources_empty_subtitle">Ajoutez des catalogues depuis vos extensions installées pour définir ce qu\'affiche ce dossier.</string> <string name="collections_editor_catalog_sources_empty_subtitle">Ajoutez des catalogues depuis vos addons installés pour définir ce qu'affiche ce dossier.</string>
<string name="collections_editor_catalog_sources_empty_title">Aucune source de catalogue</string> <string name="collections_editor_catalog_sources_empty_title">Aucune source de catalogue</string>
<string name="collections_editor_choose_genre">Choisir</string> <string name="collections_editor_choose_genre">Choisir</string>
<string name="collections_editor_cover_emoji">Emoji</string> <string name="collections_editor_cover_emoji">Emoji</string>
<string name="collections_editor_cover_image_url">URL de l\'image</string> <string name="collections_editor_cover_image_url">URL de l'image</string>
<string name="collections_editor_cover_none">Aucune</string> <string name="collections_editor_cover_none">Aucune</string>
<string name="collections_editor_cover">Couverture</string> <string name="collections_editor_cover">Couverture</string>
<string name="collections_editor_create_collection">Créer une collection</string> <string name="collections_editor_create_collection">Créer une collection</string>
<string name="collections_editor_done">Terminé</string> <string name="collections_editor_done">Terminé</string>
<string name="collections_editor_edit_collection">Modifier la collection</string> <string name="collections_editor_edit_collection">Modifier la collection</string>
<string name="collections_editor_edit_folder">Modifier le dossier</string> <string name="collections_editor_edit_folder">Modifier le dossier</string>
<string name="collections_editor_folder_editor_help">Configurez l\'identité, la présentation et les sources de catalogue du dossier avec la même structure que l\'éditeur principal de collections.</string> <string name="collections_editor_folder_editor_help">Configurez l'identité, la présentation et les sources de catalogue du dossier avec la même structure que l'éditeur principal de collections.</string>
<string name="collections_editor_folder_empty_subtitle">Ajoutez-en un pour commencer.</string> <string name="collections_editor_folder_empty_subtitle">Ajoutez-en un pour commencer.</string>
<string name="collections_editor_folder_empty_title">Aucun dossier</string> <string name="collections_editor_folder_empty_title">Aucun dossier</string>
<string name="collections_editor_folders">Dossiers</string> <string name="collections_editor_folders">Dossiers</string>
<string name="collections_editor_genre_filter">Filtre de genre</string> <string name="collections_editor_genre_filter">Filtre de genre</string>
<string name="collections_editor_hide_title_desc">Afficher uniquement l\'image de couverture</string> <string name="collections_editor_hide_title_desc">Afficher uniquement l'image de couverture</string>
<string name="collections_editor_hide_title">Masquer le titre</string> <string name="collections_editor_hide_title">Masquer le titre</string>
<string name="collections_editor_new_folder">Nouveau dossier</string> <string name="collections_editor_new_folder">Nouveau dossier</string>
<string name="collections_editor_pin_above_desc">Affiche cette collection au-dessus de tous les catalogues normaux de l\'accueil. Plusieurs collections épinglées suivent l\'ordre de création.</string> <string name="collections_editor_pin_above_desc">Affiche cette collection au-dessus de tous les catalogues normaux de l'accueil. Plusieurs collections épinglées suivent l'ordre de création.</string>
<string name="collections_editor_pin_above">Épingler au-dessus des catalogues</string> <string name="collections_editor_pin_above">Épingler au-dessus des catalogues</string>
<string name="collections_editor_placeholder_backdrop">URL de l\'image de fond (facultatif)</string> <string name="collections_editor_placeholder_backdrop">URL de l'image de fond (facultatif)</string>
<string name="collections_editor_placeholder_folder">Nom du dossier</string> <string name="collections_editor_placeholder_folder">Nom du dossier</string>
<string name="collections_editor_placeholder_gif">URL du GIF animé (se lit uniquement au focus)</string> <string name="collections_editor_placeholder_gif">URL du GIF animé (se lit uniquement au focus)</string>
<string name="collections_editor_placeholder_name">Nom de la collection</string> <string name="collections_editor_placeholder_name">Nom de la collection</string>
@ -88,7 +88,7 @@
<string name="collections_editor_section_appearance">Apparence</string> <string name="collections_editor_section_appearance">Apparence</string>
<string name="collections_editor_section_basics">Informations de base</string> <string name="collections_editor_section_basics">Informations de base</string>
<string name="collections_editor_section_catalog_sources">Sources de catalogue</string> <string name="collections_editor_section_catalog_sources">Sources de catalogue</string>
<string name="collections_editor_select_catalogs_description">Choisissez les catalogues d\'extension que ce dossier doit regrouper.</string> <string name="collections_editor_select_catalogs_description">Choisissez les catalogues d'addon que ce dossier doit regrouper.</string>
<string name="collections_editor_select_catalogs">Sélectionner des catalogues</string> <string name="collections_editor_select_catalogs">Sélectionner des catalogues</string>
<string name="collections_editor_select_genre">Sélectionner un genre</string> <string name="collections_editor_select_genre">Sélectionner un genre</string>
<string name="collections_editor_selected_count">%1$d sélectionné(s)</string> <string name="collections_editor_selected_count">%1$d sélectionné(s)</string>
@ -98,26 +98,26 @@
<string name="collections_editor_shape_square">Carré</string> <string name="collections_editor_shape_square">Carré</string>
<string name="collections_editor_shape_wide">Large</string> <string name="collections_editor_shape_wide">Large</string>
<string name="collections_editor_show_all_tab_desc">Combiner tous les catalogues en un seul onglet</string> <string name="collections_editor_show_all_tab_desc">Combiner tous les catalogues en un seul onglet</string>
<string name="collections_editor_show_all_tab">Afficher l\'onglet « Tout »</string> <string name="collections_editor_show_all_tab">Afficher l'onglet « Tout »</string>
<string name="collections_editor_show_gif_when_configured_desc">Lire le GIF configuré à la place de la couverture statique lorsqu\'il est disponible.</string> <string name="collections_editor_show_gif_when_configured_desc">Lire le GIF configuré à la place de la couverture statique lorsqu'il est disponible.</string>
<string name="collections_editor_show_gif_when_configured">Afficher le GIF si configuré</string> <string name="collections_editor_show_gif_when_configured">Afficher le GIF si configuré</string>
<string name="collections_editor_source_count">%1$d source(s) · %2$s</string> <string name="collections_editor_source_count">%1$d source(s) · %2$s</string>
<string name="collections_editor_tile_shape">Forme de la tuile</string> <string name="collections_editor_tile_shape">Forme de la tuile</string>
<string name="collections_editor_view_mode_rows">Lignes</string> <string name="collections_editor_view_mode_rows">Lignes</string>
<string name="collections_editor_view_mode_tabs">Onglets</string> <string name="collections_editor_view_mode_tabs">Onglets</string>
<string name="collections_editor_view_mode">Mode d\'affichage</string> <string name="collections_editor_view_mode">Mode d'affichage</string>
<string name="collections_editor_tmdb_sources">Sources TMDB</string> <string name="collections_editor_tmdb_sources">Sources TMDB</string>
<string name="collections_editor_tmdb_public_list_mode">Liste publique</string> <string name="collections_editor_tmdb_public_list_mode">Liste publique</string>
<string name="collections_editor_tmdb_production_mode">Production</string> <string name="collections_editor_tmdb_production_mode">Production</string>
<string name="collections_editor_tmdb_network_mode">Chaîne</string> <string name="collections_editor_tmdb_network_mode">Chaîne</string>
<string name="collections_editor_tmdb_collection_mode">Collection</string> <string name="collections_editor_tmdb_collection_mode">Collection</string>
<string name="collections_editor_tmdb_custom_mode">Personnalisé</string> <string name="collections_editor_tmdb_custom_mode">Personnalisé</string>
<string name="collections_editor_tmdb_help_presets">Choisissez une source prédéfinie. Vous pouvez la modifier ou la supprimer après l\'avoir ajoutée.</string> <string name="collections_editor_tmdb_help_presets">Choisissez une source prédéfinie. Vous pouvez la modifier ou la supprimer après l'avoir ajoutée.</string>
<string name="collections_editor_tmdb_help_list">Collez une URL de liste publique TMDB ou uniquement le numéro de l\'URL.</string> <string name="collections_editor_tmdb_help_list">Collez une URL de liste publique TMDB ou uniquement le numéro de l'URL.</string>
<string name="collections_editor_tmdb_help_production">Recherchez par nom de studio, ou collez un ID/URL de société TMDB et ajoutez-le directement.</string> <string name="collections_editor_tmdb_help_production">Recherchez par nom de studio, ou collez un ID/URL de société TMDB et ajoutez-le directement.</string>
<string name="collections_editor_tmdb_help_network">Saisissez un ID de chaîne. Les chaînes courantes sont disponibles dans les préréglages et les filtres rapides.</string> <string name="collections_editor_tmdb_help_network">Saisissez un ID de chaîne. Les chaînes courantes sont disponibles dans les préréglages et les filtres rapides.</string>
<string name="collections_editor_tmdb_help_collection">Recherchez le nom d\'une collection de films ou collez l\'ID de collection TMDB.</string> <string name="collections_editor_tmdb_help_collection">Recherchez le nom d'une collection de films ou collez l'ID de collection TMDB.</string>
<string name="collections_editor_tmdb_help_discover">Créez une ligne TMDB dynamique avec des filtres optionnels. Laissez les champs vides si vous n\'avez pas besoin de ce filtre.</string> <string name="collections_editor_tmdb_help_discover">Créez une ligne TMDB dynamique avec des filtres optionnels. Laissez les champs vides si vous n'avez pas besoin de ce filtre.</string>
<string name="collections_editor_tmdb_public_list">Liste publique TMDB</string> <string name="collections_editor_tmdb_public_list">Liste publique TMDB</string>
<string name="collections_editor_tmdb_network_id">ID de chaîne</string> <string name="collections_editor_tmdb_network_id">ID de chaîne</string>
<string name="collections_editor_tmdb_collection_id">ID de collection</string> <string name="collections_editor_tmdb_collection_id">ID de collection</string>
@ -129,12 +129,12 @@
<string name="collections_editor_tmdb_company_placeholder">Marvel Studios, 420 ou URL de société</string> <string name="collections_editor_tmdb_company_placeholder">Marvel Studios, 420 ou URL de société</string>
<string name="collections_editor_tmdb_search_helper">Exemples : Marvel Studios, 420 ou https://www.themoviedb.org/company/420.</string> <string name="collections_editor_tmdb_search_helper">Exemples : Marvel Studios, 420 ou https://www.themoviedb.org/company/420.</string>
<string name="collections_editor_tmdb_collection_helper">Exemple : Star Wars Collection, Harry Potter Collection ou une URL de collection.</string> <string name="collections_editor_tmdb_collection_helper">Exemple : Star Wars Collection, Harry Potter Collection ou une URL de collection.</string>
<string name="collections_editor_tmdb_network_helper">Exemples d\'ID : Netflix 213, HBO 49, Disney+ 2739.</string> <string name="collections_editor_tmdb_network_helper">Exemples d'ID : Netflix 213, HBO 49, Disney+ 2739.</string>
<string name="collections_editor_tmdb_list_helper">Exemple : https://www.themoviedb.org/list/8504994 ou 8504994.</string> <string name="collections_editor_tmdb_list_helper">Exemple : https://www.themoviedb.org/list/8504994 ou 8504994.</string>
<string name="collections_editor_tmdb_display_title">Titre affiché</string> <string name="collections_editor_tmdb_display_title">Titre affiché</string>
<string name="collections_editor_tmdb_title_helper">Affiché comme nom de ligne/onglet. Si vide, Nuvio en génère un depuis la source.</string> <string name="collections_editor_tmdb_title_helper">Affiché comme nom de ligne/onglet. Si vide, Nuvio en génère un depuis la source.</string>
<string name="collections_editor_tmdb_title_placeholder">Films Marvel, Originaux Netflix, Pixar</string> <string name="collections_editor_tmdb_title_placeholder">Films Marvel, Originaux Netflix, Pixar</string>
<string name="collections_editor_tmdb_discover_title_placeholder">Meilleurs films d\'action, drames coréens, animation 2024</string> <string name="collections_editor_tmdb_discover_title_placeholder">Meilleurs films d'action, drames coréens, animation 2024</string>
<string name="collections_editor_tmdb_search_results">Résultats de recherche</string> <string name="collections_editor_tmdb_search_results">Résultats de recherche</string>
<string name="collections_editor_tmdb_collection">Collection TMDB</string> <string name="collections_editor_tmdb_collection">Collection TMDB</string>
<string name="collections_editor_tmdb_company_fallback">Société TMDB %1$d</string> <string name="collections_editor_tmdb_company_fallback">Société TMDB %1$d</string>
@ -145,7 +145,7 @@
<string name="collections_editor_tmdb_both">Les deux</string> <string name="collections_editor_tmdb_both">Les deux</string>
<string name="collections_editor_tmdb_sort">Tri</string> <string name="collections_editor_tmdb_sort">Tri</string>
<string name="collections_editor_tmdb_filters">Filtres</string> <string name="collections_editor_tmdb_filters">Filtres</string>
<string name="collections_editor_tmdb_filters_helper">Laissez les champs vides si vous n\'avez pas besoin de ce filtre.</string> <string name="collections_editor_tmdb_filters_helper">Laissez les champs vides si vous n'avez pas besoin de ce filtre.</string>
<string name="collections_editor_tmdb_quick_genres">Genres rapides</string> <string name="collections_editor_tmdb_quick_genres">Genres rapides</string>
<string name="collections_editor_tmdb_quick_languages">Langues rapides</string> <string name="collections_editor_tmdb_quick_languages">Langues rapides</string>
<string name="collections_editor_tmdb_quick_countries">Pays rapides</string> <string name="collections_editor_tmdb_quick_countries">Pays rapides</string>
@ -155,7 +155,7 @@
<string name="collections_editor_tmdb_genres">ID de genre</string> <string name="collections_editor_tmdb_genres">ID de genre</string>
<string name="collections_editor_tmdb_genres_helper">Utilisez des numéros de genre TMDB. Séparez plusieurs valeurs par des virgules pour ET, ou des barres verticales pour OU.</string> <string name="collections_editor_tmdb_genres_helper">Utilisez des numéros de genre TMDB. Séparez plusieurs valeurs par des virgules pour ET, ou des barres verticales pour OU.</string>
<string name="collections_editor_tmdb_date_from">Date de sortie ou de diffusion depuis</string> <string name="collections_editor_tmdb_date_from">Date de sortie ou de diffusion depuis</string>
<string name="collections_editor_tmdb_date_to">Date de sortie ou de diffusion jusqu\'au</string> <string name="collections_editor_tmdb_date_to">Date de sortie ou de diffusion jusqu'au</string>
<string name="collections_editor_tmdb_date_helper">Utilisez le format AAAA-MM-JJ, ex. 2024-01-01.</string> <string name="collections_editor_tmdb_date_helper">Utilisez le format AAAA-MM-JJ, ex. 2024-01-01.</string>
<string name="collections_editor_tmdb_rating_min">Note minimale</string> <string name="collections_editor_tmdb_rating_min">Note minimale</string>
<string name="collections_editor_tmdb_rating_max">Note maximale</string> <string name="collections_editor_tmdb_rating_max">Note maximale</string>
@ -164,7 +164,7 @@
<string name="collections_editor_tmdb_votes_helper">Utilisez ceci pour éviter les titres peu connus avec peu de votes. Exemple : 100.</string> <string name="collections_editor_tmdb_votes_helper">Utilisez ceci pour éviter les titres peu connus avec peu de votes. Exemple : 100.</string>
<string name="collections_editor_tmdb_language">Langue originale</string> <string name="collections_editor_tmdb_language">Langue originale</string>
<string name="collections_editor_tmdb_language_helper">Utilisez des codes de langue à deux lettres, ex. en, ko, ja, hi.</string> <string name="collections_editor_tmdb_language_helper">Utilisez des codes de langue à deux lettres, ex. en, ko, ja, hi.</string>
<string name="collections_editor_tmdb_country">Pays d\'origine</string> <string name="collections_editor_tmdb_country">Pays d'origine</string>
<string name="collections_editor_tmdb_country_helper">Utilisez des codes de pays à deux lettres, ex. US, KR, JP, IN.</string> <string name="collections_editor_tmdb_country_helper">Utilisez des codes de pays à deux lettres, ex. US, KR, JP, IN.</string>
<string name="collections_editor_tmdb_keywords">ID de mots-clés</string> <string name="collections_editor_tmdb_keywords">ID de mots-clés</string>
<string name="collections_editor_tmdb_keywords_helper">Utilisez des numéros de mots-clés TMDB. Les puces rapides remplissent des exemples courants.</string> <string name="collections_editor_tmdb_keywords_helper">Utilisez des numéros de mots-clés TMDB. Les puces rapides remplissent des exemples courants.</string>
@ -200,7 +200,7 @@
<string name="collections_editor_tmdb_country_india">Inde</string> <string name="collections_editor_tmdb_country_india">Inde</string>
<string name="collections_editor_tmdb_country_uk">Royaume-Uni</string> <string name="collections_editor_tmdb_country_uk">Royaume-Uni</string>
<string name="collections_editor_tmdb_keyword_superhero">Super-héros</string> <string name="collections_editor_tmdb_keyword_superhero">Super-héros</string>
<string name="collections_editor_tmdb_keyword_based_on_novel">Adapté d\'un roman</string> <string name="collections_editor_tmdb_keyword_based_on_novel">Adapté d'un roman</string>
<string name="collections_editor_tmdb_keyword_time_travel">Voyage dans le temps</string> <string name="collections_editor_tmdb_keyword_time_travel">Voyage dans le temps</string>
<string name="collections_editor_tmdb_keyword_space">Espace</string> <string name="collections_editor_tmdb_keyword_space">Espace</string>
<string name="collections_editor_tmdb_studio_marvel">Marvel</string> <string name="collections_editor_tmdb_studio_marvel">Marvel</string>
@ -314,13 +314,13 @@
<string name="compose_profile_add_profile">Ajouter un profil</string> <string name="compose_profile_add_profile">Ajouter un profil</string>
<string name="compose_search_clear">Effacer la recherche</string> <string name="compose_search_clear">Effacer la recherche</string>
<string name="compose_search_discover_title">Découvrir</string> <string name="compose_search_discover_title">Découvrir</string>
<string name="compose_search_empty_failed_message">Les extensions installées n\'ont retourné aucun résultat de recherche valide.</string> <string name="compose_search_empty_failed_message">Les addons installés n'ont retourné aucun résultat de recherche valide.</string>
<string name="compose_search_empty_failed_title">La recherche a échoué</string> <string name="compose_search_empty_failed_title">La recherche a échoué</string>
<string name="compose_search_empty_no_active_addons_message">Installez et validez au moins une extension avant de rechercher.</string> <string name="compose_search_empty_no_active_addons_message">Installez et validez au moins un addon avant de rechercher.</string>
<string name="compose_search_empty_no_active_addons_title">Aucune extension active</string> <string name="compose_search_empty_no_active_addons_title">Aucun addon active</string>
<string name="compose_search_empty_no_results_message">Les catalogues installés n\'ont retourné aucun résultat pour cette requête.</string> <string name="compose_search_empty_no_results_message">Les catalogues installés n'ont retourné aucun résultat pour cette requête.</string>
<string name="compose_search_empty_no_results_title">Aucun résultat trouvé</string> <string name="compose_search_empty_no_results_title">Aucun résultat trouvé</string>
<string name="compose_search_empty_no_search_catalogs_message">Vos extensions installées n\'exposent pas de catalogue de recherche.</string> <string name="compose_search_empty_no_search_catalogs_message">Vos addons installés n'exposent pas de catalogue de recherche.</string>
<string name="compose_search_empty_no_search_catalogs_title">Aucun catalogue de recherche</string> <string name="compose_search_empty_no_search_catalogs_title">Aucun catalogue de recherche</string>
<string name="compose_search_placeholder">Rechercher des films, séries…</string> <string name="compose_search_placeholder">Rechercher des films, séries…</string>
<string name="compose_search_recent_searches">Recherches récentes</string> <string name="compose_search_recent_searches">Recherches récentes</string>
@ -328,11 +328,11 @@
<string name="compose_settings_category_about">À propos</string> <string name="compose_settings_category_about">À propos</string>
<string name="compose_settings_category_general">Général</string> <string name="compose_settings_category_general">Général</string>
<string name="compose_settings_page_account">Compte</string> <string name="compose_settings_page_account">Compte</string>
<string name="compose_settings_page_addons">Extensions</string> <string name="compose_settings_page_addons">Addons</string>
<string name="compose_settings_page_appearance">Apparence</string> <string name="compose_settings_page_appearance">Apparence</string>
<string name="compose_settings_page_content_discovery">Contenu et découverte</string> <string name="compose_settings_page_content_discovery">Contenu et découverte</string>
<string name="compose_settings_page_continue_watching">Continuer à regarder</string> <string name="compose_settings_page_continue_watching">Continuer à regarder</string>
<string name="compose_settings_page_homescreen">Écran d\'accueil</string> <string name="compose_settings_page_homescreen">Écran d'accueil</string>
<string name="compose_settings_page_integrations">Intégrations</string> <string name="compose_settings_page_integrations">Intégrations</string>
<string name="compose_settings_page_mdblist_ratings">Notes MDBList</string> <string name="compose_settings_page_mdblist_ratings">Notes MDBList</string>
<string name="compose_settings_page_meta_screen">Écran méta</string> <string name="compose_settings_page_meta_screen">Écran méta</string>
@ -347,15 +347,15 @@
<string name="compose_settings_root_about_section">À PROPOS</string> <string name="compose_settings_root_about_section">À PROPOS</string>
<string name="compose_settings_root_account_description">Gérez votre compte, déconnectez-vous ou supprimez-le.</string> <string name="compose_settings_root_account_description">Gérez votre compte, déconnectez-vous ou supprimez-le.</string>
<string name="compose_settings_root_account_section">COMPTE</string> <string name="compose_settings_root_account_section">COMPTE</string>
<string name="compose_settings_root_appearance_description">Ajustez la présentation de l\'accueil et les préférences visuelles.</string> <string name="compose_settings_root_appearance_description">Ajustez la présentation de l'accueil et les préférences visuelles.</string>
<string name="compose_settings_root_check_updates_description">Rechercher de nouvelles versions de l\'application.</string> <string name="compose_settings_root_check_updates_description">Rechercher de nouvelles versions de l'application.</string>
<string name="compose_settings_root_check_updates_title">Vérifier les mises à jour</string> <string name="compose_settings_root_check_updates_title">Vérifier les mises à jour</string>
<string name="compose_settings_root_content_discovery_description">Gérez les extensions et sources de découverte.</string> <string name="compose_settings_root_content_discovery_description">Gérez les addons et sources de découverte.</string>
<string name="compose_settings_root_downloads_description">Gérez vos films et épisodes téléchargés.</string> <string name="compose_settings_root_downloads_description">Gérez vos films et épisodes téléchargés.</string>
<string name="compose_settings_root_downloads_title">Téléchargements</string> <string name="compose_settings_root_downloads_title">Téléchargements</string>
<string name="compose_settings_root_general_section">GÉNÉRAL</string> <string name="compose_settings_root_general_section">GÉNÉRAL</string>
<string name="compose_settings_root_integrations_description">Connectez les services TMDB et MDBList.</string> <string name="compose_settings_root_integrations_description">Connectez les services TMDB et MDBList.</string>
<string name="compose_settings_root_notifications_description">Gérez les alertes de sortie d\'épisodes et envoyez une notification de test.</string> <string name="compose_settings_root_notifications_description">Gérez les alertes de sortie d'épisodes et envoyez une notification de test.</string>
<string name="compose_settings_root_switch_profile_description">Basculer vers un profil différent.</string> <string name="compose_settings_root_switch_profile_description">Basculer vers un profil différent.</string>
<string name="compose_settings_root_switch_profile_title">Changer de profil</string> <string name="compose_settings_root_switch_profile_title">Changer de profil</string>
<string name="compose_settings_root_trakt_description">Connectez Trakt, synchronisez des listes et enregistrez des titres directement dans Trakt.</string> <string name="compose_settings_root_trakt_description">Connectez Trakt, synchronisez des listes et enregistrez des titres directement dans Trakt.</string>
@ -369,8 +369,8 @@
<string name="detail_comments_badge_rating">%1$d/10</string> <string name="detail_comments_badge_rating">%1$d/10</string>
<string name="detail_comments_badge_review">Avis</string> <string name="detail_comments_badge_review">Avis</string>
<string name="detail_comments_badge_spoiler">Spoiler</string> <string name="detail_comments_badge_spoiler">Spoiler</string>
<string name="detail_comments_empty">Aucun avis Trakt pour l\'instant.</string> <string name="detail_comments_empty">Aucun avis Trakt pour l'instant.</string>
<string name="detail_comments_likes">%1$d j\'aime</string> <string name="detail_comments_likes">%1$d j'aime</string>
<string name="detail_comments_spoiler_card">Ce commentaire contient des spoilers.</string> <string name="detail_comments_spoiler_card">Ce commentaire contient des spoilers.</string>
<string name="detail_comments_spoiler_hidden_sheet">Ce commentaire contient des spoilers et a été masqué.</string> <string name="detail_comments_spoiler_hidden_sheet">Ce commentaire contient des spoilers et a été masqué.</string>
<string name="detail_comments_title">Commentaires</string> <string name="detail_comments_title">Commentaires</string>
@ -407,31 +407,31 @@
<string name="settings_account_email">Adresse e-mail</string> <string name="settings_account_email">Adresse e-mail</string>
<string name="settings_account_not_signed_in">Non connecté</string> <string name="settings_account_not_signed_in">Non connecté</string>
<string name="settings_account_sign_out">Se déconnecter</string> <string name="settings_account_sign_out">Se déconnecter</string>
<string name="settings_account_sign_out_confirm_message">Vous serez redirigé vers l\'écran de connexion.</string> <string name="settings_account_sign_out_confirm_message">Vous serez redirigé vers l'écran de connexion.</string>
<string name="settings_account_sign_out_confirm_title">Se déconnecter ?</string> <string name="settings_account_sign_out_confirm_title">Se déconnecter ?</string>
<string name="settings_account_status">Statut</string> <string name="settings_account_status">Statut</string>
<string name="settings_account_status_anonymous">Anonyme</string> <string name="settings_account_status_anonymous">Anonyme</string>
<string name="settings_account_status_signed_in">Connecté</string> <string name="settings_account_status_signed_in">Connecté</string>
<string name="settings_appearance_amoled_black">Noir AMOLED</string> <string name="settings_appearance_amoled_black">Noir AMOLED</string>
<string name="settings_appearance_amoled_description">Utilise des fonds noirs purs pour les écrans OLED.</string> <string name="settings_appearance_amoled_description">Utilise des fonds noirs purs pour les écrans OLED.</string>
<string name="settings_appearance_app_language">Langue de l\'application</string> <string name="settings_appearance_app_language">Langue de l'application</string>
<string name="settings_appearance_app_language_sheet_title">Choisir la langue</string> <string name="settings_appearance_app_language_sheet_title">Choisir la langue</string>
<string name="settings_appearance_continue_watching_description">Afficher, masquer et ajuster le bandeau Continuer à regarder.</string> <string name="settings_appearance_continue_watching_description">Afficher, masquer et ajuster le bandeau Continuer à regarder.</string>
<string name="settings_appearance_poster_customization_description">Ajustez la largeur partagée des cartes d\'affiches et les rayons des coins.</string> <string name="settings_appearance_poster_customization_description">Ajustez la largeur partagée des cartes d'affiches et les rayons des coins.</string>
<string name="settings_appearance_section_display">AFFICHAGE</string> <string name="settings_appearance_section_display">AFFICHAGE</string>
<string name="settings_appearance_section_home">ACCUEIL</string> <string name="settings_appearance_section_home">ACCUEIL</string>
<string name="settings_appearance_section_theme">THÈME</string> <string name="settings_appearance_section_theme">THÈME</string>
<string name="settings_homescreen_collection_with_addon">Collection • %1$s</string> <string name="settings_homescreen_collection_with_addon">Collection • %1$s</string>
<string name="settings_homescreen_display_name">Nom affiché</string> <string name="settings_homescreen_display_name">Nom affiché</string>
<string name="settings_homescreen_empty_message">Installez une extension avec des catalogues compatibles avec les tableaux pour configurer les lignes de l\'écran d\'accueil.</string> <string name="settings_homescreen_empty_message">Installez un addon avec des catalogues compatibles avec les tableaux pour configurer les lignes de l'écran d'accueil.</string>
<string name="settings_homescreen_empty_title">Aucun catalogue d\'accueil</string> <string name="settings_homescreen_empty_title">Aucun catalogue d'accueil</string>
<string name="settings_homescreen_hero_source">Source Hero</string> <string name="settings_homescreen_hero_source">Source Hero</string>
<string name="settings_homescreen_hidden">Masqué</string> <string name="settings_homescreen_hidden">Masqué</string>
<string name="settings_homescreen_keep_home_focused">Garder l\'accueil en focus</string> <string name="settings_homescreen_keep_home_focused">Garder l'accueil en focus</string>
<string name="settings_homescreen_limit_reached">%1$s • Limite atteinte (max. %2$d)</string> <string name="settings_homescreen_limit_reached">%1$s • Limite atteinte (max. %2$d)</string>
<string name="settings_homescreen_no_sources_selected">Aucune source Hero sélectionnée</string> <string name="settings_homescreen_no_sources_selected">Aucune source Hero sélectionnée</string>
<string name="settings_homescreen_not_in_hero">Absent du Hero</string> <string name="settings_homescreen_not_in_hero">Absent du Hero</string>
<string name="settings_homescreen_pin_to_move_toast">Retirez l\'épingle de la collection pour la déplacer</string> <string name="settings_homescreen_pin_to_move_toast">Retirez l'épingle de la collection pour la déplacer</string>
<string name="settings_homescreen_pinned">Épinglé</string> <string name="settings_homescreen_pinned">Épinglé</string>
<string name="settings_homescreen_pinned_to_top">Épinglé en haut</string> <string name="settings_homescreen_pinned_to_top">Épinglé en haut</string>
<string name="settings_homescreen_reorder">Réorganiser</string> <string name="settings_homescreen_reorder">Réorganiser</string>
@ -442,7 +442,7 @@
<string name="settings_homescreen_section_hero_sources">SOURCES HERO</string> <string name="settings_homescreen_section_hero_sources">SOURCES HERO</string>
<string name="settings_homescreen_selected_count">%1$d sur %2$d sélectionnés</string> <string name="settings_homescreen_selected_count">%1$d sur %2$d sélectionnés</string>
<string name="settings_homescreen_show_hero">Afficher le Hero</string> <string name="settings_homescreen_show_hero">Afficher le Hero</string>
<string name="settings_homescreen_show_hero_description">Afficher un carrousel Hero en vedette en haut de l\'accueil. Choisissez jusqu\'à 2 catalogues sources ci-dessous.</string> <string name="settings_homescreen_show_hero_description">Afficher un carrousel Hero en vedette en haut de l'accueil. Choisissez jusqu'à 2 catalogues sources ci-dessous.</string>
<string name="settings_homescreen_summary">%1$d sur %2$d catalogues visibles • %3$d sources Hero sélectionnées</string> <string name="settings_homescreen_summary">%1$d sur %2$d catalogues visibles • %3$d sources Hero sélectionnées</string>
<string name="settings_homescreen_summary_hint">Ouvrez un catalogue uniquement si vous avez besoin de le renommer ou de le réorganiser.</string> <string name="settings_homescreen_summary_hint">Ouvrez un catalogue uniquement si vous avez besoin de le renommer ou de le réorganiser.</string>
<string name="settings_homescreen_visible">Visible</string> <string name="settings_homescreen_visible">Visible</string>
@ -451,7 +451,7 @@
<string name="settings_poster_card_style">STYLE DE CARTE D\'AFFICHE</string> <string name="settings_poster_card_style">STYLE DE CARTE D\'AFFICHE</string>
<string name="settings_poster_card_width">Largeur de carte</string> <string name="settings_poster_card_width">Largeur de carte</string>
<string name="settings_poster_custom">Personnalisé</string> <string name="settings_poster_custom">Personnalisé</string>
<string name="settings_poster_description">Personnalisez la largeur de carte et le rayon des coins pour les cartes d\'affiches partagées dans toute l\'application.</string> <string name="settings_poster_description">Personnalisez la largeur de carte et le rayon des coins pour les cartes d'affiches partagées dans toute l'application.</string>
<string name="settings_poster_hide_labels">Masquer les étiquettes</string> <string name="settings_poster_hide_labels">Masquer les étiquettes</string>
<string name="settings_poster_landscape_mode">Mode paysage pour les affiches dans les rayons</string> <string name="settings_poster_landscape_mode">Mode paysage pour les affiches dans les rayons</string>
<string name="settings_poster_live_preview">Aperçu en direct</string> <string name="settings_poster_live_preview">Aperçu en direct</string>
@ -470,36 +470,36 @@
<string name="settings_poster_width_dense">Dense</string> <string name="settings_poster_width_dense">Dense</string>
<string name="settings_poster_width_large">Grand</string> <string name="settings_poster_width_large">Grand</string>
<string name="settings_poster_width_standard">Standard</string> <string name="settings_poster_width_standard">Standard</string>
<string name="settings_continue_watching_resume_prompt_description">Afficher une invite pour reprendre là où vous en étiez à l\'ouverture de l\'application après avoir quitté le lecteur.</string> <string name="settings_continue_watching_resume_prompt_description">Afficher une invite pour reprendre là où vous en étiez à l'ouverture de l'application après avoir quitté le lecteur.</string>
<string name="settings_continue_watching_resume_prompt_title">Invite de reprise au démarrage</string> <string name="settings_continue_watching_resume_prompt_title">Invite de reprise au démarrage</string>
<string name="settings_continue_watching_section_card_style">STYLE DE CARTE</string> <string name="settings_continue_watching_section_card_style">STYLE DE CARTE</string>
<string name="settings_continue_watching_section_on_launch">AU DÉMARRAGE</string> <string name="settings_continue_watching_section_on_launch">AU DÉMARRAGE</string>
<string name="settings_continue_watching_section_up_next_behavior">COMPORTEMENT DE LA SUITE</string> <string name="settings_continue_watching_section_up_next_behavior">COMPORTEMENT DE LA SUITE</string>
<string name="settings_continue_watching_section_visibility">VISIBILITÉ</string> <string name="settings_continue_watching_section_visibility">VISIBILITÉ</string>
<string name="settings_continue_watching_show_description">Afficher le bandeau Continuer à regarder sur l\'écran d\'accueil.</string> <string name="settings_continue_watching_show_description">Afficher le bandeau Continuer à regarder sur l'écran d'accueil.</string>
<string name="settings_continue_watching_show_title">Afficher Continuer à regarder</string> <string name="settings_continue_watching_show_title">Afficher Continuer à regarder</string>
<string name="settings_continue_watching_style_poster">Affiche</string> <string name="settings_continue_watching_style_poster">Affiche</string>
<string name="settings_continue_watching_style_poster_description">Carte d\'affiche centrée sur la couverture</string> <string name="settings_continue_watching_style_poster_description">Carte d'affiche centrée sur la couverture</string>
<string name="settings_continue_watching_style_wide">Large</string> <string name="settings_continue_watching_style_wide">Large</string>
<string name="settings_continue_watching_style_wide_description">Carte horizontale riche en informations</string> <string name="settings_continue_watching_style_wide_description">Carte horizontale riche en informations</string>
<string name="settings_continue_watching_up_next_description">Quand activé, La suite reprend toujours depuis l\'épisode le plus avancé vu. Quand désactivé, suit l\'épisode le plus récemment visionné. Utile si vous revoyez des épisodes précédents.</string> <string name="settings_continue_watching_up_next_description">Quand activé, La suite reprend toujours depuis l'épisode le plus avancé vu. Quand désactivé, suit l'épisode le plus récemment visionné. Utile si vous revoyez des épisodes précédents.</string>
<string name="settings_continue_watching_up_next_title">La suite depuis l\'épisode le plus avancé</string> <string name="settings_continue_watching_up_next_title">La suite depuis l'épisode le plus avancé</string>
<string name="settings_content_discovery_section_home">ACCUEIL</string> <string name="settings_content_discovery_section_home">ACCUEIL</string>
<string name="settings_content_discovery_section_sources">SOURCES</string> <string name="settings_content_discovery_section_sources">SOURCES</string>
<string name="settings_content_discovery_addons_description">Installez, supprimez, mettez à jour et ordonnez vos sources de contenu.</string> <string name="settings_content_discovery_addons_description">Installez, supprimez, mettez à jour et ordonnez vos sources de contenu.</string>
<string name="settings_content_discovery_plugins_description">Installez des dépôts de scrapers JavaScript et testez des fournisseurs en interne.</string> <string name="settings_content_discovery_plugins_description">Installez des dépôts de scrapers JavaScript et testez des fournisseurs en interne.</string>
<string name="settings_content_discovery_homescreen_description">Contrôlez quels catalogues apparaissent à l\'accueil et dans quel ordre.</string> <string name="settings_content_discovery_homescreen_description">Contrôlez quels catalogues apparaissent à l'accueil et dans quel ordre.</string>
<string name="settings_content_discovery_meta_screen_description">Désactivez des sections de détails et réorganisez tout sous le Hero.</string> <string name="settings_content_discovery_meta_screen_description">Désactivez des sections de détails et réorganisez tout sous le Hero.</string>
<string name="settings_content_discovery_collections_description">Créez des regroupements de catalogues personnalisés avec des dossiers affichés à l\'accueil.</string> <string name="settings_content_discovery_collections_description">Créez des regroupements de catalogues personnalisés avec des dossiers affichés à l'accueil.</string>
<string name="settings_integrations_section_title">INTÉGRATIONS</string> <string name="settings_integrations_section_title">INTÉGRATIONS</string>
<string name="settings_integrations_tmdb_description">Enrichissez les pages de détails avec de l\'art, des crédits, des métadonnées d\'épisodes et plus depuis TMDB.</string> <string name="settings_integrations_tmdb_description">Enrichissez les pages de détails avec de l'art, des crédits, des métadonnées d'épisodes et plus depuis TMDB.</string>
<string name="settings_integrations_mdblist_description">Ajoutez des notes externes d\'IMDb, Rotten Tomatoes, Metacritic et d\'autres aux pages de détails.</string> <string name="settings_integrations_mdblist_description">Ajoutez des notes externes d'IMDb, Rotten Tomatoes, Metacritic et d'autres aux pages de détails.</string>
<string name="settings_mdb_add_api_key_first">Ajoutez votre clé API MDBList ci-dessous avant d\'activer les notes.</string> <string name="settings_mdb_add_api_key_first">Ajoutez votre clé API MDBList ci-dessous avant d'activer les notes.</string>
<string name="settings_mdb_api_key_description">Obtenez une clé sur https://mdblist.com/preferences et collez-la ici.</string> <string name="settings_mdb_api_key_description">Obtenez une clé sur https://mdblist.com/preferences et collez-la ici.</string>
<string name="settings_mdb_api_key_label">Clé API</string> <string name="settings_mdb_api_key_label">Clé API</string>
<string name="settings_mdb_api_key_title">Clé API MDBList</string> <string name="settings_mdb_api_key_title">Clé API MDBList</string>
<string name="settings_mdb_enable_ratings">Activer les notes MDBList</string> <string name="settings_mdb_enable_ratings">Activer les notes MDBList</string>
<string name="settings_mdb_enable_ratings_description">Afficher les notes externes de MDBList sur les pages de métadonnées lorsqu\'un ID IMDb est disponible.</string> <string name="settings_mdb_enable_ratings_description">Afficher les notes externes de MDBList sur les pages de métadonnées lorsqu'un ID IMDb est disponible.</string>
<string name="settings_mdb_section_api_key">CLÉ API</string> <string name="settings_mdb_section_api_key">CLÉ API</string>
<string name="settings_mdb_section_rating_providers">FOURNISSEURS DE NOTES</string> <string name="settings_mdb_section_rating_providers">FOURNISSEURS DE NOTES</string>
<string name="settings_mdb_section_title">MDBLIST</string> <string name="settings_mdb_section_title">MDBLIST</string>
@ -508,21 +508,21 @@
<string name="settings_meta_cast">Casting</string> <string name="settings_meta_cast">Casting</string>
<string name="settings_meta_cast_description">Liste principale du casting.</string> <string name="settings_meta_cast_description">Liste principale du casting.</string>
<string name="settings_meta_cinematic_background">Fond cinématographique</string> <string name="settings_meta_cinematic_background">Fond cinématographique</string>
<string name="settings_meta_cinematic_background_description">Fond flou derrière le contenu, similaire à l\'écran de streams.</string> <string name="settings_meta_cinematic_background_description">Fond flou derrière le contenu, similaire à l'écran de streams.</string>
<string name="settings_meta_collection">Collection</string> <string name="settings_meta_collection">Collection</string>
<string name="settings_meta_collection_description">Rayon de collection ou de franchise associée.</string> <string name="settings_meta_collection_description">Rayon de collection ou de franchise associée.</string>
<string name="settings_meta_comments">Commentaires</string> <string name="settings_meta_comments">Commentaires</string>
<string name="settings_meta_comments_description">Section de commentaires Trakt.</string> <string name="settings_meta_comments_description">Section de commentaires Trakt.</string>
<string name="settings_meta_details">Détails</string> <string name="settings_meta_details">Détails</string>
<string name="settings_meta_details_description">Durée, statut, sortie, langue et informations associées.</string> <string name="settings_meta_details_description">Durée, statut, sortie, langue et informations associées.</string>
<string name="settings_meta_episode_cards">Cartes d\'épisodes</string> <string name="settings_meta_episode_cards">Cartes d'épisodes</string>
<string name="settings_meta_episode_cards_description">Choisissez comment les épisodes sont affichés sur l\'écran de métadonnées.</string> <string name="settings_meta_episode_cards_description">Choisissez comment les épisodes sont affichés sur l'écran de métadonnées.</string>
<string name="settings_meta_episode_style_horizontal">Horizontal</string> <string name="settings_meta_episode_style_horizontal">Horizontal</string>
<string name="settings_meta_episode_style_horizontal_description">Cartes en ligne style fond</string> <string name="settings_meta_episode_style_horizontal_description">Cartes en ligne style fond</string>
<string name="settings_meta_episode_style_list">Liste</string> <string name="settings_meta_episode_style_list">Liste</string>
<string name="settings_meta_episode_style_list_description">Cartes empilées centrées sur les détails</string> <string name="settings_meta_episode_style_list_description">Cartes empilées centrées sur les détails</string>
<string name="settings_meta_episodes">Épisodes</string> <string name="settings_meta_episodes">Épisodes</string>
<string name="settings_meta_episodes_description">Saisons et liste d\'épisodes pour les séries.</string> <string name="settings_meta_episodes_description">Saisons et liste d'épisodes pour les séries.</string>
<string name="settings_meta_group_label">Groupe %1$d</string> <string name="settings_meta_group_label">Groupe %1$d</string>
<string name="settings_meta_more_like_this">Plus comme ceci</string> <string name="settings_meta_more_like_this">Plus comme ceci</string>
<string name="settings_meta_more_like_this_description">Rayon de recommandations.</string> <string name="settings_meta_more_like_this_description">Rayon de recommandations.</string>
@ -533,14 +533,14 @@
<string name="settings_meta_production_description">Studios et chaînes.</string> <string name="settings_meta_production_description">Studios et chaînes.</string>
<string name="settings_meta_section_appearance">APPARENCE</string> <string name="settings_meta_section_appearance">APPARENCE</string>
<string name="settings_meta_section_sections">SECTIONS</string> <string name="settings_meta_section_sections">SECTIONS</string>
<string name="settings_meta_tab_group_format">Groupe d\'onglets %1$d</string> <string name="settings_meta_tab_group_format">Groupe d'onglets %1$d</string>
<string name="settings_meta_tab_layout">Disposition des onglets</string> <string name="settings_meta_tab_layout">Disposition des onglets</string>
<string name="settings_meta_tab_layout_description">Regroupez les sections en onglets comme dans l\'application TV. Assignez jusqu\'à 3 sections par groupe d\'onglets.</string> <string name="settings_meta_tab_layout_description">Regroupez les sections en onglets comme dans l'application TV. Assignez jusqu'à 3 sections par groupe d'onglets.</string>
<string name="settings_meta_trailers">Bandes-annonces</string> <string name="settings_meta_trailers">Bandes-annonces</string>
<string name="settings_meta_trailers_description">Rayon de bandes-annonces et raccourcis de lecture.</string> <string name="settings_meta_trailers_description">Rayon de bandes-annonces et raccourcis de lecture.</string>
<string name="settings_notifications_disabled_in_app">Les notifications sont actuellement désactivées dans Nuvio.</string> <string name="settings_notifications_disabled_in_app">Les notifications sont actuellement désactivées dans Nuvio.</string>
<string name="settings_notifications_episode_release_alerts">Alertes de sortie d\'épisodes</string> <string name="settings_notifications_episode_release_alerts">Alertes de sortie d'épisodes</string>
<string name="settings_notifications_episode_release_alerts_description">Programmez des notifications locales lorsqu\'un nouvel épisode d\'une série sauvegardée est disponible.</string> <string name="settings_notifications_episode_release_alerts_description">Programmez des notifications locales lorsqu'un nouvel épisode d'une série sauvegardée est disponible.</string>
<string name="settings_notifications_permission_disabled">Les notifications système sont désactivées pour Nuvio. Activez-les pour recevoir des alertes et des notifications de test.</string> <string name="settings_notifications_permission_disabled">Les notifications système sont désactivées pour Nuvio. Activez-les pour recevoir des alertes et des notifications de test.</string>
<string name="settings_notifications_scheduled_count">Il y a actuellement %1$d alertes de sortie programmées sur cet appareil.</string> <string name="settings_notifications_scheduled_count">Il y a actuellement %1$d alertes de sortie programmées sur cet appareil.</string>
<string name="settings_notifications_section_alerts">ALERTES</string> <string name="settings_notifications_section_alerts">ALERTES</string>
@ -548,11 +548,11 @@
<string name="settings_notifications_send_test">Envoyer une notification de test</string> <string name="settings_notifications_send_test">Envoyer une notification de test</string>
<string name="settings_notifications_sending_test">Envoi de la notification de test…</string> <string name="settings_notifications_sending_test">Envoi de la notification de test…</string>
<string name="settings_notifications_test_for_title">Envoyer une notification locale de test pour %1$s.</string> <string name="settings_notifications_test_for_title">Envoyer une notification locale de test pour %1$s.</string>
<string name="settings_notifications_test_requires_saved_show">Sauvegardez d\'abord une série dans votre bibliothèque pour tester les notifications.</string> <string name="settings_notifications_test_requires_saved_show">Sauvegardez d'abord une série dans votre bibliothèque pour tester les notifications.</string>
<string name="settings_notifications_test_title">Notification de test</string> <string name="settings_notifications_test_title">Notification de test</string>
<string name="community_section_title">Communauté</string> <string name="community_section_title">Communauté</string>
<string name="community_section_description">Découvrez les personnes qui construisent et soutiennent Nuvio sur Mobile, TV et Web.</string> <string name="community_section_description">Découvrez les personnes qui construisent et soutiennent Nuvio sur Mobile, TV et Web.</string>
<string name="community_supporters_not_configured">L\'API des supporters n\'est pas configurée. Ajoutez DONATIONS_BASE_URL dans local.properties.</string> <string name="community_supporters_not_configured">L\'API des supporters n'est pas configurée. Ajoutez DONATIONS_BASE_URL dans local.properties.</string>
<string name="community_tab_contributors">Contributeurs</string> <string name="community_tab_contributors">Contributeurs</string>
<string name="community_tab_supporters">Supporters</string> <string name="community_tab_supporters">Supporters</string>
<string name="community_open_github">Ouvrir GitHub</string> <string name="community_open_github">Ouvrir GitHub</string>
@ -582,19 +582,19 @@
<string name="community_month_nov">Nov</string> <string name="community_month_nov">Nov</string>
<string name="community_month_dec">Déc</string> <string name="community_month_dec">Déc</string>
<string name="community_date_format">%1$s %2$s %3$s</string> <string name="community_date_format">%1$s %2$s %3$s</string>
<string name="settings_playback_all_addons">Toutes les extensions</string> <string name="settings_playback_all_addons">Toutes les addons</string>
<string name="settings_playback_all_plugins">Tous les plugins</string> <string name="settings_playback_all_plugins">Tous les plugins</string>
<string name="settings_playback_allowed_addons">Extensions autorisées</string> <string name="settings_playback_allowed_addons">Addons autorisés</string>
<string name="settings_playback_allowed_plugins">Plugins autorisés</string> <string name="settings_playback_allowed_plugins">Plugins autorisés</string>
<string name="settings_playback_anime_skip">Anime Skip</string> <string name="settings_playback_anime_skip">Anime Skip</string>
<string name="settings_playback_anime_skip_client_id">ID client AnimeSkip</string> <string name="settings_playback_anime_skip_client_id">ID client AnimeSkip</string>
<string name="settings_playback_anime_skip_client_id_description">Saisissez votre ID client API AnimeSkip. Obtenez-en un sur anime-skip.com.</string> <string name="settings_playback_anime_skip_client_id_description">Saisissez votre ID client API AnimeSkip. Obtenez-en un sur anime-skip.com.</string>
<string name="settings_playback_anime_skip_description">Rechercher également des marqueurs de saut sur AnimeSkip (nécessite un ID client).</string> <string name="settings_playback_anime_skip_description">Rechercher également des marqueurs de saut sur AnimeSkip (nécessite un ID client).</string>
<string name="settings_playback_auto_play_next_episode">Lecture automatique de l\'épisode suivant</string> <string name="settings_playback_auto_play_next_episode">Lecture automatique de l'épisode suivant</string>
<string name="settings_playback_auto_play_next_episode_description">Rechercher et lire automatiquement l\'épisode suivant lorsque le seuil est atteint.</string> <string name="settings_playback_auto_play_next_episode_description">Rechercher et lire automatiquement l'épisode suivant lorsque le seuil est atteint.</string>
<string name="settings_playback_decoder_device_only">Appareil uniquement</string> <string name="settings_playback_decoder_device_only">Appareil uniquement</string>
<string name="settings_playback_decoder_prefer_app">Préférer l\'application (FFmpeg)</string> <string name="settings_playback_decoder_prefer_app">Préférer l'application (FFmpeg)</string>
<string name="settings_playback_decoder_prefer_device">Préférer l\'appareil</string> <string name="settings_playback_decoder_prefer_device">Préférer l'appareil</string>
<string name="settings_playback_decoder_priority">Priorité du décodeur</string> <string name="settings_playback_decoder_priority">Priorité du décodeur</string>
<string name="settings_playback_dialog_close">Appuyez en dehors pour fermer</string> <string name="settings_playback_dialog_close">Appuyez en dehors pour fermer</string>
<string name="settings_playback_dialog_save_close">Appuyez en dehors pour enregistrer et fermer</string> <string name="settings_playback_dialog_save_close">Appuyez en dehors pour enregistrer et fermer</string>
@ -606,18 +606,18 @@
<string name="settings_playback_enable_libass_description">Utiliser libass pour afficher les sous-titres ASS/SSA à la place du moteur par défaut.</string> <string name="settings_playback_enable_libass_description">Utiliser libass pour afficher les sous-titres ASS/SSA à la place du moteur par défaut.</string>
<string name="settings_playback_hold_speed">Vitesse au maintien</string> <string name="settings_playback_hold_speed">Vitesse au maintien</string>
<string name="settings_playback_hold_to_speed">Maintenir pour accélérer</string> <string name="settings_playback_hold_to_speed">Maintenir pour accélérer</string>
<string name="settings_playback_hold_to_speed_description">Maintenez appuyé n\'importe où sur la surface du lecteur pour augmenter temporairement la vitesse.</string> <string name="settings_playback_hold_to_speed_description">Maintenez appuyé n'importe où sur la surface du lecteur pour augmenter temporairement la vitesse.</string>
<string name="settings_playback_invalid_regex_pattern">Modèle regex invalide</string> <string name="settings_playback_invalid_regex_pattern">Modèle regex invalide</string>
<string name="settings_playback_last_link_cache_duration">Durée du cache du dernier lien</string> <string name="settings_playback_last_link_cache_duration">Durée du cache du dernier lien</string>
<string name="settings_playback_map_dv7_to_hevc">Mapper DV7 vers HEVC</string> <string name="settings_playback_map_dv7_to_hevc">Mapper DV7 vers HEVC</string>
<string name="settings_playback_map_dv7_to_hevc_description">Utiliser Dolby Vision Profil 7 vers HEVC comme alternative pour les appareils non compatibles.</string> <string name="settings_playback_map_dv7_to_hevc_description">Utiliser Dolby Vision Profil 7 vers HEVC comme alternative pour les appareils non compatibles.</string>
<string name="settings_playback_minutes_before_end">Minutes avant la fin</string> <string name="settings_playback_minutes_before_end">Minutes avant la fin</string>
<string name="settings_playback_minutes_before_end_description">Afficher la carte de l\'épisode suivant ce nombre de minutes avant la fin.</string> <string name="settings_playback_minutes_before_end_description">Afficher la carte de l'épisode suivant ce nombre de minutes avant la fin.</string>
<string name="settings_playback_minutes_value">%1$d min</string> <string name="settings_playback_minutes_value">%1$d min</string>
<string name="settings_playback_no_items_available">Aucun élément disponible</string> <string name="settings_playback_no_items_available">Aucun élément disponible</string>
<string name="settings_playback_not_set">Non défini</string> <string name="settings_playback_not_set">Non défini</string>
<string name="settings_playback_option_default">Par défaut</string> <string name="settings_playback_option_default">Par défaut</string>
<string name="settings_playback_option_device_language">Langue de l\'appareil</string> <string name="settings_playback_option_device_language">Langue de l'appareil</string>
<string name="settings_playback_option_forced">Forcé</string> <string name="settings_playback_option_forced">Forcé</string>
<string name="settings_playback_option_none">Aucun</string> <string name="settings_playback_option_none">Aucun</string>
<string name="settings_playback_prefer_binge_group">Préférer le groupe binge</string> <string name="settings_playback_prefer_binge_group">Préférer le groupe binge</string>
@ -625,7 +625,7 @@
<string name="settings_playback_preferred_audio_language">Langue audio préférée</string> <string name="settings_playback_preferred_audio_language">Langue audio préférée</string>
<string name="settings_playback_preferred_subtitle_language">Langue des sous-titres préférée</string> <string name="settings_playback_preferred_subtitle_language">Langue des sous-titres préférée</string>
<string name="settings_playback_presets">Préréglages</string> <string name="settings_playback_presets">Préréglages</string>
<string name="settings_playback_regex_matches_against">Correspond au nom du stream, à l\'étiquette, à la description, à l\'extension et à l\'URL.</string> <string name="settings_playback_regex_matches_against">Correspond au nom du stream, à l'étiquette, à la description, à l'addon et à l'URL.</string>
<string name="settings_playback_regex_pattern">Modèle regex</string> <string name="settings_playback_regex_pattern">Modèle regex</string>
<string name="settings_playback_regex_placeholder">4K|2160p|Remux</string> <string name="settings_playback_regex_placeholder">4K|2160p|Remux</string>
<string name="settings_playback_regex_preset_any_1080p">N\'importe quel 1080p+</string> <string name="settings_playback_regex_preset_any_1080p">N\'importe quel 1080p+</string>
@ -661,18 +661,18 @@
<string name="settings_playback_section_subtitle_rendering">RENDU DES SOUS-TITRES</string> <string name="settings_playback_section_subtitle_rendering">RENDU DES SOUS-TITRES</string>
<string name="settings_playback_selected_count">%1$d sélectionné(s)</string> <string name="settings_playback_selected_count">%1$d sélectionné(s)</string>
<string name="settings_playback_show_loading_overlay">Afficher la superposition de chargement</string> <string name="settings_playback_show_loading_overlay">Afficher la superposition de chargement</string>
<string name="settings_playback_show_loading_overlay_description">Afficher la superposition de chargement initiale pendant le démarrage d\'un stream.</string> <string name="settings_playback_show_loading_overlay_description">Afficher la superposition de chargement initiale pendant le démarrage d'un stream.</string>
<string name="settings_playback_skip_intro_outro_recap">Passer l\'intro/outro/récap</string> <string name="settings_playback_skip_intro_outro_recap">Passer l'intro/outro/récap</string>
<string name="settings_playback_skip_intro_outro_recap_description">Afficher un bouton de saut lors des segments d\'intro, d\'outro et de récapitulatif détectés.</string> <string name="settings_playback_skip_intro_outro_recap_description">Afficher un bouton de saut lors des segments d'intro, d'outro et de récapitulatif détectés.</string>
<string name="settings_playback_source_scope">Périmètre des sources</string> <string name="settings_playback_source_scope">Périmètre des sources</string>
<string name="settings_playback_source_scope_all_addons">Toutes les extensions</string> <string name="settings_playback_source_scope_all_addons">Toutes les addons</string>
<string name="settings_playback_source_scope_all_addons_description">Considérer les streams de toutes les extensions installées.</string> <string name="settings_playback_source_scope_all_addons_description">Considérer les streams de toutes les addons installés.</string>
<string name="settings_playback_source_scope_all_sources">Toutes les sources</string> <string name="settings_playback_source_scope_all_sources">Toutes les sources</string>
<string name="settings_playback_source_scope_all_sources_description">Considérer les streams des extensions et des plugins.</string> <string name="settings_playback_source_scope_all_sources_description">Considérer les streams des addons et des plugins.</string>
<string name="settings_playback_source_scope_enabled_plugins_only">Plugins activés uniquement</string> <string name="settings_playback_source_scope_enabled_plugins_only">Plugins activés uniquement</string>
<string name="settings_playback_source_scope_enabled_plugins_only_description">Considérer uniquement les streams des plugins activés.</string> <string name="settings_playback_source_scope_enabled_plugins_only_description">Considérer uniquement les streams des plugins activés.</string>
<string name="settings_playback_source_scope_installed_addons_only">Extensions installées uniquement</string> <string name="settings_playback_source_scope_installed_addons_only">Addons installés uniquement</string>
<string name="settings_playback_source_scope_installed_addons_only_description">Considérer uniquement les streams des extensions installées.</string> <string name="settings_playback_source_scope_installed_addons_only_description">Considérer uniquement les streams des addons installés.</string>
<string name="settings_playback_stream_selection_mode">Mode de sélection du stream</string> <string name="settings_playback_stream_selection_mode">Mode de sélection du stream</string>
<string name="settings_playback_stream_selection_mode_first_stream">Premier stream disponible</string> <string name="settings_playback_stream_selection_mode_first_stream">Premier stream disponible</string>
<string name="settings_playback_stream_selection_mode_first_stream_description">Lire automatiquement le premier stream trouvé.</string> <string name="settings_playback_stream_selection_mode_first_stream_description">Lire automatiquement le premier stream trouvé.</string>
@ -680,28 +680,28 @@
<string name="settings_playback_stream_selection_mode_manual_description">Sélectionner les streams manuellement à chaque fois.</string> <string name="settings_playback_stream_selection_mode_manual_description">Sélectionner les streams manuellement à chaque fois.</string>
<string name="settings_playback_stream_selection_mode_regex">Correspondance regex</string> <string name="settings_playback_stream_selection_mode_regex">Correspondance regex</string>
<string name="settings_playback_stream_selection_mode_regex_description">Sélectionner automatiquement un stream correspondant à un modèle regex.</string> <string name="settings_playback_stream_selection_mode_regex_description">Sélectionner automatiquement un stream correspondant à un modèle regex.</string>
<string name="settings_playback_stream_timeout">Délai d\'expiration du stream</string> <string name="settings_playback_stream_timeout">Délai d'expiration du stream</string>
<string name="settings_playback_stream_timeout_description">Combien de temps attendre les streams avant la sélection automatique.</string> <string name="settings_playback_stream_timeout_description">Combien de temps attendre les streams avant la sélection automatique.</string>
<string name="settings_playback_threshold_minutes">Minutes avant la fin</string> <string name="settings_playback_threshold_minutes">Minutes avant la fin</string>
<string name="settings_playback_threshold_mode">Mode de seuil</string> <string name="settings_playback_threshold_mode">Mode de seuil</string>
<string name="settings_playback_threshold_mode_minutes_before_end">Minutes avant la fin</string> <string name="settings_playback_threshold_mode_minutes_before_end">Minutes avant la fin</string>
<string name="settings_playback_threshold_mode_percentage">Pourcentage</string> <string name="settings_playback_threshold_mode_percentage">Pourcentage</string>
<string name="settings_playback_threshold_percentage">Pourcentage de seuil</string> <string name="settings_playback_threshold_percentage">Pourcentage de seuil</string>
<string name="settings_playback_threshold_percentage_description">Afficher la carte de l\'épisode suivant lorsque la lecture atteint ce pourcentage.</string> <string name="settings_playback_threshold_percentage_description">Afficher la carte de l'épisode suivant lorsque la lecture atteint ce pourcentage.</string>
<string name="settings_playback_threshold_percentage_value">%1$d%</string> <string name="settings_playback_threshold_percentage_value">%1$d%</string>
<string name="settings_playback_timeout_instant">Instantané</string> <string name="settings_playback_timeout_instant">Instantané</string>
<string name="settings_playback_timeout_seconds">%1$ds</string> <string name="settings_playback_timeout_seconds">%1$ds</string>
<string name="settings_playback_timeout_unlimited">Illimité</string> <string name="settings_playback_timeout_unlimited">Illimité</string>
<string name="settings_playback_tunneled_playback">Lecture tunnelisée</string> <string name="settings_playback_tunneled_playback">Lecture tunnelisée</string>
<string name="settings_playback_tunneled_playback_description">Active la lecture tunnelisée pour une latence réduite dans la synchronisation audio/vidéo.</string> <string name="settings_playback_tunneled_playback_description">Active la lecture tunnelisée pour une latence réduite dans la synchronisation audio/vidéo.</string>
<string name="settings_tmdb_add_api_key_first">Ajoutez votre propre clé API TMDB ci-dessous avant d\'activer l\'enrichissement.</string> <string name="settings_tmdb_add_api_key_first">Ajoutez votre propre clé API TMDB ci-dessous avant d'activer l'enrichissement.</string>
<string name="settings_tmdb_api_key_label">Clé API TMDB</string> <string name="settings_tmdb_api_key_label">Clé API TMDB</string>
<string name="settings_tmdb_enable_enrichment">Activer l\'enrichissement TMDB</string> <string name="settings_tmdb_enable_enrichment">Activer l'enrichissement TMDB</string>
<string name="settings_tmdb_enable_enrichment_description">Utiliser votre clé API TMDB pour enrichir les métadonnées de l\'extension sur l\'écran de détails lorsqu\'un ID TMDB ou IMDb est disponible.</string> <string name="settings_tmdb_enable_enrichment_description">Utiliser votre clé API TMDB pour enrichir les métadonnées de l'addon sur l'écran de détails lorsqu'un ID TMDB ou IMDb est disponible.</string>
<string name="settings_tmdb_enter_api_key">Saisissez votre clé API v3 TMDB.</string> <string name="settings_tmdb_enter_api_key">Saisissez votre clé API v3 TMDB.</string>
<string name="settings_tmdb_language_code_label">Code de langue</string> <string name="settings_tmdb_language_code_label">Code de langue</string>
<string name="settings_tmdb_module_artwork">Visuels</string> <string name="settings_tmdb_module_artwork">Visuels</string>
<string name="settings_tmdb_module_artwork_description">Remplacer le fond, l\'affiche et le logo par les visuels TMDB.</string> <string name="settings_tmdb_module_artwork_description">Remplacer le fond, l'affiche et le logo par les visuels TMDB.</string>
<string name="settings_tmdb_module_basic_info">Informations de base</string> <string name="settings_tmdb_module_basic_info">Informations de base</string>
<string name="settings_tmdb_module_basic_info_description">Utiliser le titre, le synopsis, les genres et la note de TMDB.</string> <string name="settings_tmdb_module_basic_info_description">Utiliser le titre, le synopsis, les genres et la note de TMDB.</string>
<string name="settings_tmdb_module_collections">Collections</string> <string name="settings_tmdb_module_collections">Collections</string>
@ -717,9 +717,9 @@
<string name="settings_tmdb_module_networks">Chaînes</string> <string name="settings_tmdb_module_networks">Chaînes</string>
<string name="settings_tmdb_module_networks_description">Utiliser les métadonnées des chaînes TMDB pour les titres TV.</string> <string name="settings_tmdb_module_networks_description">Utiliser les métadonnées des chaînes TMDB pour les titres TV.</string>
<string name="settings_tmdb_module_production_companies">Sociétés de production</string> <string name="settings_tmdb_module_production_companies">Sociétés de production</string>
<string name="settings_tmdb_module_production_companies_description">Utiliser les métadonnées des sociétés de production TMDB sur l\'écran de détails.</string> <string name="settings_tmdb_module_production_companies_description">Utiliser les métadonnées des sociétés de production TMDB sur l'écran de détails.</string>
<string name="settings_tmdb_module_season_posters">Affiches de saison</string> <string name="settings_tmdb_module_season_posters">Affiches de saison</string>
<string name="settings_tmdb_module_season_posters_description">Utiliser les affiches de saison TMDB dans le sélecteur de saisons de l\'écran de métadonnées pour les séries.</string> <string name="settings_tmdb_module_season_posters_description">Utiliser les affiches de saison TMDB dans le sélecteur de saisons de l'écran de métadonnées pour les séries.</string>
<string name="settings_tmdb_module_trailers">Bandes-annonces</string> <string name="settings_tmdb_module_trailers">Bandes-annonces</string>
<string name="settings_tmdb_module_trailers_description">Récupérer et afficher la section des bandes-annonces TMDB sur les pages de détails.</string> <string name="settings_tmdb_module_trailers_description">Récupérer et afficher la section des bandes-annonces TMDB sur les pages de détails.</string>
<string name="settings_tmdb_personal_api_key">Clé API personnelle</string> <string name="settings_tmdb_personal_api_key">Clé API personnelle</string>
@ -737,13 +737,13 @@
<string name="settings_trakt_connected_as">Connecté en tant que %1$s</string> <string name="settings_trakt_connected_as">Connecté en tant que %1$s</string>
<string name="settings_trakt_default_user">Utilisateur Trakt</string> <string name="settings_trakt_default_user">Utilisateur Trakt</string>
<string name="settings_trakt_disconnect">Déconnecter</string> <string name="settings_trakt_disconnect">Déconnecter</string>
<string name="settings_trakt_failed_open_browser">Impossible d\'ouvrir le navigateur</string> <string name="settings_trakt_failed_open_browser">Impossible d'ouvrir le navigateur</string>
<string name="settings_trakt_features">FONCTIONNALITÉS</string> <string name="settings_trakt_features">FONCTIONNALITÉS</string>
<string name="settings_trakt_finish_sign_in">Terminez la connexion Trakt dans votre navigateur</string> <string name="settings_trakt_finish_sign_in">Terminez la connexion Trakt dans votre navigateur</string>
<string name="settings_trakt_intro_description">Suivez ce que vous regardez, enregistrez dans votre liste ou vos listes personnalisées et gardez votre bibliothèque synchronisée avec Trakt.</string> <string name="settings_trakt_intro_description">Suivez ce que vous regardez, enregistrez dans votre liste ou vos listes personnalisées et gardez votre bibliothèque synchronisée avec Trakt.</string>
<string name="settings_trakt_missing_credentials">Identifiants Trakt manquants dans local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET).</string> <string name="settings_trakt_missing_credentials">Identifiants Trakt manquants dans local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET).</string>
<string name="settings_trakt_open_login">Ouvrir la connexion Trakt</string> <string name="settings_trakt_open_login">Ouvrir la connexion Trakt</string>
<string name="settings_trakt_save_actions_description">Vos actions d\'enregistrement peuvent maintenant cibler la watchlist Trakt et vos listes personnelles.</string> <string name="settings_trakt_save_actions_description">Vos actions d'enregistrement peuvent maintenant cibler la watchlist Trakt et vos listes personnelles.</string>
<string name="settings_trakt_sign_in_description">Connectez-vous avec Trakt pour activer la sauvegarde basée sur les listes et le mode bibliothèque Trakt.</string> <string name="settings_trakt_sign_in_description">Connectez-vous avec Trakt pour activer la sauvegarde basée sur les listes et le mode bibliothèque Trakt.</string>
<string name="source_audience_score">Score du public</string> <string name="source_audience_score">Score du public</string>
<string name="source_imdb">IMDb</string> <string name="source_imdb">IMDb</string>
@ -763,11 +763,11 @@
<string name="player_next_episode">Épisode suivant</string> <string name="player_next_episode">Épisode suivant</string>
<string name="player_next_episode_finding_source">Recherche de la source…</string> <string name="player_next_episode_finding_source">Recherche de la source…</string>
<string name="player_next_episode_playing_via_countdown">Lecture via %1$s dans %2$d…</string> <string name="player_next_episode_playing_via_countdown">Lecture via %1$s dans %2$d…</string>
<string name="player_next_episode_thumbnail">Miniature de l\'épisode suivant</string> <string name="player_next_episode_thumbnail">Miniature de l'épisode suivant</string>
<string name="player_next_episode_unaired">Non diffusé</string> <string name="player_next_episode_unaired">Non diffusé</string>
<string name="player_skip">Passer</string> <string name="player_skip">Passer</string>
<string name="player_skip_intro">Passer l\'intro</string> <string name="player_skip_intro">Passer l'intro</string>
<string name="player_skip_outro">Passer l\'outro</string> <string name="player_skip_outro">Passer l'outro</string>
<string name="player_skip_recap">Passer le récap</string> <string name="player_skip_recap">Passer le récap</string>
<string name="compose_player_no_subtitles_found">Aucun sous-titre trouvé</string> <string name="compose_player_no_subtitles_found">Aucun sous-titre trouvé</string>
<string name="lang_afrikaans">Afrikaans</string> <string name="lang_afrikaans">Afrikaans</string>
@ -856,32 +856,32 @@
<string name="action_no">Non</string> <string name="action_no">Non</string>
<string name="action_update">Mettre à jour</string> <string name="action_update">Mettre à jour</string>
<string name="action_yes">Oui</string> <string name="action_yes">Oui</string>
<string name="app_exit_message">Voulez-vous quitter l\'application ?</string> <string name="app_exit_message">Voulez-vous quitter l'application ?</string>
<string name="app_exit_title">Quitter l\'application</string> <string name="app_exit_title">Quitter l'application</string>
<string name="catalog_empty_message">Ce catalogue n\'a retourné aucun élément.</string> <string name="catalog_empty_message">Ce catalogue n'a retourné aucun élément.</string>
<string name="catalog_empty_title">Aucun titre trouvé</string> <string name="catalog_empty_title">Aucun titre trouvé</string>
<string name="details_check_connection">Vérifiez votre connexion WiFi ou données mobiles et réessayez.</string> <string name="details_check_connection">Vérifiez votre connexion WiFi ou données mobiles et réessayez.</string>
<string name="details_director">Réalisateur</string> <string name="details_director">Réalisateur</string>
<string name="details_failed_to_load">Échec du chargement</string> <string name="details_failed_to_load">Échec du chargement</string>
<string name="details_more_like_this">Plus comme ceci</string> <string name="details_more_like_this">Plus comme ceci</string>
<string name="details_seasons">Saisons</string> <string name="details_seasons">Saisons</string>
<string name="details_series_missing_numbers">Cette extension a retourné des vidéos pour la série, mais aucune n\'incluait de numéros de saison ou d\'épisode.</string> <string name="details_series_missing_numbers">Cet addon a retourné des vidéos pour la série, mais aucune n'incluait de numéros de saison ou d'épisode.</string>
<string name="details_series_no_metadata">Cette extension n\'a fourni aucune métadonnée d\'épisode pour cette série.</string> <string name="details_series_no_metadata">Cet addon n'a fourni aucune métadonnée d'épisode pour cette série.</string>
<string name="details_series_unpublished">Cette extension n\'a pas encore publié d\'épisodes.</string> <string name="details_series_unpublished">Cet addon n'a pas encore publié d'épisodes.</string>
<string name="details_servers_unreachable">Votre appareil est en ligne, mais Nuvio n\'a pas pu se connecter aux serveurs nécessaires.</string> <string name="details_servers_unreachable">Votre appareil est en ligne, mais Nuvio n'a pas pu se connecter aux serveurs nécessaires.</string>
<string name="details_show_less">Afficher moins</string> <string name="details_show_less">Afficher moins</string>
<string name="details_show_more">Afficher plus ▾</string> <string name="details_show_more">Afficher plus ▾</string>
<string name="details_writer">Scénariste</string> <string name="details_writer">Scénariste</string>
<string name="discover_all_genres">Tous les genres</string> <string name="discover_all_genres">Tous les genres</string>
<string name="discover_catalog">Catalogue</string> <string name="discover_catalog">Catalogue</string>
<string name="discover_catalog_context">%1$s • %2$s</string> <string name="discover_catalog_context">%1$s • %2$s</string>
<string name="discover_empty_load_failed_message">Le catalogue sélectionné n\'a retourné aucun élément de découverte.</string> <string name="discover_empty_load_failed_message">Le catalogue sélectionné n'a retourné aucun élément de découverte.</string>
<string name="discover_empty_load_failed_title">Impossible de charger Découvrir</string> <string name="discover_empty_load_failed_title">Impossible de charger Découvrir</string>
<string name="discover_empty_no_catalogs_message">Les extensions installées n\'exposent pas de catalogues compatibles avec le tableau pour Découvrir.</string> <string name="discover_empty_no_catalogs_message">Les addons installés n'exposent pas de catalogues compatibles avec le tableau pour Découvrir.</string>
<string name="discover_empty_no_catalogs_title">Aucun catalogue de découverte</string> <string name="discover_empty_no_catalogs_title">Aucun catalogue de découverte</string>
<string name="discover_empty_no_results_message">Le catalogue et les filtres sélectionnés n\'ont retourné aucun élément.</string> <string name="discover_empty_no_results_message">Le catalogue et les filtres sélectionnés n'ont retourné aucun élément.</string>
<string name="discover_empty_no_results_title">Aucun titre trouvé</string> <string name="discover_empty_no_results_title">Aucun titre trouvé</string>
<string name="discover_empty_no_active_addons_message">Installez et validez au moins une extension avant d\'explorer les catalogues dans Découvrir.</string> <string name="discover_empty_no_active_addons_message">Installez et validez au moins un addon avant d'explorer les catalogues dans Découvrir.</string>
<string name="discover_select_catalog">Sélectionner un catalogue</string> <string name="discover_select_catalog">Sélectionner un catalogue</string>
<string name="discover_select_genre">Sélectionner un genre</string> <string name="discover_select_genre">Sélectionner un genre</string>
<string name="discover_select_type">Sélectionner un type</string> <string name="discover_select_type">Sélectionner un type</string>
@ -894,9 +894,9 @@
<string name="episode_mark_watched">Marquer comme vu</string> <string name="episode_mark_watched">Marquer comme vu</string>
<string name="home_continue_watching_up_next">Suivant</string> <string name="home_continue_watching_up_next">Suivant</string>
<string name="home_continue_watching_watched">%1$s vu</string> <string name="home_continue_watching_watched">%1$s vu</string>
<string name="home_empty_no_active_addons_message">Installez et validez au moins une extension avant de charger des lignes de catalogue à l\'accueil.</string> <string name="home_empty_no_active_addons_message">Installez et validez au moins un addon avant de charger des lignes de catalogue à l'accueil.</string>
<string name="home_empty_no_rows_message">Les extensions installées n\'exposent actuellement aucun catalogue compatible avec le tableau sans extras requis.</string> <string name="home_empty_no_rows_message">Les addons installés n'exposent actuellement aucun catalogue compatible avec le tableau sans extras requis.</string>
<string name="home_empty_no_rows_title">Aucune ligne d\'accueil disponible</string> <string name="home_empty_no_rows_title">Aucune ligne d'accueil disponible</string>
<string name="home_view_details">Voir les détails</string> <string name="home_view_details">Voir les détails</string>
<string name="meta_section_actions_description">Contrôles pour lire et enregistrer.</string> <string name="meta_section_actions_description">Contrôles pour lire et enregistrer.</string>
<string name="meta_section_actions_title">Actions</string> <string name="meta_section_actions_title">Actions</string>
@ -906,7 +906,7 @@
<string name="meta_section_comments_description">Section de commentaires Trakt.</string> <string name="meta_section_comments_description">Section de commentaires Trakt.</string>
<string name="meta_section_details_description">Durée, statut, date de sortie, langue et informations associées.</string> <string name="meta_section_details_description">Durée, statut, date de sortie, langue et informations associées.</string>
<string name="meta_section_details_title">Détails</string> <string name="meta_section_details_title">Détails</string>
<string name="meta_section_episodes_description">Saisons et liste d\'épisodes pour les séries.</string> <string name="meta_section_episodes_description">Saisons et liste d'épisodes pour les séries.</string>
<string name="meta_section_more_like_this_description">Rayon de recommandations.</string> <string name="meta_section_more_like_this_description">Rayon de recommandations.</string>
<string name="meta_section_more_like_this_title">Plus comme ceci</string> <string name="meta_section_more_like_this_title">Plus comme ceci</string>
<string name="meta_section_overview_description">Synopsis, notes, genres et crédits principaux.</string> <string name="meta_section_overview_description">Synopsis, notes, genres et crédits principaux.</string>
@ -915,7 +915,7 @@
<string name="meta_section_production_title">Production</string> <string name="meta_section_production_title">Production</string>
<string name="meta_section_trailers_description">Rayon de bandes-annonces et raccourcis de lecture.</string> <string name="meta_section_trailers_description">Rayon de bandes-annonces et raccourcis de lecture.</string>
<string name="network_back_online">De nouveau en ligne</string> <string name="network_back_online">De nouveau en ligne</string>
<string name="network_cannot_reach_servers">Impossible d\'atteindre les serveurs</string> <string name="network_cannot_reach_servers">Impossible d'atteindre les serveurs</string>
<string name="network_no_internet_connection">Pas de connexion Internet</string> <string name="network_no_internet_connection">Pas de connexion Internet</string>
<string name="person_age">(âge %1$d)</string> <string name="person_age">(âge %1$d)</string>
<string name="person_born">Né(e) le %1$s%2$s</string> <string name="person_born">Né(e) le %1$s%2$s</string>
@ -933,7 +933,7 @@
<string name="pin_forgot">Code PIN oublié ?</string> <string name="pin_forgot">Code PIN oublié ?</string>
<string name="pin_incorrect">Code PIN incorrect</string> <string name="pin_incorrect">Code PIN incorrect</string>
<string name="pin_locked_try_again">Bloqué. Réessayez dans %1$ds</string> <string name="pin_locked_try_again">Bloqué. Réessayez dans %1$ds</string>
<string name="profile_avatar_options_pending">Les options d\'avatar apparaîtront ici une fois le catalogue chargé.</string> <string name="profile_avatar_options_pending">Les options d'avatar apparaîtront ici une fois le catalogue chargé.</string>
<string name="profile_avatar_selected">Avatar : %1$s</string> <string name="profile_avatar_selected">Avatar : %1$s</string>
<string name="profile_choose_avatar">Choisir un avatar</string> <string name="profile_choose_avatar">Choisir un avatar</string>
<string name="profile_choose_avatar_below">Choisissez un avatar ci-dessous.</string> <string name="profile_choose_avatar_below">Choisissez un avatar ci-dessous.</string>
@ -949,32 +949,32 @@
<string name="profile_manage_profiles">Gérer les profils</string> <string name="profile_manage_profiles">Gérer les profils</string>
<string name="profile_name_placeholder">Nom du profil</string> <string name="profile_name_placeholder">Nom du profil</string>
<string name="profile_new">Nouveau profil</string> <string name="profile_new">Nouveau profil</string>
<string name="profile_primary_addons_off">Extensions principales désactivées</string> <string name="profile_primary_addons_off">Addons principaux désactivés</string>
<string name="profile_primary_addons_on">Extensions principales activées</string> <string name="profile_primary_addons_on">Addons principaux activés</string>
<string name="profile_remove_pin_for">Supprimer le code PIN pour %1$s</string> <string name="profile_remove_pin_for">Supprimer le code PIN pour %1$s</string>
<string name="profile_remove_pin_lock">Supprimer le verrouillage PIN</string> <string name="profile_remove_pin_lock">Supprimer le verrouillage PIN</string>
<string name="profile_saving">Enregistrement…</string> <string name="profile_saving">Enregistrement…</string>
<string name="profile_security">Sécurité</string> <string name="profile_security">Sécurité</string>
<string name="profile_security_pin_disabled">Ajoutez un code PIN si vous souhaitez que ce profil soit verrouillé avant d\'y accéder.</string> <string name="profile_security_pin_disabled">Ajoutez un code PIN si vous souhaitez que ce profil soit verrouillé avant d'y accéder.</string>
<string name="profile_security_pin_enabled">Ce profil est protégé par un code PIN.</string> <string name="profile_security_pin_enabled">Ce profil est protégé par un code PIN.</string>
<string name="profile_select_avatar">Sélectionnez un avatar pour ce profil.</string> <string name="profile_select_avatar">Sélectionnez un avatar pour ce profil.</string>
<string name="profile_set_pin_lock">Configurer le verrouillage PIN</string> <string name="profile_set_pin_lock">Configurer le verrouillage PIN</string>
<string name="profile_unnamed">Profil sans nom</string> <string name="profile_unnamed">Profil sans nom</string>
<string name="profile_use_primary_addons">Utiliser les extensions principales</string> <string name="profile_use_primary_addons">Utiliser les addons principales</string>
<string name="profile_use_primary_addons_description">Partager la configuration des extensions du profil principal plutôt que de gérer une liste séparée.</string> <string name="profile_use_primary_addons_description">Partager la configuration des addons du profil principal plutôt que de gérer une liste séparée.</string>
<string name="profile_who_is_watching">Qui regarde ?</string> <string name="profile_who_is_watching">Qui regarde ?</string>
<string name="provider_downloaded">Téléchargé</string> <string name="provider_downloaded">Téléchargé</string>
<string name="resume_prompt_action">Reprendre</string> <string name="resume_prompt_action">Reprendre</string>
<string name="streams_active_scrapers">Scrapers actifs</string> <string name="streams_active_scrapers">Scrapers actifs</string>
<string name="streams_checking_more_addons">Vérification d\'autres extensions…</string> <string name="streams_checking_more_addons">Vérification d'autres addons…</string>
<string name="streams_copy_link">Copier le lien du stream</string> <string name="streams_copy_link">Copier le lien du stream</string>
<string name="streams_download_file">Télécharger le fichier</string> <string name="streams_download_file">Télécharger le fichier</string>
<string name="streams_empty_load_failed_message">Les extensions de streams installées n\'ont pas retourné de réponse valide.</string> <string name="streams_empty_load_failed_message">Les addons de streams installés n'ont pas retourné de réponse valide.</string>
<string name="streams_empty_load_failed_title">Impossible de charger les streams</string> <string name="streams_empty_load_failed_title">Impossible de charger les streams</string>
<string name="streams_empty_no_addons_message">Installez d\'abord une extension pour charger les streams de ce titre.</string> <string name="streams_empty_no_addons_message">Installez d'abord un addon pour charger les streams de ce titre.</string>
<string name="streams_empty_no_stream_addon_message">Vos extensions installées ne fournissent pas de streams pour ce type de titre.</string> <string name="streams_empty_no_stream_addon_message">Vos addons installés ne fournissent pas de streams pour ce type de titre.</string>
<string name="streams_empty_no_stream_addon_title">Aucune extension de streams disponible</string> <string name="streams_empty_no_stream_addon_title">Aucun addon de streams disponible</string>
<string name="streams_empty_no_streams_message">Aucune de vos extensions installées n\'a retourné de streams pour ce titre.</string> <string name="streams_empty_no_streams_message">Aucune de vos addons installés n'a retourné de streams pour ce titre.</string>
<string name="streams_episode_badge">S%1$d E%2$d</string> <string name="streams_episode_badge">S%1$d E%2$d</string>
<string name="streams_episode_fallback_title">Épisode</string> <string name="streams_episode_fallback_title">Épisode</string>
<string name="streams_episode_title_with_name">S%1$dE%2$d - %3$s</string> <string name="streams_episode_title_with_name">S%1$dE%2$d - %3$s</string>
@ -995,10 +995,10 @@
<string name="updates_asset_line">%1$s • %2$s</string> <string name="updates_asset_line">%1$s • %2$s</string>
<string name="updates_check_failed">Échec de la vérification des mises à jour</string> <string name="updates_check_failed">Échec de la vérification des mises à jour</string>
<string name="updates_download_failed">Échec du téléchargement</string> <string name="updates_download_failed">Échec du téléchargement</string>
<string name="updates_downloading_progress">Téléchargement %1$d%</string> <string name="updates_downloading_progress">Téléchargement %1$d%%</string>
<string name="updates_install_failed">Impossible de démarrer l\'installation</string> <string name="updates_install_failed">Impossible de démarrer l'installation</string>
<string name="updates_latest_version">Vous utilisez la version la plus récente.</string> <string name="updates_latest_version">Vous utilisez la version la plus récente.</string>
<string name="updates_message_allow_installs">Activez l\'installation d\'applications pour Nuvio puis revenez pour continuer.</string> <string name="updates_message_allow_installs">Activez l'installation d'applications pour Nuvio puis revenez pour continuer.</string>
<string name="updates_message_downloading">Téléchargement de la mise à jour…</string> <string name="updates_message_downloading">Téléchargement de la mise à jour…</string>
<string name="updates_message_no_updates">Aucune mise à jour trouvée.</string> <string name="updates_message_no_updates">Aucune mise à jour trouvée.</string>
<string name="updates_message_ready">Une nouvelle version est prête à être installée.</string> <string name="updates_message_ready">Une nouvelle version est prête à être installée.</string>
@ -1008,22 +1008,22 @@
<string name="updates_title_allow_installs">Autoriser les installations pour continuer</string> <string name="updates_title_allow_installs">Autoriser les installations pour continuer</string>
<string name="updates_title_available">Mise à jour disponible</string> <string name="updates_title_available">Mise à jour disponible</string>
<string name="updates_title_status">Statut de la mise à jour</string> <string name="updates_title_status">Statut de la mise à jour</string>
<string name="addon_already_installed">Cette extension est déjà installée.</string> <string name="addon_already_installed">Cet addon est déjà installé.</string>
<string name="addon_invalid_url">Veuillez saisir une URL d\'extension valide</string> <string name="addon_invalid_url">Veuillez saisir une URL d'addon valide</string>
<string name="addon_load_manifest_failed">Impossible de charger le manifeste</string> <string name="addon_load_manifest_failed">Impossible de charger le manifeste</string>
<string name="app_brand_name">Nuvio</string> <string name="app_brand_name">Nuvio</string>
<string name="auth_account_deletion_failed">Impossible de supprimer le compte</string> <string name="auth_account_deletion_failed">Impossible de supprimer le compte</string>
<string name="auth_sign_in_failed">Échec de la connexion</string> <string name="auth_sign_in_failed">Échec de la connexion</string>
<string name="auth_sign_out_failed">Échec de la déconnexion</string> <string name="auth_sign_out_failed">Échec de la déconnexion</string>
<string name="auth_sign_up_failed">Échec de l\'inscription</string> <string name="auth_sign_up_failed">Échec de l'inscription</string>
<string name="catalog_load_failed">Impossible de charger les éléments du catalogue.</string> <string name="catalog_load_failed">Impossible de charger les éléments du catalogue.</string>
<string name="continue_watching_up_next">À suivre</string> <string name="continue_watching_up_next">À suivre</string>
<string name="continue_watching_up_next_episode">À suivre • S%1$dE%2$d</string> <string name="continue_watching_up_next_episode">À suivre • S%1$dE%2$d</string>
<string name="detail_logo_content_description">logo de %1$s</string> <string name="detail_logo_content_description">logo de %1$s</string>
<string name="details_comments_load_failed">Impossible de charger les commentaires</string> <string name="details_comments_load_failed">Impossible de charger les commentaires</string>
<string name="details_load_failed_all_addons">Impossible de charger les détails depuis aucune extension.</string> <string name="details_load_failed_all_addons">Impossible de charger les détails depuis aucun addon.</string>
<string name="details_networks">Réseaux</string> <string name="details_networks">Réseaux</string>
<string name="details_no_addon_meta">Aucune extension ne fournit de métadonnées pour ce contenu.</string> <string name="details_no_addon_meta">Aucun addon ne fournit de métadonnées pour ce contenu.</string>
<string name="download_failed">Téléchargement échoué</string> <string name="download_failed">Téléchargement échoué</string>
<string name="downloads_channel_description">Affiche la progression en direct et les contrôles de téléchargement.</string> <string name="downloads_channel_description">Affiche la progression en direct et les contrôles de téléchargement.</string>
<string name="downloads_channel_name">Téléchargements</string> <string name="downloads_channel_name">Téléchargements</string>
@ -1036,18 +1036,18 @@
<string name="library_remove_message">Supprimer %1$s de votre bibliothèque ?</string> <string name="library_remove_message">Supprimer %1$s de votre bibliothèque ?</string>
<string name="library_remove_title">Retirer de la bibliothèque ?</string> <string name="library_remove_title">Retirer de la bibliothèque ?</string>
<string name="media_movie">Film</string> <string name="media_movie">Film</string>
<string name="notifications_channel_episode_releases_description">Alertes lorsqu\'un nouvel épisode d\'une série sauvegardée est disponible.</string> <string name="notifications_channel_episode_releases_description">Alertes lorsqu'un nouvel épisode d'une série sauvegardée est disponible.</string>
<string name="notifications_test_preview_body">Aperçu de l\'alerte de sortie d\'épisode.</string> <string name="notifications_test_preview_body">Aperçu de l'alerte de sortie d'épisode.</string>
<string name="notifications_test_send_failed">Impossible d\'envoyer une notification de test.</string> <string name="notifications_test_send_failed">Impossible d'envoyer une notification de test.</string>
<string name="notifications_test_sent_for">Notification de test envoyée pour %1$s.</string> <string name="notifications_test_sent_for">Notification de test envoyée pour %1$s.</string>
<string name="player_unable_to_play_stream">Impossible de lire ce stream.</string> <string name="player_unable_to_play_stream">Impossible de lire ce stream.</string>
<string name="profile_pin_changed_requires_refresh">Le code PIN de ce profil a changé. Connectez-vous une fois pour mettre à jour le verrouillage sur cet appareil.</string> <string name="profile_pin_changed_requires_refresh">Le code PIN de ce profil a changé. Connectez-vous une fois pour mettre à jour le verrouillage sur cet appareil.</string>
<string name="profile_pin_clear_failed">Impossible de supprimer le verrouillage PIN. Veuillez réessayer.</string> <string name="profile_pin_clear_failed">Impossible de supprimer le verrouillage PIN. Veuillez réessayer.</string>
<string name="profile_pin_clear_requires_internet">Connectez-vous à Internet pour supprimer le verrouillage PIN.</string> <string name="profile_pin_clear_requires_internet">Connectez-vous à Internet pour supprimer le verrouillage PIN.</string>
<string name="profile_pin_offline_verification_requires_online">Ce code PIN ne peut pas encore être vérifié hors ligne sur cet appareil. Connectez-vous une fois et déverrouillez-le en ligne d\'abord.</string> <string name="profile_pin_offline_verification_requires_online">Ce code PIN ne peut pas encore être vérifié hors ligne sur cet appareil. Connectez-vous une fois et déverrouillez-le en ligne d'abord.</string>
<string name="profile_pin_set_failed">Impossible de définir le code PIN. Veuillez réessayer.</string> <string name="profile_pin_set_failed">Impossible de définir le code PIN. Veuillez réessayer.</string>
<string name="profile_pin_set_requires_internet">Connectez-vous à Internet pour définir un code PIN.</string> <string name="profile_pin_set_requires_internet">Connectez-vous à Internet pour définir un code PIN.</string>
<string name="profile_primary_addons_required">Ce profil utilise les extensions principales.</string> <string name="profile_primary_addons_required">Ce profil utilise les addons principales.</string>
<string name="streams_failed_to_load_scraper">Impossible de charger %1$s</string> <string name="streams_failed_to_load_scraper">Impossible de charger %1$s</string>
<string name="stream_default_name">Source</string> <string name="stream_default_name">Source</string>
<string name="source_embedded">Intégré</string> <string name="source_embedded">Intégré</string>
@ -1058,7 +1058,7 @@
<string name="trakt_invalid_token_response">Réponse de jeton Trakt invalide</string> <string name="trakt_invalid_token_response">Réponse de jeton Trakt invalide</string>
<string name="trakt_library_load_failed">Impossible de charger la bibliothèque Trakt</string> <string name="trakt_library_load_failed">Impossible de charger la bibliothèque Trakt</string>
<string name="trakt_list_fallback_title">Liste %1$d</string> <string name="trakt_list_fallback_title">Liste %1$d</string>
<string name="trakt_missing_auth_code">Trakt n\'a pas retourné de code d\'autorisation</string> <string name="trakt_missing_auth_code">Trakt n'a pas retourné de code d'autorisation</string>
<string name="trakt_missing_credentials">Identifiants Trakt manquants</string> <string name="trakt_missing_credentials">Identifiants Trakt manquants</string>
<string name="trakt_progress_load_failed">Impossible de charger la progression Trakt</string> <string name="trakt_progress_load_failed">Impossible de charger la progression Trakt</string>
<string name="trakt_sign_in_complete_failed">Impossible de terminer la connexion Trakt</string> <string name="trakt_sign_in_complete_failed">Impossible de terminer la connexion Trakt</string>
@ -1066,18 +1066,18 @@
<string name="trakt_watchlist">Liste de suivi</string> <string name="trakt_watchlist">Liste de suivi</string>
<string name="generic_trailer">Bande-annonce</string> <string name="generic_trailer">Bande-annonce</string>
<string name="generic_unknown">Inconnu</string> <string name="generic_unknown">Inconnu</string>
<string name="generic_addon">Extension</string> <string name="generic_addon">Addon</string>
<string name="action_saved">Enregistré</string> <string name="action_saved">Enregistré</string>
<string name="action_play_episode">Lire %1$s</string> <string name="action_play_episode">Lire %1$s</string>
<string name="action_resume_episode">Reprendre %1$s</string> <string name="action_resume_episode">Reprendre %1$s</string>
<string name="collections_import_error_empty_json">Le JSON est vide.</string> <string name="collections_import_error_empty_json">Le JSON est vide.</string>
<string name="collections_import_error_collection_blank_id">La collection %1$d a un ID vide.</string> <string name="collections_import_error_collection_blank_id">La collection %1$d a un ID vide.</string>
<string name="collections_import_error_collection_blank_title">La collection \'%1$s\' a un titre vide.</string> <string name="collections_import_error_collection_blank_title">La collection \'%1$s' a un titre vide.</string>
<string name="collections_import_error_folder_blank_id">Le dossier %1$d dans \'%2$s\' a un ID vide.</string> <string name="collections_import_error_folder_blank_id">Le dossier %1$d dans \'%2$s' a un ID vide.</string>
<string name="collections_import_error_folder_blank_title">Le dossier \'%1$s\' dans \'%2$s\' a un titre vide.</string> <string name="collections_import_error_folder_blank_title">Le dossier \'%1$s\' dans \'%2$s\' a un titre vide.</string>
<string name="collections_import_error_source_blank_fields">La source %1$d dans le dossier \'%2$s\' a des champs vides.</string> <string name="collections_import_error_source_blank_fields">La source %1$d dans le dossier \'%2$s\' a des champs vides.</string>
<string name="collections_import_error_invalid_json">JSON invalide : %1$s</string> <string name="collections_import_error_invalid_json">JSON invalide : %1$s</string>
<string name="collections_folder_addon_not_found">Extension introuvable : %1$s</string> <string name="collections_folder_addon_not_found">Addon introuvable : %1$s</string>
<string name="date_month_january">Janvier</string> <string name="date_month_january">Janvier</string>
<string name="date_month_february">Février</string> <string name="date_month_february">Février</string>
<string name="date_month_march">Mars</string> <string name="date_month_march">Mars</string>
@ -1112,7 +1112,7 @@
<string name="details_certification">Classification</string> <string name="details_certification">Classification</string>
<string name="details_movie_details">Détails du film</string> <string name="details_movie_details">Détails du film</string>
<string name="details_original_language">Langue originale</string> <string name="details_original_language">Langue originale</string>
<string name="details_origin_country">Pays d\'origine</string> <string name="details_origin_country">Pays d'origine</string>
<string name="details_release_info">Informations de sortie</string> <string name="details_release_info">Informations de sortie</string>
<string name="details_runtime">Durée</string> <string name="details_runtime">Durée</string>
<string name="details_season_view_posters">Affiches</string> <string name="details_season_view_posters">Affiches</string>
@ -1127,7 +1127,7 @@
<string name="downloads_enqueue_unsupported_format">Format de stream non pris en charge pour les téléchargements</string> <string name="downloads_enqueue_unsupported_format">Format de stream non pris en charge pour les téléchargements</string>
<string name="downloads_error_empty_body">Corps de réponse vide</string> <string name="downloads_error_empty_body">Corps de réponse vide</string>
<string name="downloads_error_http_failed">La requête a échoué avec HTTP %1$d</string> <string name="downloads_error_http_failed">La requête a échoué avec HTTP %1$d</string>
<string name="downloads_error_not_initialized">Le système de téléchargement n\'est pas initialisé</string> <string name="downloads_error_not_initialized">Le système de téléchargement n'est pas initialisé</string>
<string name="downloads_error_request_failed">La requête de téléchargement a échoué</string> <string name="downloads_error_request_failed">La requête de téléchargement a échoué</string>
<string name="home_catalog_default_title">%1$s - %2$s</string> <string name="home_catalog_default_title">%1$s - %2$s</string>
<string name="library_empty_message">Les titres enregistrés apparaîtront ici après avoir appuyé sur Enregistrer dans un écran de détails.</string> <string name="library_empty_message">Les titres enregistrés apparaîtront ici après avoir appuyé sur Enregistrer dans un écran de détails.</string>
@ -1148,7 +1148,7 @@
<string name="notifications_episode_release_body_code_title">%1$s • %2$s est maintenant disponible</string> <string name="notifications_episode_release_body_code_title">%1$s • %2$s est maintenant disponible</string>
<string name="notifications_episode_release_body_generic">Un nouvel épisode est maintenant disponible</string> <string name="notifications_episode_release_body_generic">Un nouvel épisode est maintenant disponible</string>
<string name="notifications_episode_release_body_title">%1$s est maintenant disponible</string> <string name="notifications_episode_release_body_title">%1$s est maintenant disponible</string>
<string name="notifications_channel_episode_releases_name">Sorties d\'épisodes</string> <string name="notifications_channel_episode_releases_name">Sorties d'épisodes</string>
<string name="person_role_creator">Créateur</string> <string name="person_role_creator">Créateur</string>
<string name="person_role_director">Réalisateur</string> <string name="person_role_director">Réalisateur</string>
<string name="person_role_writer">Scénariste</string> <string name="person_role_writer">Scénariste</string>

View file

@ -94,7 +94,7 @@
<string name="collections_editor_shape_square">Quadrato</string> <string name="collections_editor_shape_square">Quadrato</string>
<string name="collections_editor_shape_wide">Orizzontale</string> <string name="collections_editor_shape_wide">Orizzontale</string>
<string name="collections_editor_show_all_tab_desc">Combina tutti i cataloghi in una singola scheda</string> <string name="collections_editor_show_all_tab_desc">Combina tutti i cataloghi in una singola scheda</string>
<string name="collections_editor_show_all_tab">Mostra scheda \"Tutti\"</string> <string name="collections_editor_show_all_tab">Mostra scheda "Tutti"</string>
<string name="collections_editor_show_gif_when_configured_desc">Riproduci la GIF configurata al posto della copertina statica quando disponibile.</string> <string name="collections_editor_show_gif_when_configured_desc">Riproduci la GIF configurata al posto della copertina statica quando disponibile.</string>
<string name="collections_editor_show_gif_when_configured">Mostra GIF se configurata</string> <string name="collections_editor_show_gif_when_configured">Mostra GIF se configurata</string>
<string name="collections_editor_source_count">%1$d sorgenti · %2$s</string> <string name="collections_editor_source_count">%1$d sorgenti · %2$s</string>
@ -297,7 +297,7 @@
<string name="settings_appearance_amoled_description">Usa sfondi neri assoluti per schermi OLED.</string> <string name="settings_appearance_amoled_description">Usa sfondi neri assoluti per schermi OLED.</string>
<string name="settings_appearance_app_language">Lingua app</string> <string name="settings_appearance_app_language">Lingua app</string>
<string name="settings_appearance_app_language_sheet_title">Scegli lingua</string> <string name="settings_appearance_app_language_sheet_title">Scegli lingua</string>
<string name="settings_appearance_continue_watching_description">Mostra, nascondi e personalizza lo stile della riga \"Continua a guardare\".</string> <string name="settings_appearance_continue_watching_description">Mostra, nascondi e personalizza lo stile della riga "Continua a guardare".</string>
<string name="settings_appearance_poster_customization_description">Regola la larghezza delle locandine e i preset del raggio degli angoli.</string> <string name="settings_appearance_poster_customization_description">Regola la larghezza delle locandine e i preset del raggio degli angoli.</string>
<string name="settings_appearance_section_display">DISPLAY</string> <string name="settings_appearance_section_display">DISPLAY</string>
<string name="settings_appearance_section_home">HOME</string> <string name="settings_appearance_section_home">HOME</string>
@ -355,15 +355,15 @@
<string name="settings_continue_watching_resume_prompt_title">Richiesta ripresa all'avvio</string> <string name="settings_continue_watching_resume_prompt_title">Richiesta ripresa all'avvio</string>
<string name="settings_continue_watching_section_card_style">STILE SCHEDA</string> <string name="settings_continue_watching_section_card_style">STILE SCHEDA</string>
<string name="settings_continue_watching_section_on_launch">ALL'AVVIO</string> <string name="settings_continue_watching_section_on_launch">ALL'AVVIO</string>
<string name="settings_continue_watching_section_up_next_behavior">COMPORTAMENTO \"PROSSIMO EPISODIO\"</string> <string name="settings_continue_watching_section_up_next_behavior">COMPORTAMENTO "PROSSIMO EPISODIO"</string>
<string name="settings_continue_watching_section_visibility">VISIBILITÀ</string> <string name="settings_continue_watching_section_visibility">VISIBILITÀ</string>
<string name="settings_continue_watching_show_description">Mostra la riga \"Continua a guardare\" nella schermata Home.</string> <string name="settings_continue_watching_show_description">Mostra la riga "Continua a guardare" nella schermata Home.</string>
<string name="settings_continue_watching_show_title">Mostra Continua a guardare</string> <string name="settings_continue_watching_show_title">Mostra Continua a guardare</string>
<string name="settings_continue_watching_style_poster">Locandina</string> <string name="settings_continue_watching_style_poster">Locandina</string>
<string name="settings_continue_watching_style_poster_description">Scheda focalizzata sulla locandina</string> <string name="settings_continue_watching_style_poster_description">Scheda focalizzata sulla locandina</string>
<string name="settings_continue_watching_style_wide">Orizzontale</string> <string name="settings_continue_watching_style_wide">Orizzontale</string>
<string name="settings_continue_watching_style_wide_description">Scheda orizzontale ricca di informazioni</string> <string name="settings_continue_watching_style_wide_description">Scheda orizzontale ricca di informazioni</string>
<string name="settings_continue_watching_up_next_description">Se abilitato, \"Prossimo episodio\" continua sempre dall'ultimo episodio visto. Se disabilitato, segue l'episodio visto più di recente. Utile se riguardi spesso episodi precedenti.</string> <string name="settings_continue_watching_up_next_description">Se abilitato, "Prossimo episodio" continua sempre dall'ultimo episodio visto. Se disabilitato, segue l'episodio visto più di recente. Utile se riguardi spesso episodi precedenti.</string>
<string name="settings_continue_watching_up_next_title">Prossimo episodio dall'ultimo visto</string> <string name="settings_continue_watching_up_next_title">Prossimo episodio dall'ultimo visto</string>
<string name="settings_content_discovery_section_home">HOME</string> <string name="settings_content_discovery_section_home">HOME</string>
<string name="settings_content_discovery_section_sources">SORGENTI</string> <string name="settings_content_discovery_section_sources">SORGENTI</string>
@ -502,7 +502,7 @@
<string name="settings_playback_option_forced">Forzati</string> <string name="settings_playback_option_forced">Forzati</string>
<string name="settings_playback_option_none">Nessuno</string> <string name="settings_playback_option_none">Nessuno</string>
<string name="settings_playback_prefer_binge_group">Preferisci Binge Group</string> <string name="settings_playback_prefer_binge_group">Preferisci Binge Group</string>
<string name="settings_playback_prefer_binge_group_description">Durante la riproduzione automatica, preferisci un flusso dello stesso \"binge group\" di quello attuale.</string> <string name="settings_playback_prefer_binge_group_description">Durante la riproduzione automatica, preferisci un flusso dello stesso "binge group" di quello attuale.</string>
<string name="settings_playback_preferred_audio_language">Lingua audio preferita</string> <string name="settings_playback_preferred_audio_language">Lingua audio preferita</string>
<string name="settings_playback_preferred_subtitle_language">Lingua sottotitoli preferita</string> <string name="settings_playback_preferred_subtitle_language">Lingua sottotitoli preferita</string>
<string name="settings_playback_presets">Preset</string> <string name="settings_playback_presets">Preset</string>
@ -544,7 +544,7 @@
<string name="settings_playback_show_loading_overlay">Mostra overlay di caricamento</string> <string name="settings_playback_show_loading_overlay">Mostra overlay di caricamento</string>
<string name="settings_playback_show_loading_overlay_description">Mostra una schermata di caricamento all'avvio della riproduzione di un flusso.</string> <string name="settings_playback_show_loading_overlay_description">Mostra una schermata di caricamento all'avvio della riproduzione di un flusso.</string>
<string name="settings_playback_skip_intro_outro_recap">Salta Intro/Outro/Recap</string> <string name="settings_playback_skip_intro_outro_recap">Salta Intro/Outro/Recap</string>
<string name="settings_playback_skip_intro_outro_recap_description">Mostra il pulsante \"salta\" durante i segmenti rilevati di introduzione, chiusura e riassunto.</string> <string name="settings_playback_skip_intro_outro_recap_description">Mostra il pulsante "salta" durante i segmenti rilevati di introduzione, chiusura e riassunto.</string>
<string name="settings_playback_source_scope">Ambito sorgente</string> <string name="settings_playback_source_scope">Ambito sorgente</string>
<string name="settings_playback_source_scope_all_addons">Tutti gli addon</string> <string name="settings_playback_source_scope_all_addons">Tutti gli addon</string>
<string name="settings_playback_source_scope_all_addons_description">Considera i flussi da tutti gli addon installati.</string> <string name="settings_playback_source_scope_all_addons_description">Considera i flussi da tutti gli addon installati.</string>
@ -819,7 +819,7 @@
<string name="profile_choose_avatar">Scegli un avatar</string> <string name="profile_choose_avatar">Scegli un avatar</string>
<string name="profile_choose_avatar_below">Scegli un avatar qui sotto.</string> <string name="profile_choose_avatar_below">Scegli un avatar qui sotto.</string>
<string name="profile_create_profile">Crea profilo</string> <string name="profile_create_profile">Crea profilo</string>
<string name="profile_delete_confirm_message">Tutti i dati di \"%1$s\" verranno eliminati permanentemente.</string> <string name="profile_delete_confirm_message">Tutti i dati di "%1$s" verranno eliminati permanentemente.</string>
<string name="profile_delete_title">Elimina profilo</string> <string name="profile_delete_title">Elimina profilo</string>
<string name="profile_edit_add_title">Aggiungi profilo</string> <string name="profile_edit_add_title">Aggiungi profilo</string>
<string name="profile_edit_edit_title">Modifica profilo</string> <string name="profile_edit_edit_title">Modifica profilo</string>
@ -1040,4 +1040,134 @@
<string name="unit_bytes_kb">KB</string> <string name="unit_bytes_kb">KB</string>
<string name="unit_bytes_mb">MB</string> <string name="unit_bytes_mb">MB</string>
<string name="unit_bytes_gb">GB</string> <string name="unit_bytes_gb">GB</string>
<string name="collections_editor_selected_count">%1$d selezionati</string>
<string name="collections_editor_catalog_count">%1$d cataloghi</string>
<string name="collections_editor_catalog_selected_count">%1$d selezionati</string>
<string name="collections_editor_tmdb_sources">Sorgenti TMDB</string>
<string name="collections_editor_tmdb_public_list_mode">Lista pubblica</string>
<string name="collections_editor_tmdb_production_mode">Produzione</string>
<string name="collections_editor_tmdb_network_mode">Network</string>
<string name="collections_editor_tmdb_collection_mode">Collezione</string>
<string name="collections_editor_tmdb_person_mode">Persona</string>
<string name="collections_editor_tmdb_director_mode">Regista</string>
<string name="collections_editor_tmdb_custom_mode">Personalizzato</string>
<string name="collections_editor_tmdb_help_presets">Scegli una sorgente pronta all\'uso. Puoi modificarla o rimuoverla dopo averla aggiunta.</string>
<string name="collections_editor_tmdb_help_list">Incolla l\'URL di una lista pubblica TMDB o solo l\'ID numerico dall\'URL.</string>
<string name="collections_editor_tmdb_help_production">Cerca per nome dello studio, oppure incolla l\'ID/URL di una casa di produzione TMDB per aggiungerla direttamente.</string>
<string name="collections_editor_tmdb_help_network">Inserisci un ID network. I network più comuni sono disponibili nei Preset e nei filtri rapidi.</string>
<string name="collections_editor_tmdb_help_collection">Cerca il nome di una collezione di film o incolla l\'ID collezione da TMDB.</string>
<string name="collections_editor_tmdb_help_person">Inserisci l\'ID o l\'URL di una persona su TMDB per creare una riga basata sul cast.</string>
<string name="collections_editor_tmdb_help_director">Inserisci l\'ID o l\'URL di una persona su TMDB per creare una riga basata sulla regia.</string>
<string name="collections_editor_tmdb_help_discover">Crea una riga dinamica TMDB usando filtri opzionali. Lascia i campi vuoti se non ti serve un filtro specifico.</string>
<string name="collections_editor_tmdb_public_list">Lista pubblica TMDB</string>
<string name="collections_editor_tmdb_network_id">ID Network</string>
<string name="collections_editor_tmdb_collection_id">ID Collezione</string>
<string name="collections_editor_tmdb_person_id">ID Persona</string>
<string name="collections_editor_tmdb_company_search">Nome, ID o URL casa di produzione</string>
<string name="collections_editor_tmdb_id_or_url">ID o URL TMDB</string>
<string name="collections_editor_tmdb_list_placeholder">https://www.themoviedb.org/list/8504994 o 8504994</string>
<string name="collections_editor_tmdb_network_placeholder">213 per Netflix, 49 per HBO, 2739 per Disney+</string>
<string name="collections_editor_tmdb_collection_placeholder">10 per Star Wars Collection</string>
<string name="collections_editor_tmdb_company_placeholder">Marvel Studios, 420, o URL società</string>
<string name="collections_editor_tmdb_person_placeholder">31 per Tom Hanks, o URL persona</string>
<string name="collections_editor_tmdb_search_helper">Esempi: Marvel Studios, 420, o https://www.themoviedb.org/company/420.</string>
<string name="collections_editor_tmdb_collection_helper">Esempio: Star Wars Collection, Harry Potter Collection, o URL collezione.</string>
<string name="collections_editor_tmdb_network_helper">Esempi ID: Netflix 213, HBO 49, Disney+ 2739.</string>
<string name="collections_editor_tmdb_list_helper">Esempio: https://www.themoviedb.org/list/8504994 o 8504994.</string>
<string name="collections_editor_tmdb_person_helper">Esempio: https://www.themoviedb.org/person/31-tom-hanks o 31.</string>
<string name="collections_editor_tmdb_display_title">Titolo visualizzato</string>
<string name="collections_editor_tmdb_title_helper">Appare come nome della riga/scheda. Se vuoto, Nuvio ne creerà uno dalla sorgente.</string>
<string name="collections_editor_tmdb_title_placeholder">Film Marvel, Originali Netflix, Pixar</string>
<string name="collections_editor_tmdb_person_title_placeholder">Film con Tom Hanks, Attori preferiti</string>
<string name="collections_editor_tmdb_director_title_placeholder">Film di Christopher Nolan, Registi preferiti</string>
<string name="collections_editor_tmdb_discover_title_placeholder">Migliori film d\'azione, Drama coreani, Animazione 2024</string>
<string name="collections_editor_tmdb_search_results">Risultati della ricerca</string>
<string name="collections_editor_tmdb_collection">Collezione TMDB</string>
<string name="collections_editor_tmdb_company_fallback">Società TMDB %1$d</string>
<string name="collections_editor_tmdb_collection_fallback">Collezione TMDB %1$d</string>
<string name="collections_editor_tmdb_type">Tipo</string>
<string name="collections_editor_tmdb_movies">Film</string>
<string name="collections_editor_tmdb_series">Serie TV</string>
<string name="collections_editor_tmdb_both">Entrambi</string>
<string name="collections_editor_tmdb_sort">Ordina</string>
<string name="collections_editor_tmdb_filters">Filtri</string>
<string name="collections_editor_tmdb_filters_helper">Lascia i campi vuoti se non ti serve quel filtro.</string>
<string name="collections_editor_tmdb_quick_genres">Generi rapidi</string>
<string name="collections_editor_tmdb_quick_languages">Lingue rapide</string>
<string name="collections_editor_tmdb_quick_countries">Paesi rapidi</string>
<string name="collections_editor_tmdb_quick_keywords">Parole chiave rapide</string>
<string name="collections_editor_tmdb_quick_studios">Studi rapidi</string>
<string name="collections_editor_tmdb_quick_networks">Network rapidi</string>
<string name="collections_editor_tmdb_genres">ID Generi</string>
<string name="collections_editor_tmdb_genres_helper">Usa i numeri dei generi TMDB. Separa con la virgola per AND, o con la barra verticale (pipe) per OR.</string>
<string name="collections_editor_tmdb_date_from">Data uscita dal</string>
<string name="collections_editor_tmdb_date_to">Data uscita al</string>
<string name="collections_editor_tmdb_date_helper">Usa AAAA-MM-GG, ad esempio 2024-01-01.</string>
<string name="collections_editor_tmdb_rating_min">Voto minimo</string>
<string name="collections_editor_tmdb_rating_max">Voto massimo</string>
<string name="collections_editor_tmdb_rating_helper">Valutazione TMDB da 0 a 10. Esempio: 7.0.</string>
<string name="collections_editor_tmdb_votes_min">Voti minimi</string>
<string name="collections_editor_tmdb_votes_helper">Usa questo per evitare titoli poco noti con pochi voti. Esempio: 100.</string>
<string name="collections_editor_tmdb_language">Lingua originale</string>
<string name="collections_editor_tmdb_language_helper">Usa codici lingua a due lettere, ad esempio it, en, ko.</string>
<string name="collections_editor_tmdb_country">Paese d\'origine</string>
<string name="collections_editor_tmdb_country_helper">Usa codici paese a due lettere, ad esempio IT, US, KR.</string>
<string name="collections_editor_tmdb_keywords">ID Parole chiave</string>
<string name="collections_editor_tmdb_keywords_helper">Usa i numeri delle parole chiave TMDB. I suggerimenti rapidi contengono esempi comuni.</string>
<string name="collections_editor_tmdb_keywords_placeholder">9715 per supereroi</string>
<string name="collections_editor_tmdb_companies">ID Società</string>
<string name="collections_editor_tmdb_companies_helper">Usa gli ID degli studi/società. I suggerimenti rapidi contengono esempi comuni.</string>
<string name="collections_editor_tmdb_companies_placeholder">420 per Marvel Studios</string>
<string name="collections_editor_tmdb_networks">ID Network</string>
<string name="collections_editor_tmdb_networks_helper">Solo per le serie TV. Usa ID network come Netflix (213) o HBO (49).</string>
<string name="collections_editor_tmdb_networks_placeholder">213 per Netflix</string>
<string name="collections_editor_tmdb_year">Anno</string>
<string name="collections_editor_tmdb_year_helper">Usa l\'anno a quattro cifre, ad esempio 2024.</string>
<string name="collections_editor_tmdb_presets">Preset</string>
<string name="collections_editor_tmdb_search">Cerca</string>
<string name="collections_editor_add_source">Aggiungi sorgente</string>
<string name="collections_editor_tmdb_genre_action">Azione</string>
<string name="collections_editor_tmdb_genre_adventure">Avventura</string>
<string name="collections_editor_tmdb_genre_animation">Animazione</string>
<string name="collections_editor_tmdb_genre_comedy">Commedia</string>
<string name="collections_editor_tmdb_genre_horror">Horror</string>
<string name="collections_editor_tmdb_genre_scifi">Fantascienza</string>
<string name="collections_editor_tmdb_genre_drama">Dramma</string>
<string name="collections_editor_tmdb_genre_crime">Crime</string>
<string name="collections_editor_tmdb_genre_reality">Reality</string>
<string name="collections_editor_tmdb_language_english">Inglese</string>
<string name="collections_editor_tmdb_language_korean">Coreano</string>
<string name="collections_editor_tmdb_language_japanese">Giapponese</string>
<string name="collections_editor_tmdb_language_hindi">Hindi</string>
<string name="collections_editor_tmdb_language_spanish">Spagnolo</string>
<string name="collections_editor_tmdb_country_us">Stati Uniti</string>
<string name="collections_editor_tmdb_country_korea">Corea</string>
<string name="collections_editor_tmdb_country_japan">Giappone</string>
<string name="collections_editor_tmdb_country_india">India</string>
<string name="collections_editor_tmdb_country_uk">Regno Unito</string>
<string name="collections_editor_tmdb_keyword_superhero">Supereroi</string>
<string name="collections_editor_tmdb_keyword_based_on_novel">Basato su un romanzo</string>
<string name="collections_editor_tmdb_keyword_time_travel">Viaggio nel tempo</string>
<string name="collections_editor_tmdb_keyword_space">Spazio</string>
<string name="collections_editor_tmdb_studio_marvel">Marvel</string>
<string name="collections_editor_tmdb_studio_disney">Disney</string>
<string name="collections_editor_tmdb_studio_pixar">Pixar</string>
<string name="collections_editor_tmdb_studio_lucasfilm">Lucasfilm</string>
<string name="collections_editor_tmdb_studio_warner">Warner Bros.</string>
<string name="collections_editor_tmdb_network_netflix">Netflix</string>
<string name="collections_editor_tmdb_network_hbo">HBO</string>
<string name="collections_editor_tmdb_network_disney_plus">Disney+</string>
<string name="collections_editor_tmdb_network_prime_video">Prime Video</string>
<string name="collections_editor_tmdb_network_hulu">Hulu</string>
<string name="collections_editor_tmdb_sort_original">Originale</string>
<string name="collections_editor_tmdb_sort_popular">Popolari</string>
<string name="collections_editor_tmdb_sort_top_rated">Più votati</string>
<string name="collections_editor_tmdb_sort_recent">Recenti</string>
<string name="collections_editor_tmdb_subtitle_list">Lista TMDB</string>
<string name="collections_editor_tmdb_subtitle_movie_collection">Collezione film TMDB</string>
<string name="collections_editor_tmdb_subtitle_production">Produzione</string>
<string name="collections_editor_tmdb_subtitle_network">Network</string>
<string name="collections_editor_tmdb_subtitle_person">Persona</string>
<string name="collections_editor_tmdb_subtitle_director">Regista</string>
<string name="collections_editor_tmdb_subtitle_discover">TMDB Discover</string>
</resources> </resources>

View file

@ -188,6 +188,27 @@
<string name="collections_editor_tmdb_presets">Presets</string> <string name="collections_editor_tmdb_presets">Presets</string>
<string name="collections_editor_tmdb_search">Search</string> <string name="collections_editor_tmdb_search">Search</string>
<string name="collections_editor_add_source">Add Source</string> <string name="collections_editor_add_source">Add Source</string>
<string name="collections_editor_add_trakt_source">Add Trakt List</string>
<string name="collections_editor_edit_trakt_source">Edit Trakt List</string>
<string name="collections_editor_trakt_sources">Trakt Lists</string>
<string name="collections_editor_trakt_list">Trakt list</string>
<string name="collections_editor_trakt_input_placeholder">Search title, Trakt URL, or list ID</string>
<string name="collections_editor_trakt_input_helper">Use a public Trakt list URL or numeric list ID, or search by name.</string>
<string name="collections_editor_trakt_title_placeholder">Weekend Watch, Award Winners</string>
<string name="collections_editor_trakt_search_results">Search Results</string>
<string name="collections_editor_trakt_trending">Trending Lists</string>
<string name="collections_editor_trakt_popular">Popular Lists</string>
<string name="collections_editor_trakt_direction">Direction</string>
<string name="collections_editor_trakt_ascending">Ascending</string>
<string name="collections_editor_trakt_descending">Descending</string>
<string name="collections_editor_trakt_sort_list_order">List Order</string>
<string name="collections_editor_trakt_sort_recently_added">Recently Added</string>
<string name="collections_editor_trakt_sort_title">Title</string>
<string name="collections_editor_trakt_sort_released">Released</string>
<string name="collections_editor_trakt_sort_runtime">Runtime</string>
<string name="collections_editor_trakt_sort_popular">Popular</string>
<string name="collections_editor_trakt_sort_percentage">Percentage</string>
<string name="collections_editor_trakt_sort_votes">Votes</string>
<string name="collections_editor_tmdb_genre_action">Action</string> <string name="collections_editor_tmdb_genre_action">Action</string>
<string name="collections_editor_tmdb_genre_adventure">Adventure</string> <string name="collections_editor_tmdb_genre_adventure">Adventure</string>
<string name="collections_editor_tmdb_genre_animation">Animation</string> <string name="collections_editor_tmdb_genre_animation">Animation</string>
@ -1091,6 +1112,7 @@
<string name="collections_import_error_folder_blank_id">Folder %1$d in '%2$s' has blank id.</string> <string name="collections_import_error_folder_blank_id">Folder %1$d in '%2$s' has blank id.</string>
<string name="collections_import_error_folder_blank_title">Folder '%1$s' in '%2$s' has blank title.</string> <string name="collections_import_error_folder_blank_title">Folder '%1$s' in '%2$s' has blank title.</string>
<string name="collections_import_error_source_blank_fields">Source %1$d in folder '%2$s' has blank fields.</string> <string name="collections_import_error_source_blank_fields">Source %1$d in folder '%2$s' has blank fields.</string>
<string name="collections_import_error_trakt_list_id">Source %1$d in folder '%2$s' is missing a Trakt list ID.</string>
<string name="collections_import_error_invalid_json">Invalid JSON: %1$s</string> <string name="collections_import_error_invalid_json">Invalid JSON: %1$s</string>
<string name="collections_folder_addon_not_found">Addon not found: %1$s</string> <string name="collections_folder_addon_not_found">Addon not found: %1$s</string>
<string name="date_month_january">January</string> <string name="date_month_january">January</string>

View file

@ -596,7 +596,9 @@ private fun MainAppContent(
NetworkCondition.ServersUnreachable, NetworkCondition.ServersUnreachable,
-> { -> {
offlineLaunchRouteHandled = true offlineLaunchRouteHandled = true
val hasPlayableDownload = downloadsUiState.completedItems.any { it.isPlayable } val hasPlayableDownload = downloadsUiState.completedItems.any {
DownloadsRepository.playableLocalFileUri(it) != null
}
if (hasPlayableDownload) { if (hasPlayableDownload) {
selectedTab = AppScreenTab.Settings selectedTab = AppScreenTab.Settings
navController.navigate(DownloadsSettingsRoute) { navController.navigate(DownloadsSettingsRoute) {
@ -689,7 +691,7 @@ private fun MainAppContent(
episodeNumber = episodeNumber, episodeNumber = episodeNumber,
videoId = videoId, videoId = videoId,
) )
val localSourceUrl = downloadedItem?.localFileUri val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri)
if (!localSourceUrl.isNullOrBlank()) { if (!localSourceUrl.isNullOrBlank()) {
val launchId = PlayerLaunchStore.put( val launchId = PlayerLaunchStore.put(
PlayerLaunch( PlayerLaunch(
@ -1327,6 +1329,7 @@ private fun MainAppContent(
) )
) )
StreamsRepository.consumeAutoPlay() StreamsRepository.consumeAutoPlay()
StreamsRepository.cancelLoading()
navController.navigate(PlayerRoute(launchId = launchId)) { navController.navigate(PlayerRoute(launchId = launchId)) {
popUpTo<StreamRoute> { inclusive = true } popUpTo<StreamRoute> { inclusive = true }
} }
@ -1405,6 +1408,7 @@ private fun MainAppContent(
initialProgressFraction = resolvedResumeProgressFraction, initialProgressFraction = resolvedResumeProgressFraction,
) )
) )
StreamsRepository.cancelLoading()
navController.navigate( navController.navigate(
PlayerRoute(launchId = launchId) PlayerRoute(launchId = launchId)
) )
@ -1531,7 +1535,7 @@ private fun MainAppContent(
DownloadsScreen( DownloadsScreen(
onBack = onBack, onBack = onBack,
onOpenDownload = { item -> onOpenDownload = { item ->
val sourceUrl = item.localFileUri ?: return@DownloadsScreen val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen
val resumeEntry = item.videoId val resumeEntry = item.videoId
.takeIf { it.isNotBlank() } .takeIf { it.isNotBlank() }
?.let(WatchProgressRepository::progressForVideo) ?.let(WatchProgressRepository::progressForVideo)

View file

@ -2,6 +2,8 @@ package com.nuvio.app.features.collection
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.trakt.TraktPublicListSearchResult
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -27,6 +29,8 @@ data class CollectionEditorUiState(
val showFolderEditor: Boolean = false, val showFolderEditor: Boolean = false,
val showCatalogPicker: Boolean = false, val showCatalogPicker: Boolean = false,
val showTmdbSourcePicker: Boolean = false, val showTmdbSourcePicker: Boolean = false,
val showTraktSourcePicker: Boolean = false,
val editingTraktSourceIndex: Int? = null,
val genrePickerSourceIndex: Int? = null, val genrePickerSourceIndex: Int? = null,
val tmdbBuilderMode: TmdbBuilderMode = TmdbBuilderMode.PRESETS, val tmdbBuilderMode: TmdbBuilderMode = TmdbBuilderMode.PRESETS,
val tmdbInput: String = "", val tmdbInput: String = "",
@ -38,6 +42,16 @@ data class CollectionEditorUiState(
val tmdbCompanyResults: List<TmdbCompanySearchResult> = emptyList(), val tmdbCompanyResults: List<TmdbCompanySearchResult> = emptyList(),
val tmdbCollectionResults: List<TmdbCollectionSearchResult> = emptyList(), val tmdbCollectionResults: List<TmdbCollectionSearchResult> = emptyList(),
val tmdbSearchError: String? = null, val tmdbSearchError: String? = null,
val traktInput: String = "",
val traktTitleInput: String = "",
val traktMediaType: TmdbCollectionMediaType = TmdbCollectionMediaType.MOVIE,
val traktMediaBoth: Boolean = true,
val traktSortBy: String = TraktListSort.RANK.value,
val traktSortHow: String = TraktSortHow.ASC.value,
val traktSearchResults: List<TraktPublicListSearchResult> = emptyList(),
val traktTrendingResults: List<TraktPublicListSearchResult> = emptyList(),
val traktPopularResults: List<TraktPublicListSearchResult> = emptyList(),
val traktSearchError: String? = null,
) )
enum class TmdbBuilderMode { enum class TmdbBuilderMode {
@ -246,7 +260,7 @@ object CollectionEditorRepository {
fun updateCatalogSourceGenre(index: Int, genre: String?) { fun updateCatalogSourceGenre(index: Int, genre: String?) {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
val sources = folder.resolvedSources val sources = folder.resolvedSources
if (index !in sources.indices || sources[index].isTmdb) return if (index !in sources.indices || sources[index].addonCatalogSource() == null) return
val updated = sources.toMutableList() val updated = sources.toMutableList()
updated[index] = updated[index].copy(genre = genre) updated[index] = updated[index].copy(genre = genre)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
@ -258,7 +272,11 @@ object CollectionEditorRepository {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
val sources = folder.resolvedSources val sources = folder.resolvedSources
val existingIndex = sources.indexOfFirst { val existingIndex = sources.indexOfFirst {
!it.isTmdb && it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId !it.isTmdb &&
!it.isTrakt &&
it.addonId == catalog.addonId &&
it.type == catalog.type &&
it.catalogId == catalog.catalogId
} }
if (existingIndex >= 0) { if (existingIndex >= 0) {
removeCatalogSource(existingIndex) removeCatalogSource(existingIndex)
@ -271,6 +289,8 @@ object CollectionEditorRepository {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
showCatalogPicker = true, showCatalogPicker = true,
showTmdbSourcePicker = false, showTmdbSourcePicker = false,
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
genrePickerSourceIndex = null, genrePickerSourceIndex = null,
) )
} }
@ -283,6 +303,8 @@ object CollectionEditorRepository {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
showTmdbSourcePicker = true, showTmdbSourcePicker = true,
showCatalogPicker = false, showCatalogPicker = false,
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
genrePickerSourceIndex = null, genrePickerSourceIndex = null,
tmdbSearchError = null, tmdbSearchError = null,
) )
@ -292,14 +314,139 @@ object CollectionEditorRepository {
_uiState.value = _uiState.value.copy(showTmdbSourcePicker = false, tmdbSearchError = null) _uiState.value = _uiState.value.copy(showTmdbSourcePicker = false, tmdbSearchError = null)
} }
fun showTraktSourcePicker() {
_uiState.value = _uiState.value.copy(
showTraktSourcePicker = true,
showCatalogPicker = false,
showTmdbSourcePicker = false,
editingTraktSourceIndex = null,
genrePickerSourceIndex = null,
traktInput = "",
traktTitleInput = "",
traktMediaType = TmdbCollectionMediaType.MOVIE,
traktMediaBoth = true,
traktSortBy = TraktListSort.RANK.value,
traktSortHow = TraktSortHow.ASC.value,
traktSearchResults = emptyList(),
traktSearchError = null,
)
loadTraktFeaturedLists()
}
fun hideTraktSourcePicker() {
_uiState.value = _uiState.value.copy(
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
traktSearchError = null,
)
}
fun editTraktSource(index: Int) {
val folder = _uiState.value.editingFolder ?: return
val source = folder.resolvedSources.getOrNull(index) ?: return
if (!source.isTrakt) return
_uiState.value = _uiState.value.copy(
showTraktSourcePicker = true,
showCatalogPicker = false,
showTmdbSourcePicker = false,
editingTraktSourceIndex = index,
genrePickerSourceIndex = null,
traktInput = source.traktListId?.toString().orEmpty(),
traktTitleInput = source.title.orEmpty(),
traktMediaType = TmdbCollectionMediaType.fromString(source.mediaType),
traktMediaBoth = false,
traktSortBy = TraktListSort.normalize(source.sortBy),
traktSortHow = TraktSortHow.normalize(source.sortHow),
traktSearchResults = emptyList(),
traktSearchError = null,
)
loadTraktFeaturedLists()
}
fun setTraktInput(value: String) {
_uiState.value = _uiState.value.copy(traktInput = value, traktSearchError = null)
}
fun setTraktTitleInput(value: String) {
_uiState.value = _uiState.value.copy(traktTitleInput = value)
}
fun setTraktMediaType(value: TmdbCollectionMediaType) {
_uiState.value = _uiState.value.copy(traktMediaType = value, traktMediaBoth = false)
}
fun setTraktMediaBoth(value: Boolean) {
_uiState.value = _uiState.value.copy(
traktMediaBoth = value,
traktMediaType = if (value) TmdbCollectionMediaType.MOVIE else _uiState.value.traktMediaType,
)
}
fun setTraktSortBy(value: String) {
_uiState.value = _uiState.value.copy(traktSortBy = TraktListSort.normalize(value))
}
fun setTraktSortHow(value: String) {
_uiState.value = _uiState.value.copy(traktSortHow = TraktSortHow.normalize(value))
}
fun searchTraktLists() {
val state = _uiState.value
val query = state.traktInput.trim()
if (query.isBlank()) {
_uiState.value = state.copy(traktSearchError = "Enter a Trakt list name, URL, or ID")
return
}
scope.launch {
val results = if (query.isTraktListIdentifierInput()) {
runCatching {
val metadata = TraktPublicListSourceResolver.listImportMetadata(query)
val id = metadata.traktListId ?: error("Could not load Trakt list")
listOf(
TraktPublicListSearchResult(
traktListId = id,
title = metadata.title ?: "Trakt List $id",
subtitle = "Resolved Trakt list",
coverImageUrl = metadata.coverImageUrl,
),
)
}
} else {
runCatching { TraktPublicListSourceResolver.searchPublicLists(query) }
}
val mapped = results.getOrDefault(emptyList())
_uiState.value = _uiState.value.copy(
traktSearchResults = mapped,
traktSearchError = results.exceptionOrNull()?.message
?: if (mapped.isEmpty()) "No Trakt lists found" else null,
)
}
}
private fun loadTraktFeaturedLists() {
scope.launch {
val trending = runCatching { TraktPublicListSourceResolver.trendingPublicLists() }
val popular = runCatching { TraktPublicListSourceResolver.popularPublicLists() }
_uiState.value = _uiState.value.copy(
traktTrendingResults = trending.getOrDefault(_uiState.value.traktTrendingResults),
traktPopularResults = popular.getOrDefault(_uiState.value.traktPopularResults),
traktSearchError = _uiState.value.traktSearchError
?: trending.exceptionOrNull()?.message
?: popular.exceptionOrNull()?.message,
)
}
}
fun showGenrePicker(index: Int) { fun showGenrePicker(index: Int) {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
val sources = folder.resolvedSources val sources = folder.resolvedSources
if (index !in sources.indices || sources[index].isTmdb) return if (index !in sources.indices || sources[index].addonCatalogSource() == null) return
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
genrePickerSourceIndex = index, genrePickerSourceIndex = index,
showCatalogPicker = false, showCatalogPicker = false,
showTmdbSourcePicker = false, showTmdbSourcePicker = false,
showTraktSourcePicker = false,
) )
} }
@ -322,6 +469,8 @@ object CollectionEditorRepository {
showFolderEditor = false, showFolderEditor = false,
showCatalogPicker = false, showCatalogPicker = false,
showTmdbSourcePicker = false, showTmdbSourcePicker = false,
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
genrePickerSourceIndex = null, genrePickerSourceIndex = null,
) )
} }
@ -332,6 +481,8 @@ object CollectionEditorRepository {
showFolderEditor = false, showFolderEditor = false,
showCatalogPicker = false, showCatalogPicker = false,
showTmdbSourcePicker = false, showTmdbSourcePicker = false,
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
genrePickerSourceIndex = null, genrePickerSourceIndex = null,
) )
} }
@ -546,6 +697,103 @@ object CollectionEditorRepository {
) )
} }
fun addTraktSourceFromInput() {
val state = _uiState.value
val input = state.traktInput.trim()
if (input.isBlank()) {
_uiState.value = state.copy(traktSearchError = "Enter a Trakt list ID or URL")
return
}
scope.launch {
val metadata = runCatching { TraktPublicListSourceResolver.listImportMetadata(input) }
val resolved = metadata.getOrNull()
val listId = resolved?.traktListId
if (metadata.isFailure || listId == null) {
_uiState.value = _uiState.value.copy(
traktSearchError = metadata.exceptionOrNull()?.message ?: "Could not load Trakt list",
)
return@launch
}
val title = state.traktTitleInput.ifBlank { resolved.title ?: "Trakt List $listId" }
addTraktSourcesToFolder(
sources = selectedTraktMediaTypes(state).map { mediaType ->
CollectionSource(
provider = "trakt",
title = titleForMedia(title, mediaType, state.traktMediaBoth),
traktListId = listId,
mediaType = mediaType.name,
sortBy = TraktListSort.normalize(state.traktSortBy),
sortHow = TraktSortHow.normalize(state.traktSortHow),
)
},
coverImageUrl = resolved.coverImageUrl,
)
}
}
fun addTraktSourceFromResult(result: TraktPublicListSearchResult) {
val state = _uiState.value
val title = state.traktTitleInput.ifBlank { result.title }
addTraktSourcesToFolder(
sources = selectedTraktMediaTypes(state).map { mediaType ->
CollectionSource(
provider = "trakt",
title = titleForMedia(title, mediaType, state.traktMediaBoth),
traktListId = result.traktListId,
mediaType = mediaType.name,
sortBy = TraktListSort.normalize(state.traktSortBy),
sortHow = TraktSortHow.normalize(state.traktSortHow),
)
},
coverImageUrl = result.coverImageUrl,
)
}
private fun addTraktSourcesToFolder(sources: List<CollectionSource>, coverImageUrl: String? = null) {
val state = _uiState.value
val folder = state.editingFolder ?: return
val editingIndex = state.editingTraktSourceIndex
val existingKeys = folder.resolvedSources
.mapIndexedNotNull { index, source ->
collectionSourceKey(source).takeUnless { index == editingIndex }
}
.toMutableSet()
val newSources = sources.filter { existingKeys.add(collectionSourceKey(it)) }
if (newSources.isEmpty()) return
val updatedSources = if (
editingIndex != null &&
editingIndex in folder.resolvedSources.indices &&
folder.resolvedSources[editingIndex].isTrakt
) {
folder.resolvedSources.toMutableList().also {
it.removeAt(editingIndex)
it.addAll(editingIndex, newSources)
}
} else {
folder.resolvedSources + newSources
}
val shouldApplyCover = !coverImageUrl.isNullOrBlank() && folder.coverImageUrl.isNullOrBlank()
val updatedFolder = if (shouldApplyCover) {
folder.withSources(updatedSources)
.copy(coverImageUrl = coverImageUrl, coverEmoji = null)
} else {
folder.withSources(updatedSources)
}
_uiState.value = state.copy(
editingFolder = updatedFolder,
showTraktSourcePicker = false,
editingTraktSourceIndex = null,
traktInput = "",
traktTitleInput = "",
traktSearchResults = emptyList(),
traktSearchError = null,
)
}
fun save(): Boolean { fun save(): Boolean {
val state = _uiState.value val state = _uiState.value
if (state.title.isBlank()) return false if (state.title.isBlank()) return false
@ -593,10 +841,18 @@ private fun CollectionFolder.withSources(nextSources: List<CollectionSource>): C
) )
private fun collectionSourceKey(source: CollectionSource): String = private fun collectionSourceKey(source: CollectionSource): String =
if (source.isTmdb) { when {
"tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}" source.isTmdb -> {
} else { "tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}"
"addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}" }
source.isTrakt -> {
"trakt_${source.traktListId}_${source.mediaType}_${TraktListSort.normalize(source.sortBy)}_${TraktSortHow.normalize(source.sortHow)}"
}
else -> {
"addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}"
}
} }
private fun selectedMediaTypes( private fun selectedMediaTypes(
@ -630,7 +886,22 @@ private fun titleForMedia(
return "$title $suffix" return "$title $suffix"
} }
private fun selectedTraktMediaTypes(state: CollectionEditorUiState): List<TmdbCollectionMediaType> =
if (state.traktMediaBoth) {
listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV)
} else {
listOf(state.traktMediaType)
}
private fun CollectionSource.tmdbType(): TmdbCollectionSourceType = private fun CollectionSource.tmdbType(): TmdbCollectionSourceType =
tmdbSourceType tmdbSourceType
?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() } ?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() }
?: TmdbCollectionSourceType.DISCOVER ?: TmdbCollectionSourceType.DISCOVER
private fun String.isTraktListIdentifierInput(): Boolean {
val trimmed = trim()
if (trimmed.isBlank()) return false
if (trimmed.toLongOrNull() != null) return true
if (trimmed.contains("trakt.tv/", ignoreCase = true)) return true
return Regex("""[?&]id=([^&#/]+)""").containsMatchIn(trimmed)
}

View file

@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -32,7 +33,6 @@ import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@ -68,6 +68,7 @@ import com.nuvio.app.core.ui.NuvioSurfaceCard
import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.core.ui.PlatformBackHandler import com.nuvio.app.core.ui.PlatformBackHandler
import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.trakt.TraktPublicListSearchResult
import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableCollectionItemScope
@ -107,6 +108,14 @@ fun CollectionEditorScreen(
return return
} }
if (state.showTraktSourcePicker) {
TraktSourcePickerScreen(
state = state,
onBack = { CollectionEditorRepository.hideTraktSourcePicker() },
)
return
}
val genrePickerIndex = state.genrePickerSourceIndex val genrePickerIndex = state.genrePickerSourceIndex
val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) } val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) }
val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource() val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource()
@ -158,6 +167,14 @@ fun CollectionEditorScreen(
return return
} }
if (state.showTraktSourcePicker) {
TraktSourcePickerScreen(
state = state,
onBack = { CollectionEditorRepository.hideTraktSourcePicker() },
)
return
}
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
NuvioScreen( NuvioScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -704,7 +721,10 @@ private fun FolderEditorPage(
FolderEditorSection( FolderEditorSection(
title = stringResource(Res.string.collections_editor_section_catalog_sources), title = stringResource(Res.string.collections_editor_section_catalog_sources),
actions = { actions = {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = { CollectionEditorRepository.showTmdbSourcePicker() }) { TextButton(onClick = { CollectionEditorRepository.showTmdbSourcePicker() }) {
Icon( Icon(
imageVector = Icons.Rounded.Add, imageVector = Icons.Rounded.Add,
@ -714,6 +734,15 @@ private fun FolderEditorPage(
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(Res.string.source_tmdb)) Text(stringResource(Res.string.source_tmdb))
} }
TextButton(onClick = { CollectionEditorRepository.showTraktSourcePicker() }) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(Res.string.collections_editor_add_trakt_source))
}
TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) { TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) {
Icon( Icon(
imageVector = Icons.Rounded.Add, imageVector = Icons.Rounded.Add,
@ -752,6 +781,12 @@ private fun FolderEditorPage(
source = source, source = source,
onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, onRemove = { CollectionEditorRepository.removeCatalogSource(index) },
) )
} else if (source.isTrakt) {
FolderTraktSourceCard(
source = source,
onEdit = { CollectionEditorRepository.editTraktSource(index) },
onRemove = { CollectionEditorRepository.removeCatalogSource(index) },
)
} else if (addonSource != null) { } else if (addonSource != null) {
FolderCatalogSourceCard( FolderCatalogSourceCard(
source = addonSource, source = addonSource,
@ -1393,6 +1428,208 @@ private fun TmdbSourcePickerScreen(
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun TraktSourcePickerScreen(
state: CollectionEditorUiState,
onBack: () -> Unit,
) {
val bottomInset = nuvioSafeBottomPadding()
val searchResultsTitle = stringResource(Res.string.collections_editor_trakt_search_results)
val trendingTitle = stringResource(Res.string.collections_editor_trakt_trending)
val popularTitle = stringResource(Res.string.collections_editor_trakt_popular)
PlatformBackHandler(enabled = true) {
onBack()
}
Box(modifier = Modifier.fillMaxSize()) {
NuvioScreen(modifier = Modifier.fillMaxSize()) {
stickyHeader {
NuvioScreenHeader(
title = if (state.editingTraktSourceIndex != null) {
stringResource(Res.string.collections_editor_edit_trakt_source)
} else {
stringResource(Res.string.collections_editor_trakt_sources)
},
onBack = onBack,
)
}
item {
NuvioSurfaceCard {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
TmdbLabeledField(
label = stringResource(Res.string.collections_editor_trakt_list),
value = state.traktInput,
onValueChange = { CollectionEditorRepository.setTraktInput(it) },
placeholder = stringResource(Res.string.collections_editor_trakt_input_placeholder),
helper = stringResource(Res.string.collections_editor_trakt_input_helper),
)
TmdbLabeledField(
label = stringResource(Res.string.collections_editor_tmdb_display_title),
value = state.traktTitleInput,
onValueChange = { CollectionEditorRepository.setTraktTitleInput(it) },
placeholder = stringResource(Res.string.collections_editor_trakt_title_placeholder),
helper = stringResource(Res.string.collections_editor_tmdb_title_helper),
)
if (state.traktSearchError != null) {
Text(
text = state.traktSearchError,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
item {
PickerPanel(title = stringResource(Res.string.collections_editor_tmdb_type)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = state.traktMediaType == TmdbCollectionMediaType.MOVIE && !state.traktMediaBoth,
onClick = {
CollectionEditorRepository.setTraktMediaBoth(false)
CollectionEditorRepository.setTraktMediaType(TmdbCollectionMediaType.MOVIE)
},
label = { Text(stringResource(Res.string.collections_editor_tmdb_movies)) },
)
FilterChip(
selected = state.traktMediaType == TmdbCollectionMediaType.TV && !state.traktMediaBoth,
onClick = {
CollectionEditorRepository.setTraktMediaBoth(false)
CollectionEditorRepository.setTraktMediaType(TmdbCollectionMediaType.TV)
},
label = { Text(stringResource(Res.string.collections_editor_tmdb_series)) },
)
FilterChip(
selected = state.traktMediaBoth,
onClick = { CollectionEditorRepository.setTraktMediaBoth(true) },
label = { Text(stringResource(Res.string.collections_editor_tmdb_both)) },
)
}
}
}
item {
PickerPanel(title = stringResource(Res.string.collections_editor_tmdb_sort)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
traktSortOptions().forEach { (value, label) ->
FilterChip(
selected = state.traktSortBy == value,
onClick = { CollectionEditorRepository.setTraktSortBy(value) },
label = { Text(label) },
)
}
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(Res.string.collections_editor_trakt_direction),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = state.traktSortHow == TraktSortHow.ASC.value,
onClick = { CollectionEditorRepository.setTraktSortHow(TraktSortHow.ASC.value) },
label = { Text(stringResource(Res.string.collections_editor_trakt_ascending)) },
)
FilterChip(
selected = state.traktSortHow == TraktSortHow.DESC.value,
onClick = { CollectionEditorRepository.setTraktSortHow(TraktSortHow.DESC.value) },
label = { Text(stringResource(Res.string.collections_editor_trakt_descending)) },
)
}
}
}
}
TraktResultSection(
title = searchResultsTitle,
results = state.traktSearchResults,
)
TraktResultSection(
title = trendingTitle,
results = state.traktTrendingResults,
)
TraktResultSection(
title = popularTitle,
results = state.traktPopularResults,
)
item {
Spacer(modifier = Modifier.height(96.dp + bottomInset))
}
}
Surface(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(),
color = MaterialTheme.colorScheme.background.copy(alpha = 0.96f),
tonalElevation = 6.dp,
shadowElevation = 10.dp,
) {
PickerActionBar(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.padding(bottom = bottomInset),
) {
TextButton(onClick = { CollectionEditorRepository.searchTraktLists() }) {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(Res.string.collections_editor_tmdb_search))
}
NuvioPrimaryButton(
text = if (state.editingTraktSourceIndex != null) {
stringResource(Res.string.collections_editor_save)
} else {
stringResource(Res.string.collections_editor_add_source)
},
modifier = Modifier.weight(1f),
enabled = state.traktInput.isNotBlank(),
onClick = { CollectionEditorRepository.addTraktSourceFromInput() },
)
}
}
}
}
private fun LazyListScope.TraktResultSection(
title: String,
results: List<TraktPublicListSearchResult>,
) {
if (results.isEmpty()) return
item {
PickerSectionLabel(title)
}
itemsIndexed(results) { _, result ->
PickerOptionRow(
title = result.title,
subtitle = result.subtitle,
selected = false,
onClick = { CollectionEditorRepository.addTraktSourceFromResult(result) },
)
}
}
@Composable @Composable
private fun PickerPanel( private fun PickerPanel(
title: String, title: String,
@ -1790,6 +2027,63 @@ private fun FolderTmdbSourceCard(
} }
} }
@Composable
private fun FolderTraktSourceCard(
source: CollectionSource,
onEdit: () -> Unit,
onRemove: () -> Unit,
) {
NuvioSurfaceCard {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = source.title?.takeIf { it.isNotBlank() } ?: stringResource(Res.string.source_trakt),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringResource(Res.string.source_trakt),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(
onClick = onEdit,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.Edit,
contentDescription = stringResource(Res.string.action_edit),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(
onClick = onRemove,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = stringResource(Res.string.action_remove),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error,
)
}
}
Text(
text = traktSourceSubtitle(source),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun FolderCatalogSourceCard( private fun FolderCatalogSourceCard(
@ -1965,6 +2259,53 @@ private fun tmdbSortLabel(sort: TmdbCollectionSort): String =
TmdbCollectionSort.FIRST_AIR_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent) TmdbCollectionSort.FIRST_AIR_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent)
} }
@Composable
private fun traktSortOptions(): List<Pair<String, String>> =
listOf(
TraktListSort.RANK.value to stringResource(Res.string.collections_editor_trakt_sort_list_order),
TraktListSort.ADDED.value to stringResource(Res.string.collections_editor_trakt_sort_recently_added),
TraktListSort.TITLE.value to stringResource(Res.string.collections_editor_trakt_sort_title),
TraktListSort.RELEASED.value to stringResource(Res.string.collections_editor_trakt_sort_released),
TraktListSort.RUNTIME.value to stringResource(Res.string.collections_editor_trakt_sort_runtime),
TraktListSort.POPULARITY.value to stringResource(Res.string.collections_editor_trakt_sort_popular),
TraktListSort.PERCENTAGE.value to stringResource(Res.string.collections_editor_trakt_sort_percentage),
TraktListSort.VOTES.value to stringResource(Res.string.collections_editor_trakt_sort_votes),
)
@Composable
private fun traktSortLabel(value: String?): String =
when (TraktListSort.normalize(value)) {
TraktListSort.ADDED.value -> stringResource(Res.string.collections_editor_trakt_sort_recently_added)
TraktListSort.TITLE.value -> stringResource(Res.string.collections_editor_trakt_sort_title)
TraktListSort.RELEASED.value -> stringResource(Res.string.collections_editor_trakt_sort_released)
TraktListSort.RUNTIME.value -> stringResource(Res.string.collections_editor_trakt_sort_runtime)
TraktListSort.POPULARITY.value -> stringResource(Res.string.collections_editor_trakt_sort_popular)
TraktListSort.PERCENTAGE.value -> stringResource(Res.string.collections_editor_trakt_sort_percentage)
TraktListSort.VOTES.value -> stringResource(Res.string.collections_editor_trakt_sort_votes)
else -> stringResource(Res.string.collections_editor_trakt_sort_list_order)
}
@Composable
private fun traktDirectionLabel(value: String?): String =
when (TraktSortHow.normalize(value)) {
TraktSortHow.DESC.value -> stringResource(Res.string.collections_editor_trakt_descending)
else -> stringResource(Res.string.collections_editor_trakt_ascending)
}
@Composable
private fun traktSourceSubtitle(source: CollectionSource): String {
val media = when (TmdbCollectionMediaType.fromString(source.mediaType)) {
TmdbCollectionMediaType.MOVIE -> stringResource(Res.string.collections_editor_tmdb_movies)
TmdbCollectionMediaType.TV -> stringResource(Res.string.collections_editor_tmdb_series)
}
return listOf(
media,
traktSortLabel(source.sortBy),
traktDirectionLabel(source.sortHow),
"ID ${source.traktListId ?: ""}".trim(),
).joinToString("")
}
@Composable @Composable
private fun tmdbSourceSubtitle(source: CollectionSource): String { private fun tmdbSourceSubtitle(source: CollectionSource): String {
val media = when (TmdbCollectionMediaType.fromString(source.mediaType)) { val media = when (TmdbCollectionMediaType.fromString(source.mediaType)) {

View file

@ -144,17 +144,27 @@ internal object CollectionJsonPreserver {
private fun unifiedSourceKey(element: JsonElement): String? { private fun unifiedSourceKey(element: JsonElement): String? {
val obj = element as? JsonObject ?: return null val obj = element as? JsonObject ?: return null
val provider = obj["provider"]?.jsonPrimitive?.contentOrNull ?: "addon" val provider = obj["provider"]?.jsonPrimitive?.contentOrNull ?: "addon"
return if (provider.equals("tmdb", ignoreCase = true)) { return when {
val sourceType = obj["tmdbSourceType"]?.jsonPrimitive?.contentOrNull ?: return null provider.equals("tmdb", ignoreCase = true) -> {
val tmdbId = obj["tmdbId"]?.jsonPrimitive?.contentOrNull.orEmpty() val sourceType = obj["tmdbSourceType"]?.jsonPrimitive?.contentOrNull ?: return null
val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty() val tmdbId = obj["tmdbId"]?.jsonPrimitive?.contentOrNull.orEmpty()
val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty() val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty()
"$provider|$sourceType|$tmdbId|$mediaType|$sortBy" val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty()
} else { "$provider|$sourceType|$tmdbId|$mediaType|$sortBy"
val addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null }
val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null provider.equals("trakt", ignoreCase = true) -> {
val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null val listId = obj["traktListId"]?.jsonPrimitive?.contentOrNull ?: return null
"$provider|$addonId|$type|$catalogId" val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty()
val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty()
val sortHow = obj["sortHow"]?.jsonPrimitive?.contentOrNull.orEmpty()
"$provider|$listId|$mediaType|$sortBy|$sortHow"
}
else -> {
val addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null
val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null
val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null
"$provider|$addonId|$type|$catalogId"
}
} }
} }
} }

View file

@ -41,15 +41,20 @@ data class CollectionSource(
val tmdbSourceType: String? = null, val tmdbSourceType: String? = null,
val title: String? = null, val title: String? = null,
val tmdbId: Int? = null, val tmdbId: Int? = null,
val traktListId: Long? = null,
val mediaType: String? = null, val mediaType: String? = null,
val sortBy: String? = null, val sortBy: String? = null,
val sortHow: String? = null,
val filters: TmdbCollectionFilters? = null, val filters: TmdbCollectionFilters? = null,
) { ) {
val isTmdb: Boolean val isTmdb: Boolean
get() = provider.equals("tmdb", ignoreCase = true) get() = provider.equals("tmdb", ignoreCase = true)
val isTrakt: Boolean
get() = provider.equals("trakt", ignoreCase = true)
fun addonCatalogSource(): CollectionCatalogSource? { fun addonCatalogSource(): CollectionCatalogSource? {
if (isTmdb) return null if (isTmdb || isTrakt) return null
val sourceAddonId = addonId?.takeIf { it.isNotBlank() } ?: return null val sourceAddonId = addonId?.takeIf { it.isNotBlank() } ?: return null
val sourceType = type?.takeIf { it.isNotBlank() } ?: return null val sourceType = type?.takeIf { it.isNotBlank() } ?: return null
val sourceCatalogId = catalogId?.takeIf { it.isNotBlank() } ?: return null val sourceCatalogId = catalogId?.takeIf { it.isNotBlank() } ?: return null
@ -62,6 +67,9 @@ data class CollectionSource(
} }
} }
internal fun CollectionSource.hasInvalidTraktListId(): Boolean =
isTrakt && (traktListId == null || traktListId <= 0L)
@Serializable @Serializable
enum class TmdbCollectionSourceType { enum class TmdbCollectionSourceType {
LIST, LIST,
@ -95,6 +103,36 @@ enum class TmdbCollectionSort(val value: String) {
FIRST_AIR_DATE_DESC("first_air_date.desc"), FIRST_AIR_DATE_DESC("first_air_date.desc"),
} }
enum class TraktListSort(val value: String) {
RANK("rank"),
ADDED("added"),
TITLE("title"),
RELEASED("released"),
RUNTIME("runtime"),
POPULARITY("popularity"),
PERCENTAGE("percentage"),
VOTES("votes");
companion object {
fun normalize(value: String?): String {
val raw = value?.trim()?.lowercase().orEmpty()
return entries.firstOrNull { it.value == raw }?.value ?: RANK.value
}
}
}
enum class TraktSortHow(val value: String) {
ASC("asc"),
DESC("desc");
companion object {
fun normalize(value: String?): String {
val raw = value?.trim()?.lowercase().orEmpty()
return entries.firstOrNull { it.value == raw }?.value ?: ASC.value
}
}
}
@Immutable @Immutable
@Serializable @Serializable
data class TmdbCollectionFilters( data class TmdbCollectionFilters(

View file

@ -23,6 +23,7 @@ import nuvio.composeapp.generated.resources.collections_import_error_folder_blan
import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_title import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_title
import nuvio.composeapp.generated.resources.collections_import_error_invalid_json import nuvio.composeapp.generated.resources.collections_import_error_invalid_json
import nuvio.composeapp.generated.resources.collections_import_error_source_blank_fields import nuvio.composeapp.generated.resources.collections_import_error_source_blank_fields
import nuvio.composeapp.generated.resources.collections_import_error_trakt_list_id
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@ -185,7 +186,20 @@ object CollectionRepository {
) )
} }
f.resolvedSources.forEachIndexed { si, s -> f.resolvedSources.forEachIndexed { si, s ->
val invalidAddon = !s.isTmdb && if (s.hasInvalidTraktListId()) {
return ValidationResult(
valid = false,
error = runBlocking {
getString(
Res.string.collections_import_error_trakt_list_id,
si + 1,
f.title,
)
},
)
}
val invalidAddon = !s.isTmdb && !s.isTrakt &&
(s.addonId.isNullOrBlank() || s.type.isNullOrBlank() || s.catalogId.isNullOrBlank()) (s.addonId.isNullOrBlank() || s.type.isNullOrBlank() || s.catalogId.isNullOrBlank())
val invalidTmdb = s.isTmdb && val invalidTmdb = s.isTmdb &&
s.tmdbSourceType.isNullOrBlank() s.tmdbSourceType.isNullOrBlank()

View file

@ -10,6 +10,7 @@ import com.nuvio.app.core.i18n.localizedMediaTypeLabel
import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.stableKey import com.nuvio.app.features.home.stableKey
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -148,6 +149,25 @@ object FolderDetailRepository {
isLoading = true, isLoading = true,
), ),
) )
} else if (source.isTrakt) {
val mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie"
val typeLabel = if (mediaType == TmdbCollectionMediaType.TV) {
"Trakt Series List"
} else {
"Trakt Movie List"
}
add(
FolderTab(
label = source.title?.takeIf { it.isNotBlank() } ?: "Trakt",
typeLabel = typeLabel,
source = source,
type = type,
catalogId = traktCatalogId(source),
supportsPagination = true,
isLoading = true,
),
)
} else { } else {
val catalogSource = source.addonCatalogSource() ?: return@forEach val catalogSource = source.addonCatalogSource() ?: return@forEach
val resolvedCatalog = addons.findCollectionCatalog(catalogSource) val resolvedCatalog = addons.findCollectionCatalog(catalogSource)
@ -188,7 +208,7 @@ object FolderDetailRepository {
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
val catalogSource = source.addonCatalogSource() val catalogSource = source.addonCatalogSource()
val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) } val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) }
if (!source.isTmdb && resolvedCatalog == null) { if (!source.isTmdb && !source.isTrakt && resolvedCatalog == null) {
updateTab(tabIndex) { updateTab(tabIndex) {
it.copy( it.copy(
isLoading = false, isLoading = false,
@ -254,7 +274,12 @@ object FolderDetailRepository {
private fun loadTabPage(index: Int, reset: Boolean) { private fun loadTabPage(index: Int, reset: Boolean) {
val currentTab = _uiState.value.tabs.getOrNull(index) ?: return val currentTab = _uiState.value.tabs.getOrNull(index) ?: return
val requestedSkip = if (reset) 0 else currentTab.nextSkip ?: return val requestedSkip = if (reset) 0 else currentTab.nextSkip ?: return
if (!currentTab.source?.isTmdb.orFalse() && currentTab.manifestUrl == null) return val currentSource = currentTab.source
if (
currentSource?.isTmdb != true &&
currentSource?.isTrakt != true &&
currentTab.manifestUrl == null
) return
updateTab(index) { tab -> updateTab(index) { tab ->
if (reset) { if (reset) {
@ -277,13 +302,18 @@ object FolderDetailRepository {
val job = scope.launch { val job = scope.launch {
runCatching { runCatching {
val source = currentTab.source val source = currentTab.source
if (source?.isTmdb == true) { when {
TmdbCollectionSourceResolver.resolve( source?.isTmdb == true -> TmdbCollectionSourceResolver.resolve(
source = source, source = source,
page = if (reset) 1 else requestedSkip, page = if (reset) 1 else requestedSkip,
) )
} else {
fetchCatalogPage( source?.isTrakt == true -> TraktPublicListSourceResolver.resolve(
source = source,
page = if (reset) 1 else requestedSkip,
)
else -> fetchCatalogPage(
manifestUrl = requireNotNull(currentTab.manifestUrl), manifestUrl = requireNotNull(currentTab.manifestUrl),
type = currentTab.type, type = currentTab.type,
catalogId = currentTab.catalogId, catalogId = currentTab.catalogId,
@ -399,3 +429,13 @@ private fun tmdbCatalogId(source: CollectionSource): String =
append("_") append("_")
append(source.mediaType?.lowercase().orEmpty()) append(source.mediaType?.lowercase().orEmpty())
} }
private fun traktCatalogId(source: CollectionSource): String =
listOf(
"trakt",
"list",
source.traktListId?.toString().orEmpty(),
source.mediaType?.lowercase().orEmpty(),
TraktListSort.normalize(source.sortBy),
TraktSortHow.normalize(source.sortHow),
).joinToString("_")

View file

@ -85,19 +85,35 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
seasonNumber = seasonNumber, seasonNumber = seasonNumber,
episodeNumber = episodeNumber, episodeNumber = episodeNumber,
) )
val candidates = sortedEpisodes var watchedIndex = sortedEpisodes.indexOfFirst { episode ->
.dropWhile { episode -> buildPlaybackVideoId(
buildPlaybackVideoId( content = WatchingContentRef(type = type, id = id),
content = WatchingContentRef(type = type, id = id), seasonNumber = episode.season,
seasonNumber = episode.season, episodeNumber = episode.episode,
episodeNumber = episode.episode, fallbackVideoId = episode.id,
fallbackVideoId = episode.id, ) == watchedVideoId
) != watchedVideoId }
// Fallback: if the seed wasn't found by season+episode (anime with absolute
// numbering on Trakt vs multi-season on addon), try global index matching.
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.season }
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
val globalIndex = episodeNumber - 1
if (globalIndex in sortedEpisodes.indices) {
watchedIndex = globalIndex
}
} }
.drop(1) }
if (watchedIndex < 0) return null
val watchedEpisodeSeason = sortedEpisodes[watchedIndex].season
val candidates = sortedEpisodes
.drop(watchedIndex + 1)
.filter { episode -> .filter { episode ->
shouldSurfaceNextEpisode( shouldSurfaceNextEpisode(
watchedSeasonNumber = seasonNumber, watchedSeasonNumber = watchedEpisodeSeason,
candidateSeasonNumber = episode.season, candidateSeasonNumber = episode.season,
todayIsoDate = todayIsoDate, todayIsoDate = todayIsoDate,
releasedDate = episode.released, releasedDate = episode.released,

View file

@ -21,4 +21,6 @@ internal expect object DownloadsPlatformDownloader {
fun removeFile(localFileUri: String?): Boolean fun removeFile(localFileUri: String?): Boolean
fun removePartialFile(destinationFileName: String): Boolean fun removePartialFile(destinationFileName: String): Boolean
fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String?
} }

View file

@ -43,7 +43,7 @@ object DownloadsRepository {
val normalizedVideoId = videoId?.trim().orEmpty() val normalizedVideoId = videoId?.trim().orEmpty()
if (normalizedVideoId.isBlank()) return null if (normalizedVideoId.isBlank()) return null
return _uiState.value.items.firstOrNull { item -> return _uiState.value.items.firstOrNull { item ->
item.videoId == normalizedVideoId && item.isPlayable && !item.localFileUri.isNullOrBlank() item.videoId == normalizedVideoId && item.hasPlayableLocalFile()
} }
} }
@ -64,20 +64,42 @@ object DownloadsRepository {
item.parentMetaId == normalizedParentMetaId && item.parentMetaId == normalizedParentMetaId &&
item.seasonNumber == seasonNumber && item.seasonNumber == seasonNumber &&
item.episodeNumber == episodeNumber && item.episodeNumber == episodeNumber &&
item.isPlayable && item.hasPlayableLocalFile()
!item.localFileUri.isNullOrBlank()
} }
} else { } else {
items.firstOrNull { item -> items.firstOrNull { item ->
item.parentMetaId == normalizedParentMetaId && item.parentMetaId == normalizedParentMetaId &&
item.seasonNumber == null && item.seasonNumber == null &&
item.episodeNumber == null && item.episodeNumber == null &&
item.isPlayable && item.hasPlayableLocalFile()
!item.localFileUri.isNullOrBlank()
} }
} }
} }
fun playableLocalFileUri(item: DownloadItem): String? {
ensureLoaded()
if (item.status != DownloadStatus.Completed) return null
val resolvedUri = DownloadsPlatformDownloader.resolveLocalFileUri(
localFileUri = item.localFileUri,
destinationFileName = item.fileName,
) ?: return null
if (resolvedUri != item.localFileUri) {
mutateItem(item.id) { current ->
if (current.fileName == item.fileName) {
current.copy(
localFileUri = resolvedUri,
updatedAtEpochMs = DownloadsClock.nowEpochMs(),
)
} else {
current
}
}
}
return resolvedUri
}
fun enqueueFromStream( fun enqueueFromStream(
contentType: String, contentType: String,
videoId: String, videoId: String,
@ -117,7 +139,7 @@ object DownloadsRepository {
if (existing != null) { if (existing != null) {
replacedExisting = true replacedExisting = true
activeHandles.remove(existing.id)?.cancel() activeHandles.remove(existing.id)?.cancel()
DownloadsPlatformDownloader.removeFile(existing.localFileUri) DownloadsPlatformDownloader.removeFile(playableLocalFileUri(existing) ?: existing.localFileUri)
DownloadsPlatformDownloader.removePartialFile(existing.fileName) DownloadsPlatformDownloader.removePartialFile(existing.fileName)
currentItems.removeAll { it.id == existing.id } currentItems.removeAll { it.id == existing.id }
} }
@ -191,6 +213,14 @@ object DownloadsRepository {
} }
} }
fun pauseActiveDownloads() {
ensureLoaded()
_uiState.value.items
.filter { it.status == DownloadStatus.Downloading }
.map { it.id }
.forEach(::pauseDownload)
}
fun resumeDownload(downloadId: String) { fun resumeDownload(downloadId: String) {
ensureLoaded() ensureLoaded()
val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return
@ -217,7 +247,7 @@ object DownloadsRepository {
val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return
activeHandles.remove(downloadId)?.cancel() activeHandles.remove(downloadId)?.cancel()
DownloadsPlatformDownloader.removeFile(item.localFileUri) DownloadsPlatformDownloader.removeFile(playableLocalFileUri(item) ?: item.localFileUri)
DownloadsPlatformDownloader.removePartialFile(item.fileName) DownloadsPlatformDownloader.removePartialFile(item.fileName)
publish(_uiState.value.items.filterNot { it.id == downloadId }) publish(_uiState.value.items.filterNot { it.id == downloadId })
@ -233,9 +263,10 @@ object DownloadsRepository {
return return
} }
var shouldPersistNormalized = false
val normalized = DownloadsCodec.decodeItems(payload) val normalized = DownloadsCodec.decodeItems(payload)
.map { item -> .map { item ->
if (item.status == DownloadStatus.Downloading) { val statusNormalized = if (item.status == DownloadStatus.Downloading) {
item.copy( item.copy(
status = DownloadStatus.Paused, status = DownloadStatus.Paused,
errorMessage = null, errorMessage = null,
@ -243,10 +274,19 @@ object DownloadsRepository {
} else { } else {
item item
} }
val localUriNormalized = normalizeCompletedLocalFileUri(statusNormalized)
if (localUriNormalized != item) {
shouldPersistNormalized = true
}
localUriNormalized
} }
_uiState.value = DownloadsUiState(normalized) _uiState.value = DownloadsUiState(normalized)
notifyLiveStatusPlatform() notifyLiveStatusPlatform()
if (shouldPersistNormalized) {
persist()
}
} }
private fun startDownload(item: DownloadItem) { private fun startDownload(item: DownloadItem) {
@ -359,6 +399,26 @@ object DownloadsRepository {
append(nextDownloadOrdinal.toString(36)) append(nextDownloadOrdinal.toString(36))
} }
} }
private fun normalizeCompletedLocalFileUri(item: DownloadItem): DownloadItem {
if (item.status != DownloadStatus.Completed) return item
val resolvedUri = DownloadsPlatformDownloader.resolveLocalFileUri(
localFileUri = item.localFileUri,
destinationFileName = item.fileName,
) ?: return item
return if (resolvedUri != item.localFileUri) {
item.copy(localFileUri = resolvedUri)
} else {
item
}
}
private fun DownloadItem.hasPlayableLocalFile(): Boolean =
status == DownloadStatus.Completed &&
DownloadsPlatformDownloader.resolveLocalFileUri(
localFileUri = localFileUri,
destinationFileName = fileName,
) != null
} }
@Serializable @Serializable

View file

@ -868,7 +868,7 @@ fun PlayerScreen(
} }
fun switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) { fun switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) {
val localFileUri = downloadItem.localFileUri ?: return val localFileUri = DownloadsRepository.playableLocalFileUri(downloadItem) ?: return
showNextEpisodeCard = false showNextEpisodeCard = false
showSourcesPanel = false showSourcesPanel = false
showEpisodesPanel = false showEpisodesPanel = false

View file

@ -4,7 +4,7 @@ import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.lang_english import nuvio.composeapp.generated.resources.lang_english
import nuvio.composeapp.generated.resources.lang_french import nuvio.composeapp.generated.resources.lang_french
import nuvio.composeapp.generated.resources.lang_spanish import nuvio.composeapp.generated.resources.lang_spanish
import nuvio.composeapp.generated.resources.lang_portuguese_portugal import nuvio.composeapp.generated.resources.lang_portuguese
import nuvio.composeapp.generated.resources.lang_turkish import nuvio.composeapp.generated.resources.lang_turkish
import nuvio.composeapp.generated.resources.lang_italian import nuvio.composeapp.generated.resources.lang_italian
import nuvio.composeapp.generated.resources.lang_greek import nuvio.composeapp.generated.resources.lang_greek

View file

@ -424,8 +424,32 @@ object StreamsRepository {
} }
} }
fun cancelLoading() {
activeJob?.cancel()
activeJob = null
_uiState.update { current ->
if (!current.isAnyLoading && current.groups.none { it.isLoading }) {
current
} else {
val updatedGroups = current.groups.map { group ->
if (group.isLoading) group.copy(isLoading = false) else group
}
current.copy(
groups = updatedGroups,
isAnyLoading = false,
emptyStateReason = if (updatedGroups.isEmpty()) {
current.emptyStateReason
} else {
updatedGroups.toEmptyStateReason(anyLoading = false)
},
)
}
}
}
fun clear() { fun clear() {
activeJob?.cancel() activeJob?.cancel()
activeJob = null
activeRequestKey = null activeRequestKey = null
_uiState.value = StreamsUiState() _uiState.value = StreamsUiState()
} }

View file

@ -0,0 +1,491 @@
package com.nuvio.app.features.trakt
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetTextWithHeaders
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaVideo
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
private const val BASE_URL = "https://api.trakt.tv"
/**
* Handles episode number remapping between addon metadata (which may use multi-season
* numbering for anime) and Trakt (which often uses absolute/single-season numbering).
*
* Example: An addon lists "Attack on Titan" as S1E1S1E25, S2E1S2E12, etc.
* Trakt may list it as S1E1S1E87 (absolute numbering).
*
* This service detects the mismatch and provides bidirectional mapping.
*/
object TraktEpisodeMappingService {
private val log = Logger.withTag("TraktEpMapSvc")
private val json = Json { ignoreUnknownKeys = true }
private val cacheMutex = Mutex()
private val mappingCache = mutableMapOf<String, EpisodeMappingEntry>()
private val reverseMappingCache = mutableMapOf<String, EpisodeMappingEntry>()
private val addonEpisodesCache = mutableMapOf<String, List<EpisodeMappingEntry>>()
private val traktEpisodesCache = mutableMapOf<String, List<EpisodeMappingEntry>>()
// In-flight dedup: prevents multiple concurrent coroutines from fetching
// the same show's addon episodes simultaneously.
private val addonEpisodesInFlight = mutableMapOf<String, CompletableDeferred<List<EpisodeMappingEntry>>>()
// ── Public API ────────────────────────────────────────────────────────
/**
* Resolves the Trakt-side season/episode for a given addon season/episode.
* Used when pushing watched status TO Trakt (forward mapping: addon Trakt).
*
* Returns null if no remapping is needed (same structure) or if mapping fails.
*/
suspend fun resolveEpisodeMapping(
contentId: String?,
contentType: String?,
videoId: String?,
season: Int?,
episode: Int?,
): EpisodeMappingEntry? {
val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null
cacheMutex.withLock {
mappingCache[key]?.let { return it }
}
val requestedSeason = season ?: return null
val requestedEpisode = episode ?: return null
val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null
val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null
val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType)
if (addonEpisodes.isEmpty()) return null
val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = videoId) ?: return null
val traktEpisodes = getTraktEpisodes(showLookupId)
if (traktEpisodes.isEmpty()) return null
if (hasSameSeasonStructure(addonEpisodes, traktEpisodes)) {
return null
}
val mapped = remapEpisodeByTitleOrIndex(
requestedSeason = requestedSeason,
requestedEpisode = requestedEpisode,
requestedVideoId = videoId,
requestedTitle = null,
addonEpisodes = addonEpisodes,
traktEpisodes = traktEpisodes,
) ?: return null
cacheMutex.withLock {
mappingCache[key] = mapped
}
return mapped
}
/**
* Resolves the addon-side season/episode for a given Trakt season/episode.
* Used when reading progress FROM Trakt to find the correct addon episode
* (reverse mapping: Trakt addon).
*
* Returns null if no remapping is needed or if mapping fails.
*/
suspend fun resolveAddonEpisodeMapping(
contentId: String?,
contentType: String?,
season: Int?,
episode: Int?,
episodeTitle: String? = null,
): EpisodeMappingEntry? {
val requestedSeason = season ?: return null
val requestedEpisode = episode ?: return null
val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null
val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null
val reverseKey = reverseCacheKey(
contentId = resolvedContentId,
contentType = resolvedContentType,
season = requestedSeason,
episode = requestedEpisode,
title = episodeTitle,
)
cacheMutex.withLock {
reverseMappingCache[reverseKey]?.let { return it }
}
val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType)
if (addonEpisodes.isEmpty()) return null
val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = null) ?: return null
val traktEpisodes = getTraktEpisodes(showLookupId)
if (traktEpisodes.isEmpty()) return null
val addonHasEpisode = addonEpisodes.any {
it.season == requestedSeason && it.episode == requestedEpisode
}
if (addonHasEpisode && hasSameSeasonStructure(addonEpisodes, traktEpisodes)) {
return null
}
val mapped = reverseRemapEpisodeByTitleOrIndex(
requestedSeason = requestedSeason,
requestedEpisode = requestedEpisode,
requestedTitle = episodeTitle,
addonEpisodes = addonEpisodes,
traktEpisodes = traktEpisodes,
) ?: return null
cacheMutex.withLock {
reverseMappingCache[reverseKey] = mapped
}
return mapped
}
suspend fun getCachedEpisodeMapping(
contentId: String?,
contentType: String?,
videoId: String?,
season: Int?,
episode: Int?,
): EpisodeMappingEntry? {
val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null
return cacheMutex.withLock { mappingCache[key] }
}
suspend fun prefetchEpisodeMapping(
contentId: String?,
contentType: String?,
videoId: String?,
season: Int?,
episode: Int?,
): EpisodeMappingEntry? {
return resolveEpisodeMapping(contentId, contentType, videoId, season, episode)
}
fun clearCache() {
mappingCache.clear()
reverseMappingCache.clear()
addonEpisodesCache.clear()
traktEpisodesCache.clear()
}
// ── Season structure comparison ───────────────────────────────────────
private fun hasSameSeasonStructure(
addonEpisodes: List<EpisodeMappingEntry>,
traktEpisodes: List<EpisodeMappingEntry>,
): Boolean {
val addonSeasons = addonEpisodes.mapTo(mutableSetOf()) { it.season }
val traktSeasons = traktEpisodes.mapTo(mutableSetOf()) { it.season }
return addonSeasons == traktSeasons
}
// ── Forward mapping: addon → Trakt ──────────────────────────────────
private fun remapEpisodeByTitleOrIndex(
requestedSeason: Int,
requestedEpisode: Int,
requestedVideoId: String?,
requestedTitle: String?,
addonEpisodes: List<EpisodeMappingEntry>,
traktEpisodes: List<EpisodeMappingEntry>,
): EpisodeMappingEntry? {
// Find the addon episode entry
val addonEntry = addonEpisodes.firstOrNull {
it.season == requestedSeason && it.episode == requestedEpisode
} ?: addonEpisodes.firstOrNull {
!requestedVideoId.isNullOrBlank() && it.videoId == requestedVideoId
} ?: return null
// Try title match first
val titleToMatch = addonEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle
if (!titleToMatch.isNullOrBlank()) {
val titleMatch = traktEpisodes.firstOrNull { target ->
!target.title.isNullOrBlank() &&
normalizeTitle(target.title) == normalizeTitle(titleToMatch)
}
if (titleMatch != null) {
return titleMatch
}
}
// Fallback: global index mapping
val addonIndex = addonEpisodes.indexOf(addonEntry)
if (addonIndex < 0 || addonIndex >= traktEpisodes.size) return null
return traktEpisodes[addonIndex]
}
// ── Reverse mapping: Trakt → addon ──────────────────────────────────
private fun reverseRemapEpisodeByTitleOrIndex(
requestedSeason: Int,
requestedEpisode: Int,
requestedTitle: String?,
addonEpisodes: List<EpisodeMappingEntry>,
traktEpisodes: List<EpisodeMappingEntry>,
): EpisodeMappingEntry? {
// Find the Trakt episode entry
val traktEntry = traktEpisodes.firstOrNull {
it.season == requestedSeason && it.episode == requestedEpisode
} ?: return null
// Try title match first
val titleToMatch = traktEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle
if (!titleToMatch.isNullOrBlank()) {
val titleMatch = addonEpisodes.firstOrNull { target ->
!target.title.isNullOrBlank() &&
normalizeTitle(target.title) == normalizeTitle(titleToMatch)
}
if (titleMatch != null) {
return titleMatch
}
}
// Fallback: global index mapping
val traktIndex = traktEpisodes.indexOf(traktEntry)
if (traktIndex < 0 || traktIndex >= addonEpisodes.size) return null
return addonEpisodes[traktIndex]
}
// ── Addon episodes fetching (with dedup) ───────────────────────────
private suspend fun getAddonEpisodes(
contentId: String,
contentType: String,
): List<EpisodeMappingEntry> {
val cacheKey = addonEpisodesCacheKey(contentId, contentType)
// Fast path: cache hit
cacheMutex.withLock {
addonEpisodesCache[cacheKey]?.let { return it }
}
// Dedup: if another coroutine is already fetching this show, await its result.
val existingDeferred = cacheMutex.withLock { addonEpisodesInFlight[cacheKey] }
if (existingDeferred != null) {
return try { existingDeferred.await() } catch (_: Exception) { emptyList() }
}
// Register ourselves as the in-flight fetcher.
val deferred = CompletableDeferred<List<EpisodeMappingEntry>>()
val weOwn = cacheMutex.withLock {
// Double-check: cache or another flight may have appeared while we waited.
addonEpisodesCache[cacheKey]?.let { return it }
if (addonEpisodesInFlight.containsKey(cacheKey)) {
false
} else {
addonEpisodesInFlight[cacheKey] = deferred
true
}
}
if (!weOwn) {
val other = cacheMutex.withLock { addonEpisodesInFlight[cacheKey] }
return try { other?.await() ?: emptyList() } catch (_: Exception) { emptyList() }
}
return try {
val addonEpisodes = fetchAddonEpisodes(contentId, contentType)
if (addonEpisodes.isNotEmpty()) {
cacheMutex.withLock { addonEpisodesCache[cacheKey] = addonEpisodes }
}
deferred.complete(addonEpisodes)
addonEpisodes
} catch (e: Exception) {
if (e is CancellationException) throw e
deferred.completeExceptionally(e)
emptyList()
} finally {
cacheMutex.withLock { addonEpisodesInFlight.remove(cacheKey) }
}
}
private suspend fun fetchAddonEpisodes(
contentId: String,
contentType: String,
): List<EpisodeMappingEntry> {
val typeCandidates = buildList {
val normalized = contentType.lowercase()
if (normalized.isNotBlank()) add(normalized)
if (normalized in listOf("series", "tv")) {
add("series")
add("tv")
}
}.distinct()
if (typeCandidates.isEmpty()) return emptyList()
val idCandidates = buildList {
add(contentId)
if (contentId.startsWith("tmdb:")) add(contentId.substringAfter(':'))
if (contentId.startsWith("trakt:")) add(contentId.substringAfter(':'))
}.distinct()
for (type in typeCandidates) {
for (candidateId in idCandidates) {
val meta = withTimeoutOrNull(3_500L) {
MetaDetailsRepository.fetch(type = type, id = candidateId)
} ?: continue
val episodes = meta.videos.toEpisodeMappingEntries()
if (episodes.isNotEmpty()) return episodes
}
}
return emptyList()
}
// ── Trakt episodes fetching ─────────────────────────────────────────
private suspend fun getTraktEpisodes(showLookupId: String): List<EpisodeMappingEntry> {
cacheMutex.withLock {
traktEpisodesCache[showLookupId]?.let { return it }
}
val headers = TraktAuthRepository.authorizedHeaders() ?: return emptyList()
// Trakt API: GET /shows/{id}/seasons?extended=episodes
val url = "$BASE_URL/shows/$showLookupId/seasons?extended=episodes"
val payload = runCatching {
httpGetTextWithHeaders(url = url, headers = headers)
}.onFailure { e ->
if (e is CancellationException) throw e
log.w { "getTraktEpisodes: seasons request failed id=$showLookupId: ${e.message}" }
}.getOrNull() ?: return emptyList()
val traktEpisodes = parseTraktSeasonsPayload(payload)
if (traktEpisodes.isNotEmpty()) {
cacheMutex.withLock {
traktEpisodesCache[showLookupId] = traktEpisodes
}
}
return traktEpisodes
}
private fun parseTraktSeasonsPayload(payload: String): List<EpisodeMappingEntry> {
val seasons = runCatching {
json.decodeFromString<List<TraktSeasonDto>>(payload)
}.getOrNull() ?: return emptyList()
return seasons
.asSequence()
.filter { (it.number ?: 0) > 0 } // Skip specials (season 0)
.sortedBy { it.number }
.flatMap { seasonDto ->
seasonDto.episodes.orEmpty().asSequence().mapNotNull { episodeDto ->
val seasonNumber = episodeDto.season ?: seasonDto.number ?: return@mapNotNull null
val episodeNumber = episodeDto.number ?: return@mapNotNull null
EpisodeMappingEntry(
season = seasonNumber,
episode = episodeNumber,
title = episodeDto.title,
)
}
}
.toList()
}
// ── Helpers ───────────────────────────────────────────────────────────
private fun resolveShowLookupId(contentId: String?, videoId: String?): String? {
val contentIds = parseTraktContentIds(contentId)
if (contentIds.hasAnyId()) {
return when {
!contentIds.imdb.isNullOrBlank() -> contentIds.imdb
contentIds.trakt != null -> contentIds.trakt.toString()
contentIds.tmdb != null -> contentIds.tmdb.toString()
else -> null
}
}
val videoIds = parseTraktContentIds(videoId)
return when {
!videoIds.imdb.isNullOrBlank() -> videoIds.imdb
videoIds.trakt != null -> videoIds.trakt.toString()
videoIds.tmdb != null -> videoIds.tmdb.toString()
else -> null
}
}
private fun TraktExternalIds.hasAnyId(): Boolean =
!imdb.isNullOrBlank() || trakt != null || tmdb != null
private fun cacheKey(
contentId: String?,
contentType: String?,
videoId: String?,
season: Int?,
episode: Int?,
): String? {
val resolvedContentId = contentId?.trim()?.takeIf { it.isNotBlank() } ?: return null
val resolvedContentType = contentType?.trim()?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
val resolvedSeason = season ?: return null
val resolvedEpisode = episode ?: return null
val resolvedVideoId = videoId?.trim().orEmpty()
return "$resolvedContentType|$resolvedContentId|$resolvedVideoId|$resolvedSeason|$resolvedEpisode"
}
private fun reverseCacheKey(
contentId: String,
contentType: String,
season: Int,
episode: Int,
title: String?,
): String {
val normalizedTitle = title?.trim()?.lowercase().orEmpty()
return "reverse|${contentType.trim().lowercase()}|${contentId.trim()}|$season|$episode|$normalizedTitle"
}
private fun addonEpisodesCacheKey(contentId: String, contentType: String): String {
return "${contentType.trim().lowercase()}|${contentId.trim()}"
}
private fun List<MetaVideo>.toEpisodeMappingEntries(): List<EpisodeMappingEntry> {
return asSequence()
.mapNotNull { video ->
val season = video.season ?: return@mapNotNull null
val episode = video.episode ?: return@mapNotNull null
if (season <= 0) return@mapNotNull null
EpisodeMappingEntry(
season = season,
episode = episode,
title = video.title.takeIf { it.isNotBlank() },
videoId = video.id.takeIf { it.isNotBlank() },
)
}
.distinctBy { it.videoId ?: "${it.season}:${it.episode}" }
.sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode))
.toList()
}
private fun normalizeTitle(title: String?): String =
title.orEmpty().trim().lowercase()
.replace(Regex("[^a-z0-9]"), "")
}
// ── Data classes ────────────────────────────────────────────────────────
data class EpisodeMappingEntry(
val season: Int,
val episode: Int,
val title: String? = null,
val videoId: String? = null,
)
// ── Trakt API DTOs for seasons endpoint ─────────────────────────────────
@Serializable
private data class TraktSeasonDto(
@SerialName("number") val number: Int? = null,
@SerialName("episodes") val episodes: List<TraktSeasonEpisodeDto>? = null,
)
@Serializable
private data class TraktSeasonEpisodeDto(
@SerialName("number") val number: Int? = null,
@SerialName("season") val season: Int? = null,
@SerialName("title") val title: String? = null,
)

View file

@ -7,6 +7,7 @@ internal data class TraktExternalIds(
val trakt: Int? = null, val trakt: Int? = null,
val imdb: String? = null, val imdb: String? = null,
val tmdb: Int? = null, val tmdb: Int? = null,
val slug: String? = null,
) )
internal fun parseTraktContentIds(contentId: String?): TraktExternalIds { internal fun parseTraktContentIds(contentId: String?): TraktExternalIds {

View file

@ -0,0 +1,60 @@
package com.nuvio.app.features.trakt
import kotlinx.serialization.Serializable
private val traktHostPattern = Regex("""^[a-z0-9.-]*trakt\.tv/""", RegexOption.IGNORE_CASE)
@Serializable
internal data class TraktImagesDto(
val fanart: List<String>? = null,
val poster: List<String>? = null,
val logo: List<String>? = null,
val clearart: List<String>? = null,
val banner: List<String>? = null,
val thumb: List<String>? = null,
)
internal fun List<String>?.firstTraktImageUrl(): String? {
return orEmpty()
.firstOrNull { it.isNotBlank() }
?.toTraktImageUrl()
}
internal fun String.toTraktImageUrl(): String {
val normalized = trim()
return when {
normalized.startsWith("https://", ignoreCase = true) -> normalized
normalized.startsWith("http://", ignoreCase = true) -> "https://${normalized.substringAfter("://")}"
normalized.startsWith("//") -> "https:$normalized"
traktHostPattern.containsMatchIn(normalized) -> "https://$normalized"
else -> normalized
}
}
internal fun TraktImagesDto?.traktPosterUrl(): String? = this?.poster.firstTraktImageUrl()
internal fun TraktImagesDto?.traktFanartUrl(): String? = this?.fanart.firstTraktImageUrl()
internal fun TraktImagesDto?.traktLogoUrl(): String? = this?.logo.firstTraktImageUrl()
internal fun TraktImagesDto?.traktClearartUrl(): String? = this?.clearart.firstTraktImageUrl()
internal fun TraktImagesDto?.traktBannerUrl(): String? = this?.banner.firstTraktImageUrl()
internal fun TraktImagesDto?.traktThumbUrl(): String? = this?.thumb.firstTraktImageUrl()
internal fun TraktImagesDto?.traktBestPosterUrl(): String? {
return traktPosterUrl() ?: traktFanartUrl()
}
internal fun TraktImagesDto?.traktBestBackdropUrl(): String? {
return traktFanartUrl() ?: traktBannerUrl() ?: traktThumbUrl() ?: traktPosterUrl()
}
internal fun TraktImagesDto?.traktBestLandscapeUrl(): String? {
return traktThumbUrl() ?: traktFanartUrl() ?: traktBannerUrl() ?: traktPosterUrl()
}
internal fun TraktImagesDto?.traktBestLogoUrl(): String? {
return traktLogoUrl() ?: traktClearartUrl()
}

View file

@ -1,20 +1,15 @@
package com.nuvio.app.features.trakt package com.nuvio.app.features.trakt
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpGetTextWithHeaders
import com.nuvio.app.features.addons.httpPostJsonWithHeaders import com.nuvio.app.features.addons.httpPostJsonWithHeaders
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.library.LibraryItem
import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.tmdb.TmdbService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -28,7 +23,6 @@ import org.jetbrains.compose.resources.getString
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.selects.select import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
@ -38,8 +32,6 @@ import kotlinx.serialization.json.Json
private const val BASE_URL = "https://api.trakt.tv" private const val BASE_URL = "https://api.trakt.tv"
private const val WATCHLIST_KEY = "trakt:watchlist" private const val WATCHLIST_KEY = "trakt:watchlist"
private const val PERSONAL_LIST_PREFIX = "trakt:list:" private const val PERSONAL_LIST_PREFIX = "trakt:list:"
private const val METADATA_FETCH_TIMEOUT_MS = 3_500L
private const val METADATA_FETCH_CONCURRENCY = 5
private const val LIST_FETCH_CONCURRENCY = 4 private const val LIST_FETCH_CONCURRENCY = 4
private const val SNAPSHOT_CACHE_TTL_MS = 60_000L private const val SNAPSHOT_CACHE_TTL_MS = 60_000L
private const val LIST_TABS_CACHE_TTL_MS = 60_000L private const val LIST_TABS_CACHE_TTL_MS = 60_000L
@ -68,7 +60,6 @@ object TraktLibraryRepository {
private var hasLoaded = false private var hasLoaded = false
private val refreshMutex = Mutex() private val refreshMutex = Mutex()
private var hydrationJob: Job? = null
private var lastRefreshAtMs: Long = 0L private var lastRefreshAtMs: Long = 0L
private var lastListTabsRefreshAtMs: Long = 0L private var lastListTabsRefreshAtMs: Long = 0L
@ -91,8 +82,6 @@ object TraktLibraryRepository {
} }
fun onProfileChanged() { fun onProfileChanged() {
hydrationJob?.cancel()
hydrationJob = null
hasLoaded = false hasLoaded = false
lastRefreshAtMs = 0L lastRefreshAtMs = 0L
lastListTabsRefreshAtMs = 0L lastListTabsRefreshAtMs = 0L
@ -101,8 +90,6 @@ object TraktLibraryRepository {
} }
fun clearLocalState() { fun clearLocalState() {
hydrationJob?.cancel()
hydrationJob = null
hasLoaded = false hasLoaded = false
lastRefreshAtMs = 0L lastRefreshAtMs = 0L
lastListTabsRefreshAtMs = 0L lastListTabsRefreshAtMs = 0L
@ -154,8 +141,6 @@ object TraktLibraryRepository {
return return
} }
AddonRepository.initialize()
val headers = TraktAuthRepository.authorizedHeaders() val headers = TraktAuthRepository.authorizedHeaders()
if (headers == null) { if (headers == null) {
_uiState.value = TraktLibraryUiState() _uiState.value = TraktLibraryUiState()
@ -173,7 +158,6 @@ object TraktLibraryRepository {
hasLoaded = true, hasLoaded = true,
errorMessage = null, errorMessage = null,
) )
hydrateMissingMetadataAsync(_uiState.value)
} }
}.onFailure { error -> }.onFailure { error ->
if (error is CancellationException) throw error if (error is CancellationException) throw error
@ -195,7 +179,6 @@ object TraktLibraryRepository {
errorMessage = null, errorMessage = null,
) )
persistSnapshot(_uiState.value) persistSnapshot(_uiState.value)
hydrateMissingMetadataAsync(_uiState.value)
lastRefreshAtMs = now lastRefreshAtMs = now
} }
} }
@ -421,7 +404,6 @@ object TraktLibraryRepository {
entriesByList = cached.entriesByList, entriesByList = cached.entriesByList,
) )
_uiState.value = state.copy(isLoading = false, errorMessage = null, hasLoaded = true) _uiState.value = state.copy(isLoading = false, errorMessage = null, hasLoaded = true)
hydrateMissingMetadataAsync(_uiState.value)
} }
private fun persistSnapshot(state: TraktLibraryUiState) { private fun persistSnapshot(state: TraktLibraryUiState) {
@ -432,59 +414,6 @@ object TraktLibraryRepository {
TraktLibraryStorage.savePayload(json.encodeToString(payload)) TraktLibraryStorage.savePayload(json.encodeToString(payload))
} }
private fun hydrateMissingMetadataAsync(state: TraktLibraryUiState) {
if (state.entriesByList.isEmpty()) return
if (state.allItems.none(::shouldHydrateTraktLibraryItem)) return
hydrationJob?.cancel()
hydrationJob = scope.launch {
val hydratedEntriesByList = runCatching {
hydrateEntriesFromAddonMeta(state.entriesByList)
}.onFailure { error ->
if (error is CancellationException) throw error
log.w { "Background Trakt metadata hydration failed: ${error.message}" }
}.getOrNull() ?: return@launch
refreshMutex.withLock {
val current = _uiState.value
if (current.entriesByList.isEmpty()) return@withLock
val mergedEntriesByList = mergeHydratedEntries(
currentEntriesByList = current.entriesByList,
hydratedEntriesByList = hydratedEntriesByList,
)
if (mergedEntriesByList == current.entriesByList) return@withLock
val rebuilt = rebuildUiState(
listTabs = current.listTabs,
entriesByList = mergedEntriesByList,
).copy(
isLoading = current.isLoading,
hasLoaded = current.hasLoaded,
errorMessage = current.errorMessage,
)
_uiState.value = rebuilt
persistSnapshot(rebuilt)
}
}
}
private fun mergeHydratedEntries(
currentEntriesByList: Map<String, List<LibraryItem>>,
hydratedEntriesByList: Map<String, List<LibraryItem>>,
): Map<String, List<LibraryItem>> {
val hydratedByContentKey = hydratedEntriesByList.values
.flatten()
.associateBy { contentKey(it.id, it.type) }
return currentEntriesByList.mapValues { (_, entries) ->
entries.map { entry ->
hydratedByContentKey[contentKey(entry.id, entry.type)] ?: entry
}
}
}
private suspend fun fetchListTabs(headers: Map<String, String>): List<TraktListTab> { private suspend fun fetchListTabs(headers: Map<String, String>): List<TraktListTab> {
val watchlistTabs = listOf( val watchlistTabs = listOf(
TraktListTab( TraktListTab(
@ -544,83 +473,6 @@ object TraktLibraryRepository {
entriesByList.toMap() entriesByList.toMap()
} }
private suspend fun hydrateEntriesFromAddonMeta(
entriesByList: Map<String, List<LibraryItem>>,
): Map<String, List<LibraryItem>> = coroutineScope {
if (entriesByList.isEmpty()) return@coroutineScope entriesByList
val uniqueItems = entriesByList.values
.flatten()
.distinctBy { contentKey(it.id, it.type) }
if (uniqueItems.isEmpty()) return@coroutineScope entriesByList
val semaphore = Semaphore(METADATA_FETCH_CONCURRENCY)
val hydratedByKey = uniqueItems
.map { item ->
async {
semaphore.withPermit {
val hydrated = hydrateItemFromAddonMeta(item)
contentKey(item.id, item.type) to hydrated
}
}
}
.awaitAll()
.toMap()
entriesByList.mapValues { (_, entries) ->
entries.map { entry -> hydratedByKey[contentKey(entry.id, entry.type)] ?: entry }
}
}
private suspend fun hydrateItemFromAddonMeta(item: LibraryItem): LibraryItem {
if (!shouldHydrateTraktLibraryItem(item)) {
return item
}
val typeCandidates = if (normalizeType(item.type) == "movie") {
listOf("movie")
} else {
listOf("series", "tv")
}
val idCandidates = buildList {
add(item.id)
if (item.id.startsWith("tmdb:")) {
add(item.id.substringAfter(':'))
}
if (item.id.startsWith("trakt:")) {
add(item.id.substringAfter(':'))
}
}.distinct()
if (idCandidates.isEmpty()) {
return item
}
for (type in typeCandidates) {
for (id in idCandidates) {
val meta = withTimeoutOrNull(METADATA_FETCH_TIMEOUT_MS) {
MetaDetailsRepository.fetch(type = type, id = id)
}
if (meta == null) continue
val shouldOverrideName = item.name.isBlank() || item.name == item.id
return item.copy(
name = if (shouldOverrideName) meta.name else item.name,
poster = item.poster.orValidImageUrl(meta.poster),
banner = item.banner.orValidImageUrl(meta.background),
logo = item.logo.orValidImageUrl(meta.logo),
description = item.description.orIfBlank(meta.description),
releaseInfo = item.releaseInfo.orIfBlank(meta.releaseInfo),
imdbRating = item.imdbRating.orIfBlank(meta.imdbRating),
genres = if (item.genres.isEmpty()) meta.genres else item.genres,
)
}
}
return item
}
private suspend fun fetchPersonalLists(headers: Map<String, String>): List<TraktListTab> { private suspend fun fetchPersonalLists(headers: Map<String, String>): List<TraktListTab> {
val payload = httpGetTextWithHeaders( val payload = httpGetTextWithHeaders(
url = "$BASE_URL/users/me/lists", url = "$BASE_URL/users/me/lists",
@ -786,10 +638,9 @@ object TraktLibraryRepository {
?: ids?.trakt?.let { "trakt:$it" } ?: ids?.trakt?.let { "trakt:$it" }
?: return null ?: return null
val poster = media.images?.poster.firstNonBlankImageUrl() val poster = media.images.traktBestPosterUrl()
?: media.images?.fanart.firstNonBlankImageUrl() val banner = media.images.traktBestBackdropUrl()
val banner = media.images?.banner.firstNonBlankImageUrl() val logo = media.images.traktBestLogoUrl()
val logo = media.images?.logo.firstNonBlankImageUrl()
val savedAt = item.listedAt val savedAt = item.listedAt
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
@ -829,34 +680,6 @@ object TraktLibraryRepository {
return yearText.toIntOrNull() return yearText.toIntOrNull()
} }
private fun String?.orIfBlank(fallback: String?): String? {
val current = this?.trim().takeUnless { it.isNullOrBlank() }
if (current != null) return current
return fallback?.trim().takeUnless { it.isNullOrBlank() }
}
private fun String?.orValidImageUrl(fallback: String?): String? {
val current = this.normalizeImageUrl()
if (current != null) return current
return fallback.normalizeImageUrl()
}
private fun List<String>?.firstNonBlankImageUrl(): String? {
return this
?.asSequence()
?.mapNotNull { it.normalizeImageUrl() }
?.firstOrNull()
}
private fun String?.normalizeImageUrl(): String? {
val value = this?.trim().takeUnless { it.isNullOrBlank() } ?: return null
val normalized = if (value.startsWith("//")) "https:$value" else value
return normalized.takeIf {
it.startsWith("https://", ignoreCase = true) ||
it.startsWith("http://", ignoreCase = true)
}
}
private val imdbRegex = Regex("tt\\d+") private val imdbRegex = Regex("tt\\d+")
} }
@ -866,11 +689,6 @@ private data class StoredTraktLibraryPayload(
val entriesByList: Map<String, List<LibraryItem>> = emptyMap(), val entriesByList: Map<String, List<LibraryItem>> = emptyMap(),
) )
internal fun shouldHydrateTraktLibraryItem(item: LibraryItem): Boolean {
val missingDisplayName = item.name.isBlank() || item.name == item.id
return missingDisplayName || item.poster.isNullOrBlank() || item.releaseInfo.isNullOrBlank()
}
@Serializable @Serializable
private data class TraktListSummaryDto( private data class TraktListSummaryDto(
val name: String? = null, val name: String? = null,
@ -902,14 +720,6 @@ private data class TraktMediaDto(
val images: TraktImagesDto? = null, val images: TraktImagesDto? = null,
) )
@Serializable
private data class TraktImagesDto(
val fanart: List<String>? = null,
val poster: List<String>? = null,
val logo: List<String>? = null,
val banner: List<String>? = null,
)
@Serializable @Serializable
private data class TraktIdsDto( private data class TraktIdsDto(
val trakt: Int? = null, val trakt: Int? = null,

View file

@ -434,9 +434,31 @@ object TraktProgressRepository {
entries.map { entry -> entries.map { entry ->
val meta = metadataByContent[entry.parentMetaType to entry.parentMetaId] ?: return@map entry val meta = metadataByContent[entry.parentMetaType to entry.parentMetaId] ?: return@map entry
val episode = if (entry.seasonNumber != null && entry.episodeNumber != null) { var resolvedSeason = entry.seasonNumber
meta.videos.firstOrNull { video -> var resolvedEpisode = entry.episodeNumber
video.season == entry.seasonNumber && video.episode == entry.episodeNumber
val episode = if (resolvedSeason != null && resolvedEpisode != null) {
// Try direct match first
val directMatch = meta.videos.firstOrNull { video ->
video.season == resolvedSeason && video.episode == resolvedEpisode
}
if (directMatch != null) {
directMatch
} else {
// Fallback: reverse-remap from Trakt numbering to addon numbering
val addonSeasons = meta.videos.mapTo(mutableSetOf()) { it.season }
if (resolvedSeason == 1 && addonSeasons.size > 1 && resolvedEpisode!! > 0) {
val sorted = meta.videos
.filter { it.season != null && it.episode != null }
.sortedWith(compareBy({ it.season }, { it.episode }))
val globalIndex = resolvedEpisode!! - 1
if (globalIndex in sorted.indices) {
val remapped = sorted[globalIndex]
resolvedSeason = remapped.season
resolvedEpisode = remapped.episode
remapped
} else null
} else null
} }
} else { } else {
null null
@ -447,6 +469,8 @@ object TraktProgressRepository {
logo = entry.logo ?: meta.logo, logo = entry.logo ?: meta.logo,
poster = entry.poster ?: meta.poster, poster = entry.poster ?: meta.poster,
background = entry.background ?: meta.background, background = entry.background ?: meta.background,
seasonNumber = resolvedSeason ?: entry.seasonNumber,
episodeNumber = resolvedEpisode ?: entry.episodeNumber,
episodeTitle = entry.episodeTitle ?: episode?.title, episodeTitle = entry.episodeTitle ?: episode?.title,
episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail, episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail,
pauseDescription = entry.pauseDescription pauseDescription = entry.pauseDescription

View file

@ -0,0 +1,380 @@
package com.nuvio.app.features.trakt
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.RawHttpResponse
import com.nuvio.app.features.addons.httpRequestRaw
import com.nuvio.app.features.catalog.CatalogPage
import com.nuvio.app.features.collection.CollectionSource
import com.nuvio.app.features.collection.TmdbCollectionMediaType
import com.nuvio.app.features.collection.TraktListSort
import com.nuvio.app.features.collection.TraktSortHow
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape
import io.ktor.http.encodeURLParameter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlin.math.roundToInt
data class TraktPublicListImportMetadata(
val title: String? = null,
val coverImageUrl: String? = null,
val traktListId: Long? = null,
)
data class TraktPublicListSearchResult(
val traktListId: Long,
val title: String,
val subtitle: String,
val coverImageUrl: String? = null,
val sortBy: String? = null,
val sortHow: String? = null,
)
object TraktPublicListSourceResolver {
const val PAGE_LIMIT = 50
private const val BASE_URL = "https://api.trakt.tv"
private const val API_VERSION = "2"
private val log = Logger.withTag("TraktPublicListSource")
private val json = Json { ignoreUnknownKeys = true }
suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) {
val listId = source.traktListId?.takeIf { it > 0L } ?: error("Missing Trakt list ID")
val mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
val type = mediaType.toTraktType()
val sortBy = TraktListSort.normalize(source.sortBy)
val sortHow = TraktSortHow.normalize(source.sortHow)
val response = requestRaw(
endpoint = "lists/$listId/items/$type",
query = mapOf(
"extended" to "full,images",
"page" to page.toString(),
"limit" to PAGE_LIMIT.toString(),
"sort_by" to sortBy,
"sort_how" to sortHow,
),
)
if (response.status !in 200..299) {
error(errorMessageFor(response.status, "Could not load Trakt list"))
}
val rawItems = json.decodeFromString<List<PublicTraktListItemDto>>(response.body)
val items = rawItems
.mapNotNull { it.toPreview(mediaType) }
.distinctBy { "${it.type}:${it.id}" }
val pageCount = response.headerInt("x-pagination-page-count") ?: page
CatalogPage(
items = items,
rawItemCount = items.size,
nextSkip = if (page < pageCount && items.isNotEmpty()) page + 1 else null,
)
}
suspend fun listImportMetadata(input: String): TraktPublicListImportMetadata = withContext(Dispatchers.Default) {
val idPath = parseTraktListPath(input) ?: error("Enter a valid Trakt list ID or URL")
val list = requestJson<PublicTraktListSummaryDto>(
endpoint = "lists/$idPath",
query = mapOf("extended" to "full,images"),
)
val id = list.ids?.trakt ?: idPath.toLongOrNull() ?: error("Trakt list did not include a numeric ID")
TraktPublicListImportMetadata(
title = list.name?.takeIf { it.isNotBlank() },
coverImageUrl = list.images?.posters.firstTraktImageUrl(),
traktListId = id,
)
}
suspend fun searchPublicLists(query: String): List<TraktPublicListSearchResult> = withContext(Dispatchers.Default) {
val trimmed = query.trim()
if (trimmed.isBlank()) return@withContext emptyList()
requestJson<List<PublicTraktSearchResultDto>>(
endpoint = "search/list",
query = mapOf(
"query" to trimmed,
"extended" to "full,images",
"page" to "1",
"limit" to "20",
),
).mapNotNull { it.toPublicListResult() }
}
suspend fun trendingPublicLists(): List<TraktPublicListSearchResult> =
loadProminentLists("lists/trending")
suspend fun popularPublicLists(): List<TraktPublicListSearchResult> =
loadProminentLists("lists/popular")
fun parseTraktListId(input: String): Long? =
parseTraktListPath(input)?.toLongOrNull()
private suspend fun loadProminentLists(endpoint: String): List<TraktPublicListSearchResult> =
withContext(Dispatchers.Default) {
requestJson<List<PublicTraktProminentListDto>>(
endpoint = endpoint,
query = mapOf(
"extended" to "full,images",
"page" to "1",
"limit" to "20",
),
).mapNotNull { item ->
item.list?.toPublicListResult(likeCount = item.likeCount)
}
}
private suspend inline fun <reified T> requestJson(
endpoint: String,
query: Map<String, String> = emptyMap(),
): T {
val response = requestRaw(endpoint = endpoint, query = query)
if (response.status !in 200..299) {
error(errorMessageFor(response.status, "Trakt request failed"))
}
return runCatching { json.decodeFromString<T>(response.body) }
.onFailure { error -> log.w(error) { "Failed to parse Trakt response for $endpoint" } }
.getOrThrow()
}
private suspend fun requestRaw(
endpoint: String,
query: Map<String, String> = emptyMap(),
): RawHttpResponse {
if (TraktConfig.CLIENT_ID.isBlank()) {
error("Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID).")
}
val url = buildTraktUrl(endpoint, query)
return httpRequestRaw(
method = "GET",
url = url,
headers = mapOf(
"Accept" to "application/json",
"trakt-api-version" to API_VERSION,
"trakt-api-key" to TraktConfig.CLIENT_ID,
),
body = "",
)
}
private fun buildTraktUrl(endpoint: String, query: Map<String, String>): String {
val trimmedEndpoint = endpoint.trim().trim('/')
val queryString = query.entries
.filter { (_, value) -> value.isNotBlank() }
.joinToString("&") { (key, value) ->
"${key.encodeURLParameter()}=${value.encodeURLParameter()}"
}
return if (queryString.isBlank()) {
"$BASE_URL/$trimmedEndpoint"
} else {
"$BASE_URL/$trimmedEndpoint?$queryString"
}
}
private fun PublicTraktListItemDto.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? {
return when (mediaType) {
TmdbCollectionMediaType.MOVIE -> movie?.toPreview()
TmdbCollectionMediaType.TV -> show?.toPreview()
}
}
private fun PublicTraktMovieDto.toPreview(): MetaPreview? {
val title = title?.takeIf { it.isNotBlank() } ?: return null
val fallback = when {
ids?.trakt != null -> "trakt:${ids.trakt}"
!ids?.slug.isNullOrBlank() -> "movie:${ids.slug}"
else -> null
}
val contentId = normalizeTraktContentId(ids, fallback)
if (contentId.isBlank()) return null
return MetaPreview(
id = contentId,
type = "movie",
name = title,
poster = images.traktBestPosterUrl(),
banner = images.traktBestBackdropUrl(),
logo = images.traktBestLogoUrl(),
posterShape = PosterShape.Poster,
description = overview?.takeIf { it.isNotBlank() },
releaseInfo = year?.toString() ?: released?.take(4),
rawReleaseDate = released,
imdbRating = rating?.formatRating(),
genres = genres.orEmpty(),
)
}
private fun PublicTraktShowDto.toPreview(): MetaPreview? {
val title = title?.takeIf { it.isNotBlank() } ?: return null
val fallback = when {
ids?.trakt != null -> "trakt:${ids.trakt}"
!ids?.slug.isNullOrBlank() -> "series:${ids.slug}"
else -> null
}
val contentId = normalizeTraktContentId(ids, fallback)
if (contentId.isBlank()) return null
return MetaPreview(
id = contentId,
type = "series",
name = title,
poster = images.traktBestPosterUrl(),
banner = images.traktBestBackdropUrl(),
logo = images.traktBestLogoUrl(),
posterShape = PosterShape.Poster,
description = overview?.takeIf { it.isNotBlank() },
releaseInfo = year?.toString() ?: firstAired?.take(4),
rawReleaseDate = firstAired,
imdbRating = rating?.formatRating(),
genres = genres.orEmpty(),
)
}
private fun PublicTraktSearchResultDto.toPublicListResult(): TraktPublicListSearchResult? {
if (!type.equals("list", ignoreCase = true)) return null
return list?.toPublicListResult()
}
private fun PublicTraktListSummaryDto.toPublicListResult(likeCount: Int? = null): TraktPublicListSearchResult? {
val id = ids?.trakt ?: return null
val listTitle = name?.takeIf { it.isNotBlank() } ?: "Trakt List $id"
val owner = user?.username?.takeIf { it.isNotBlank() }
val stats = buildList {
itemCount?.let { add("$it items") }
(likeCount ?: likes)?.let { add("$it likes") }
}
val subtitle = (listOfNotNull(owner) + stats).joinToString("").ifBlank { "Trakt public list" }
return TraktPublicListSearchResult(
traktListId = id,
title = listTitle,
subtitle = subtitle,
coverImageUrl = images?.posters.firstTraktImageUrl(),
sortBy = sortBy,
sortHow = sortHow,
)
}
private fun parseTraktListPath(input: String): String? {
val trimmed = input.trim()
if (trimmed.isBlank()) return null
trimmed.toLongOrNull()?.let { return it.toString() }
Regex("""[?&]id=([^&#/]+)""")
.find(trimmed)
?.groupValues
?.getOrNull(1)
?.takeIf { it.isNotBlank() }
?.let { return it }
Regex("""trakt\.tv/lists/([^/?#]+)""", RegexOption.IGNORE_CASE)
.find(trimmed)
?.groupValues
?.getOrNull(1)
?.takeIf { it.isNotBlank() }
?.let { return it }
Regex("""trakt\.tv/users/[^/]+/lists/([^/?#]+)""", RegexOption.IGNORE_CASE)
.find(trimmed)
?.groupValues
?.getOrNull(1)
?.takeIf { it.isNotBlank() }
?.let { return it }
return trimmed.takeIf { it.matches(Regex("""[A-Za-z0-9_-]+""")) }
}
private fun TmdbCollectionMediaType.toTraktType(): String =
when (this) {
TmdbCollectionMediaType.MOVIE -> "movie"
TmdbCollectionMediaType.TV -> "show"
}
private fun RawHttpResponse.headerInt(name: String): Int? =
headers.entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }
?.value
?.substringBefore(',')
?.trim()
?.toIntOrNull()
private fun errorMessageFor(code: Int, fallback: String): String {
return when (code) {
401, 403, 404 -> "Trakt list not found or not public"
429 -> "Trakt rate limit reached"
else -> "$fallback ($code)"
}
}
}
private fun Double.formatRating(): String =
((this * 10).roundToInt() / 10.0).toString()
@Serializable
private data class PublicTraktSearchResultDto(
val type: String? = null,
val list: PublicTraktListSummaryDto? = null,
)
@Serializable
private data class PublicTraktProminentListDto(
@SerialName("like_count") val likeCount: Int? = null,
val list: PublicTraktListSummaryDto? = null,
)
@Serializable
private data class PublicTraktListSummaryDto(
val name: String? = null,
val description: String? = null,
@SerialName("sort_by") val sortBy: String? = null,
@SerialName("sort_how") val sortHow: String? = null,
@SerialName("item_count") val itemCount: Int? = null,
val likes: Int? = null,
val ids: PublicTraktListIdsDto? = null,
val user: PublicTraktUserDto? = null,
val images: PublicTraktListImagesDto? = null,
)
@Serializable
private data class PublicTraktListImagesDto(
val posters: List<String>? = null,
)
@Serializable
private data class PublicTraktListIdsDto(
val trakt: Long? = null,
val slug: String? = null,
)
@Serializable
private data class PublicTraktUserDto(
val username: String? = null,
)
@Serializable
private data class PublicTraktListItemDto(
val rank: Int? = null,
val id: Long? = null,
@SerialName("listed_at") val listedAt: String? = null,
val type: String? = null,
val movie: PublicTraktMovieDto? = null,
val show: PublicTraktShowDto? = null,
)
@Serializable
private data class PublicTraktMovieDto(
val title: String? = null,
val year: Int? = null,
val ids: TraktExternalIds? = null,
val overview: String? = null,
val released: String? = null,
val rating: Double? = null,
val genres: List<String>? = null,
val images: TraktImagesDto? = null,
)
@Serializable
private data class PublicTraktShowDto(
val title: String? = null,
val year: Int? = null,
val ids: TraktExternalIds? = null,
val overview: String? = null,
@SerialName("first_aired") val firstAired: String? = null,
val rating: Double? = null,
val genres: List<String>? = null,
val images: TraktImagesDto? = null,
)

View file

@ -46,12 +46,30 @@ fun nextReleasedEpisodeAfter(
compareBy<WatchingReleasedEpisode>({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }), compareBy<WatchingReleasedEpisode>({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }),
) )
val watchedVideoId = buildPlaybackVideoId(content, seasonNumber, episodeNumber) val watchedVideoId = buildPlaybackVideoId(content, seasonNumber, episodeNumber)
var watchedIndex = sortedEpisodes.indexOfFirst { episode ->
buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) == watchedVideoId
}
// Fallback: if the seed wasn't found by season+episode (anime with absolute
// numbering on Trakt vs multi-season on addon), try global index matching.
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.seasonNumber }
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
val globalIndex = episodeNumber - 1
if (globalIndex in sortedEpisodes.indices) {
watchedIndex = globalIndex
}
}
}
if (watchedIndex < 0) return null
val watchedEpisodeSeason = sortedEpisodes[watchedIndex].seasonNumber
val candidates = sortedEpisodes val candidates = sortedEpisodes
.dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId } .drop(watchedIndex + 1)
.drop(1)
.filter { episode -> .filter { episode ->
shouldSurfaceNextEpisode( shouldSurfaceNextEpisode(
watchedSeasonNumber = seasonNumber, watchedSeasonNumber = watchedEpisodeSeason,
candidateSeasonNumber = episode.seasonNumber, candidateSeasonNumber = episode.seasonNumber,
todayIsoDate = todayIsoDate, todayIsoDate = todayIsoDate,
releasedDate = episode.releasedDate, releasedDate = episode.releasedDate,

View file

@ -166,7 +166,11 @@ fun latestCompletedSeriesEpisode(
{ it.markedAtEpochMs }, { it.markedAtEpochMs },
) )
} else { } else {
compareBy<WatchingCompletedEpisode> { it.markedAtEpochMs } compareBy<WatchingCompletedEpisode>(
{ it.markedAtEpochMs },
{ normalizeSeasonNumber(it.seasonNumber) },
{ it.episodeNumber },
)
} }
val allMarkers = buildList { val allMarkers = buildList {
progressRecords progressRecords

View file

@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpGetTextWithHeaders
import com.nuvio.app.features.addons.httpPostJsonWithHeaders import com.nuvio.app.features.addons.httpPostJsonWithHeaders
import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktEpisodeMappingService
import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.WatchedItem
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -92,7 +93,30 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
} }
} }
return result // Apply reverse mapping for anime: if Trakt uses absolute numbering (S1E1..S1EN)
// but addon uses multi-season, remap pulled episodes to addon numbering.
val remappedResult = mutableListOf<WatchedItem>()
for (item in result) {
if (item.season == null || item.episode == null || item.type != "series") {
remappedResult += item
continue
}
val mapped = runCatching {
TraktEpisodeMappingService.resolveAddonEpisodeMapping(
contentId = item.id,
contentType = item.type,
season = item.season,
episode = item.episode,
)
}.getOrNull()
if (mapped != null && (mapped.season != item.season || mapped.episode != item.episode)) {
remappedResult += item.copy(season = mapped.season, episode = mapped.episode)
} else {
remappedResult += item
}
}
return remappedResult
} }
// ── push (add to history) ─────────────────────────────────────────── // ── push (add to history) ───────────────────────────────────────────
@ -107,6 +131,8 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
val shows = mutableListOf<TraktHistoryShowRequestDto>() val shows = mutableListOf<TraktHistoryShowRequestDto>()
items.forEach { item -> items.forEach { item ->
if (!item.shouldSyncToTraktHistory()) return@forEach
val ids = parseIds(item.id) ?: return@forEach val ids = parseIds(item.id) ?: return@forEach
val normalizedType = item.type.trim().lowercase() val normalizedType = item.type.trim().lowercase()
@ -161,16 +187,11 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
), ),
) )
} }
} else {
// Series-level mark (no season/episode) → mark entire show
shows += TraktHistoryShowRequestDto(
title = item.name.takeIf { it.isNotBlank() },
year = parseYear(item.releaseInfo),
ids = ids,
)
} }
} }
if (movies.isEmpty() && shows.isEmpty()) return
val body = json.encodeToString( val body = json.encodeToString(
TraktHistoryAddRequestDto( TraktHistoryAddRequestDto(
movies = movies.takeIf { it.isNotEmpty() }, movies = movies.takeIf { it.isNotEmpty() },
@ -178,7 +199,7 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
), ),
) )
runCatching { val responseText = runCatching {
httpPostJsonWithHeaders( httpPostJsonWithHeaders(
url = "$BASE_URL/sync/history", url = "$BASE_URL/sync/history",
body = body, body = body,
@ -187,6 +208,101 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
}.onFailure { e -> }.onFailure { e ->
if (e is CancellationException) throw e if (e is CancellationException) throw e
log.w { "Failed to push watched items to Trakt: ${e.message}" } log.w { "Failed to push watched items to Trakt: ${e.message}" }
}.getOrNull()
// Retry with remapped numbering for episodes that Trakt didn't recognize
// (anime with different season structures between addon and Trakt).
if (responseText != null && shows.isNotEmpty()) {
val episodeItems = items.filter {
it.season != null && it.episode != null &&
it.type.trim().lowercase() !in listOf("movie", "film")
}
if (episodeItems.isNotEmpty()) {
retryWithRemappedEpisodes(headers, episodeItems)
}
}
}
private suspend fun retryWithRemappedEpisodes(
headers: Map<String, String>,
items: Collection<WatchedItem>,
) {
val remappedShows = mutableListOf<TraktHistoryShowRequestDto>()
for (item in items) {
val season = item.season ?: continue
val episode = item.episode ?: continue
val mapped = TraktEpisodeMappingService.resolveEpisodeMapping(
contentId = item.id,
contentType = item.type,
videoId = null,
season = season,
episode = episode,
) ?: continue
if (mapped.season == season && mapped.episode == episode) continue
val ids = parseIds(item.id) ?: continue
val existing = remappedShows.firstOrNull { it.ids == ids }
if (existing != null) {
val seasonDto = existing.seasons?.firstOrNull { it.number == mapped.season }
if (seasonDto != null) {
(seasonDto.episodes as? MutableList)?.add(
TraktHistoryEpisodeRequestDto(
number = mapped.episode,
watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null,
),
)
} else {
(existing.seasons as? MutableList)?.add(
TraktHistorySeasonRequestDto(
number = mapped.season,
episodes = mutableListOf(
TraktHistoryEpisodeRequestDto(
number = mapped.episode,
watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null,
),
),
),
)
}
} else {
remappedShows += TraktHistoryShowRequestDto(
title = item.name.takeIf { it.isNotBlank() },
year = parseYear(item.releaseInfo),
ids = ids,
seasons = mutableListOf(
TraktHistorySeasonRequestDto(
number = mapped.season,
episodes = mutableListOf(
TraktHistoryEpisodeRequestDto(
number = mapped.episode,
watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null,
),
),
),
),
)
}
}
if (remappedShows.isEmpty()) return
val retryBody = json.encodeToString(
TraktHistoryAddRequestDto(
movies = null,
shows = remappedShows,
),
)
runCatching {
httpPostJsonWithHeaders(
url = "$BASE_URL/sync/history",
body = retryBody,
headers = headers,
)
}.onFailure { e ->
if (e is CancellationException) throw e
log.w { "Failed to push remapped episodes to Trakt: ${e.message}" }
} }
} }
@ -202,6 +318,8 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
val shows = mutableListOf<TraktHistoryShowRequestDto>() val shows = mutableListOf<TraktHistoryShowRequestDto>()
items.forEach { item -> items.forEach { item ->
if (!item.shouldSyncToTraktHistory()) return@forEach
val ids = parseIds(item.id) ?: return@forEach val ids = parseIds(item.id) ?: return@forEach
val normalizedType = item.type.trim().lowercase() val normalizedType = item.type.trim().lowercase()
@ -225,15 +343,11 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
), ),
), ),
) )
} else {
shows += TraktHistoryShowRequestDto(
title = item.name.takeIf { it.isNotBlank() },
year = parseYear(item.releaseInfo),
ids = ids,
)
} }
} }
if (movies.isEmpty() && shows.isEmpty()) return
val body = json.encodeToString( val body = json.encodeToString(
TraktHistoryRemoveRequestDto( TraktHistoryRemoveRequestDto(
movies = movies.takeIf { it.isNotEmpty() }, movies = movies.takeIf { it.isNotEmpty() },
@ -251,6 +365,70 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
if (e is CancellationException) throw e if (e is CancellationException) throw e
log.w { "Failed to remove watched items from Trakt: ${e.message}" } log.w { "Failed to remove watched items from Trakt: ${e.message}" }
} }
// Retry removal with remapped numbering for anime cases
val episodeItems = items.filter {
it.season != null && it.episode != null &&
it.type.trim().lowercase() !in listOf("movie", "film")
}
if (episodeItems.isNotEmpty()) {
retryDeleteWithRemappedEpisodes(headers, episodeItems)
}
}
private suspend fun retryDeleteWithRemappedEpisodes(
headers: Map<String, String>,
items: Collection<WatchedItem>,
) {
val remappedShowDtos = mutableListOf<TraktHistoryShowRequestDto>()
for (item in items) {
val season = item.season ?: continue
val episode = item.episode ?: continue
val mapped = TraktEpisodeMappingService.resolveEpisodeMapping(
contentId = item.id,
contentType = item.type,
videoId = null,
season = season,
episode = episode,
) ?: continue
if (mapped.season == season && mapped.episode == episode) continue
val ids = parseIds(item.id) ?: continue
remappedShowDtos += TraktHistoryShowRequestDto(
title = item.name.takeIf { it.isNotBlank() },
year = parseYear(item.releaseInfo),
ids = ids,
seasons = listOf(
TraktHistorySeasonRequestDto(
number = mapped.season,
episodes = listOf(
TraktHistoryEpisodeRequestDto(number = mapped.episode),
),
),
),
)
}
if (remappedShowDtos.isEmpty()) return
val retryBody = json.encodeToString(
TraktHistoryRemoveRequestDto(
movies = null,
shows = remappedShowDtos,
),
)
runCatching {
httpPostJsonWithHeaders(
url = "$BASE_URL/sync/history/remove",
body = retryBody,
headers = headers,
)
}.onFailure { e ->
if (e is CancellationException) throw e
log.w { "Failed to remove remapped episodes from Trakt: ${e.message}" }
}
} }
// ── helpers ───────────────────────────────────────────────────────── // ── helpers ─────────────────────────────────────────────────────────
@ -348,6 +526,13 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
private fun Int.pad4(): String = "$this".padStart(4, '0') private fun Int.pad4(): String = "$this".padStart(4, '0')
} }
internal fun WatchedItem.shouldSyncToTraktHistory(): Boolean {
val normalizedType = type.trim().lowercase()
return normalizedType == "movie" ||
normalizedType == "film" ||
(season != null && episode != null)
}
// ── DTOs for pull (GET /sync/watched) ─────────────────────────────────── // ── DTOs for pull (GET /sync/watched) ───────────────────────────────────
@Serializable @Serializable

View file

@ -0,0 +1,181 @@
package com.nuvio.app.features.collection
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class CollectionSourceSerializationTest {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
prettyPrint = false
}
@Test
fun traktSourceRoundTripsWithPublicListShape() {
val collection = Collection(
id = "collection-1",
title = "Favorites",
folders = listOf(
CollectionFolder(
id = "folder-1",
title = "Lists",
sources = listOf(
CollectionSource(
provider = "trakt",
title = "Criterion Movies",
traktListId = 123456L,
mediaType = TmdbCollectionMediaType.MOVIE.name,
sortBy = TraktListSort.ADDED.value,
sortHow = TraktSortHow.DESC.value,
),
),
),
),
)
val encoded = json.encodeToString(listOf(collection))
assertTrue(encoded.contains(""""provider":"trakt""""))
assertTrue(encoded.contains(""""traktListId":123456"""))
assertTrue(encoded.contains(""""sortHow":"desc""""))
val decoded = json.decodeFromString<List<Collection>>(encoded)
val source = decoded.single().folders.single().resolvedSources.single()
assertTrue(source.isTrakt)
assertEquals(123456L, source.traktListId)
assertEquals(TmdbCollectionMediaType.MOVIE.name, source.mediaType)
assertEquals(TraktListSort.ADDED.value, source.sortBy)
assertEquals(TraktSortHow.DESC.value, source.sortHow)
}
@Test
fun importedTraktSourceWithoutListIdIsRejected() {
val payload = """
[
{
"id": "collection-1",
"title": "Favorites",
"folders": [
{
"id": "folder-1",
"title": "Lists",
"sources": [
{
"provider": "trakt",
"title": "Missing List",
"mediaType": "MOVIE",
"sortBy": "rank",
"sortHow": "asc"
}
]
}
]
}
]
""".trimIndent()
val source = json.decodeFromString<List<Collection>>(payload)
.single()
.folders
.single()
.resolvedSources
.single()
assertTrue(source.hasInvalidTraktListId())
}
@Test
fun legacyAddonCatalogSourcesRemainCompatible() {
val payload = """
[
{
"id": "collection-1",
"title": "Favorites",
"folders": [
{
"id": "folder-1",
"title": "Movies",
"catalogSources": [
{
"addonId": "addon-1",
"type": "movie",
"catalogId": "top",
"genre": "Action"
}
]
}
]
}
]
""".trimIndent()
val collection = json.decodeFromString<List<Collection>>(payload).single()
val source = collection.folders.single().resolvedSources.single()
val addonSource = source.addonCatalogSource()
assertNotNull(addonSource)
assertEquals("addon-1", addonSource.addonId)
assertEquals("movie", addonSource.type)
assertEquals("top", addonSource.catalogId)
assertEquals("Action", addonSource.genre)
}
@Test
fun sourceKeyPreservationKeepsUnknownTraktFields() {
val raw = json.parseToJsonElement(
"""
[
{
"id": "collection-1",
"title": "Favorites",
"folders": [
{
"id": "folder-1",
"title": "Lists",
"sources": [
{
"provider": "trakt",
"title": "Criterion Movies",
"traktListId": 123456,
"mediaType": "MOVIE",
"sortBy": "rank",
"sortHow": "asc",
"customField": "keep-me"
}
]
}
]
}
]
""".trimIndent(),
)
val collection = Collection(
id = "collection-1",
title = "Favorites",
folders = listOf(
CollectionFolder(
id = "folder-1",
title = "Lists",
sources = listOf(
CollectionSource(
provider = "trakt",
title = "Criterion Movies",
traktListId = 123456L,
mediaType = TmdbCollectionMediaType.MOVIE.name,
sortBy = TraktListSort.RANK.value,
sortHow = TraktSortHow.ASC.value,
),
),
),
),
)
val merged = CollectionJsonPreserver.merge(json, raw, listOf(collection)).toString()
assertTrue(merged.contains(""""customField":"keep-me""""))
assertTrue(merged.contains(""""traktListId":123456"""))
}
}

View file

@ -0,0 +1,44 @@
package com.nuvio.app.features.trakt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class TraktImageUtilsTest {
@Test
fun normalizesTraktHostedImageUrls() {
assertEquals(
"https://media.trakt.tv/images/movies/poster.jpg.webp",
listOf("media.trakt.tv/images/movies/poster.jpg.webp").firstTraktImageUrl(),
)
assertEquals(
"https://media.trakt.tv/images/movies/poster.jpg.webp",
listOf("//media.trakt.tv/images/movies/poster.jpg.webp").firstTraktImageUrl(),
)
assertEquals(
"https://media.trakt.tv/images/movies/poster.jpg.webp",
listOf("http://media.trakt.tv/images/movies/poster.jpg.webp").firstTraktImageUrl(),
)
}
@Test
fun selectsBestTraktImages() {
val images = TraktImagesDto(
fanart = listOf("media.trakt.tv/images/movies/fanart.jpg.webp"),
logo = listOf("media.trakt.tv/images/movies/logo.png.webp"),
thumb = listOf("media.trakt.tv/images/movies/thumb.jpg.webp"),
)
assertEquals("https://media.trakt.tv/images/movies/fanart.jpg.webp", images.traktBestPosterUrl())
assertEquals("https://media.trakt.tv/images/movies/fanart.jpg.webp", images.traktBestBackdropUrl())
assertEquals("https://media.trakt.tv/images/movies/thumb.jpg.webp", images.traktBestLandscapeUrl())
assertEquals("https://media.trakt.tv/images/movies/logo.png.webp", images.traktBestLogoUrl())
}
@Test
fun returnsNullWhenTraktImagesAreMissing() {
assertNull(emptyList<String>().firstTraktImageUrl())
assertNull(TraktImagesDto().traktBestPosterUrl())
}
}

View file

@ -1,50 +0,0 @@
package com.nuvio.app.features.trakt
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.library.LibraryItem
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class TraktLibraryRepositoryTest {
@Test
fun `hydration skips items that already have core library data`() {
val item = LibraryItem(
id = "tt1234567",
type = "movie",
name = "Example",
poster = "https://image.tmdb.org/t/p/w500/poster.jpg",
banner = null,
logo = null,
description = null,
releaseInfo = "2024",
imdbRating = null,
genres = emptyList(),
posterShape = PosterShape.Poster,
savedAtEpochMs = 1L,
)
assertFalse(shouldHydrateTraktLibraryItem(item))
}
@Test
fun `hydration keeps filling missing poster metadata`() {
val item = LibraryItem(
id = "tt7654321",
type = "series",
name = "Example Show",
poster = null,
banner = null,
logo = null,
description = "",
releaseInfo = "2025",
imdbRating = null,
genres = emptyList(),
posterShape = PosterShape.Poster,
savedAtEpochMs = 1L,
)
assertTrue(shouldHydrateTraktLibraryItem(item))
}
}

View file

@ -0,0 +1,49 @@
package com.nuvio.app.features.trakt
import com.nuvio.app.features.collection.TraktListSort
import com.nuvio.app.features.collection.TraktSortHow
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class TraktPublicListSourceResolverTest {
@Test
fun parsesNumericTraktListIdsFromInputs() {
assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("123456"))
assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("https://trakt.tv/lists/123456"))
assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("https://trakt.tv/users/nuvio/lists/123456"))
assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("https://example.com/import?id=123456"))
assertNull(TraktPublicListSourceResolver.parseTraktListId(""))
}
@Test
fun normalizesTraktSortValues() {
assertEquals("rank", TraktListSort.normalize(null))
assertEquals("added", TraktListSort.normalize(" ADDED "))
assertEquals("rank", TraktListSort.normalize("unknown"))
assertEquals("asc", TraktSortHow.normalize(null))
assertEquals("desc", TraktSortHow.normalize(" DESC "))
assertEquals("asc", TraktSortHow.normalize("sideways"))
}
@Test
fun normalizesTraktImageUrls() {
assertEquals(
"https://media.trakt.tv/images/poster.jpg",
"media.trakt.tv/images/poster.jpg".toTraktImageUrl(),
)
assertEquals(
"https://media.trakt.tv/images/poster.jpg",
"http://media.trakt.tv/images/poster.jpg".toTraktImageUrl(),
)
assertEquals(
"https://cdn.example.com/poster.jpg",
"https://cdn.example.com/poster.jpg".toTraktImageUrl(),
)
assertEquals(
"https://media.trakt.tv/images/poster.jpg",
listOf("", "media.trakt.tv/images/poster.jpg").firstTraktImageUrl(),
)
}
}

View file

@ -1,9 +1,8 @@
package com.nuvio.app.features.downloads package com.nuvio.app.features.downloads
import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf import kotlinx.cinterop.CPointer
import kotlinx.cinterop.convert import kotlinx.cinterop.convert
import kotlinx.cinterop.usePinned
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -13,6 +12,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import platform.Foundation.NSError import platform.Foundation.NSError
import platform.Foundation.NSDate import platform.Foundation.NSDate
import platform.Foundation.NSData
import platform.Foundation.NSFileManager import platform.Foundation.NSFileManager
import platform.Foundation.NSHTTPURLResponse import platform.Foundation.NSHTTPURLResponse
import platform.Foundation.NSHomeDirectory import platform.Foundation.NSHomeDirectory
@ -23,16 +23,17 @@ import platform.Foundation.NSURLRequestReloadIgnoringLocalCacheData
import platform.Foundation.NSURLResponse import platform.Foundation.NSURLResponse
import platform.Foundation.NSURLSession import platform.Foundation.NSURLSession
import platform.Foundation.NSURLSessionConfiguration import platform.Foundation.NSURLSessionConfiguration
import platform.Foundation.NSURLSessionDownloadDelegateProtocol import platform.Foundation.NSURLSessionDataDelegateProtocol
import platform.Foundation.NSURLSessionDownloadTask import platform.Foundation.NSURLSessionDataTask
import platform.Foundation.NSURLSessionTask import platform.Foundation.NSURLSessionTask
import platform.Foundation.setHTTPMethod import platform.Foundation.setHTTPMethod
import platform.Foundation.setValue import platform.Foundation.setValue
import platform.Foundation.timeIntervalSince1970 import platform.Foundation.timeIntervalSince1970
import platform.darwin.NSObject import platform.darwin.NSObject
import platform.posix.fopen import platform.posix.FILE
import platform.posix.fclose import platform.posix.fclose
import platform.posix.fread import platform.posix.fflush
import platform.posix.fopen
import platform.posix.fwrite import platform.posix.fwrite
private const val DOWNLOAD_REQUEST_TIMEOUT_SECONDS = 60.0 private const val DOWNLOAD_REQUEST_TIMEOUT_SECONDS = 60.0
@ -49,6 +50,10 @@ fun handleDownloadsBackgroundEvents(
backgroundSessionCompletionHandlers[identifier] = completionHandler backgroundSessionCompletionHandlers[identifier] = completionHandler
} }
fun pauseDownloadsForAppBackground() {
DownloadsRepository.pauseActiveDownloads()
}
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
internal actual object DownloadsPlatformDownloader { internal actual object DownloadsPlatformDownloader {
actual fun start( actual fun start(
@ -132,22 +137,45 @@ internal actual object DownloadsPlatformDownloader {
actual fun removeFile(localFileUri: String?): Boolean { actual fun removeFile(localFileUri: String?): Boolean {
if (localFileUri.isNullOrBlank()) return false if (localFileUri.isNullOrBlank()) return false
val path = localFileUri.toLocalPath() ?: return false val path = localFileUri.toLocalPath() ?: return false
return removePathIfExists(path) if (NSFileManager.defaultManager.fileExistsAtPath(path)) {
return removePathIfExists(path)
}
val fileName = path.substringAfterLast('/').takeIf { it.isNotBlank() } ?: return false
return removePathIfExists("${downloadsDirectoryPath()}/$fileName")
} }
actual fun removePartialFile(destinationFileName: String): Boolean { actual fun removePartialFile(destinationFileName: String): Boolean {
val tempPath = "${downloadsDirectoryPath()}/$destinationFileName.part" val tempPath = "${downloadsDirectoryPath()}/$destinationFileName.part"
return removePathIfExists(tempPath) return removePathIfExists(tempPath)
} }
actual fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? {
localFileUri?.toLocalPath()
?.takeIf { NSFileManager.defaultManager.fileExistsAtPath(it) }
?.let { path ->
return NSURL.fileURLWithPath(path).absoluteString ?: "file://$path"
}
val fileName = destinationFileName.trim().takeIf { it.isNotBlank() }
?: localFileUri?.toLocalPath()?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
?: return null
val currentPath = "${downloadsDirectoryPath()}/$fileName"
return if (NSFileManager.defaultManager.fileExistsAtPath(currentPath)) {
NSURL.fileURLWithPath(currentPath).absoluteString ?: "file://$currentPath"
} else {
null
}
}
} }
private class IosDownloadsTaskHandle( private class IosDownloadsTaskHandle(
private val job: Job, private val job: Job,
) : DownloadsTaskHandle { ) : DownloadsTaskHandle {
private var task: NSURLSessionDownloadTask? = null private var task: NSURLSessionTask? = null
private var session: NSURLSession? = null private var session: NSURLSession? = null
fun attach(task: NSURLSessionDownloadTask, session: NSURLSession) { fun attach(task: NSURLSessionTask, session: NSURLSession) {
this.task = task this.task = task
this.session = session this.session = session
} }
@ -177,10 +205,14 @@ private class IosDownloadDelegate(
private val resumeFromBytes: Long, private val resumeFromBytes: Long,
private val tempPath: String, private val tempPath: String,
private val onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, private val onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
) : NSObject(), NSURLSessionDownloadDelegateProtocol { ) : NSObject(), NSURLSessionDataDelegateProtocol {
private val completion = CompletableDeferred<IosDownloadResult>() private val completion = CompletableDeferred<IosDownloadResult>()
private var result: IosDownloadResult? = null private var result: IosDownloadResult? = null
private var fileError: Throwable? = null private var fileError: Throwable? = null
private var outputFile: CPointer<FILE>? = null
private var startingBytesForResponse = 0L
private var bytesWrittenForResponse = 0L
private var totalBytesForResponse: Long? = null
private var lastProgressBytes = -1L private var lastProgressBytes = -1L
private var lastProgressTimestampSeconds = 0.0 private var lastProgressTimestampSeconds = 0.0
@ -188,12 +220,13 @@ private class IosDownloadDelegate(
override fun URLSession( override fun URLSession(
session: NSURLSession, session: NSURLSession,
downloadTask: NSURLSessionDownloadTask, dataTask: NSURLSessionDataTask,
didFinishDownloadingToURL: NSURL, didReceiveResponse: NSURLResponse,
completionHandler: (Long) -> Unit,
) { ) {
val httpResponse = downloadTask.response as? NSHTTPURLResponse val httpResponse = didReceiveResponse as? NSHTTPURLResponse
val statusCode = httpResponse?.statusCode?.toInt() ?: 200 val statusCode = httpResponse?.statusCode?.toInt() ?: 200
result = IosDownloadResult( val nextResult = IosDownloadResult(
statusCode = statusCode, statusCode = statusCode,
contentRange = httpResponse?.valueForHTTPHeaderField("Content-Range"), contentRange = httpResponse?.valueForHTTPHeaderField("Content-Range"),
contentLength = httpResponse contentLength = httpResponse
@ -201,51 +234,59 @@ private class IosDownloadDelegate(
?.toLongOrNull() ?.toLongOrNull()
?.takeIf { it > 0L }, ?.takeIf { it > 0L },
) )
result = nextResult
if (statusCode !in 200..299) return if (statusCode in 200..299) {
val isPartialResume = attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L
startingBytesForResponse = if (isPartialResume) resumeFromBytes else 0L
bytesWrittenForResponse = 0L
totalBytesForResponse = resolveTotalBytes(
startingBytes = startingBytesForResponse,
isPartialResume = isPartialResume,
contentRangeHeader = nextResult.contentRange,
contentLength = nextResult.contentLength,
)
val sourcePath = didFinishDownloadingToURL.path outputFile = fopen(tempPath, if (isPartialResume) "ab" else "wb") ?: run {
if (sourcePath.isNullOrBlank()) { fileError = IllegalStateException("Failed to open partial download file")
fileError = IllegalStateException("Downloaded file was not available") null
return }
reportProgress(startingBytesForResponse, totalBytesForResponse)
} }
val isPartialResume = attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L completionHandler(1L)
val stored = if (isPartialResume) {
appendFile(sourcePath, tempPath)
} else {
removePathIfExists(tempPath) &&
NSFileManager.defaultManager.moveItemAtPath(
srcPath = sourcePath,
toPath = tempPath,
error = null,
)
}
if (!stored) {
fileError = IllegalStateException("Failed to store download file")
}
} }
override fun URLSession( override fun URLSession(
session: NSURLSession, session: NSURLSession,
downloadTask: NSURLSessionDownloadTask, dataTask: NSURLSessionDataTask,
didWriteData: Long, didReceiveData: NSData,
totalBytesWritten: Long,
totalBytesExpectedToWrite: Long,
) { ) {
val statusCode = (downloadTask.response as? NSHTTPURLResponse)?.statusCode?.toInt() if (fileError != null) return
val startingBytes = if (attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L) {
resumeFromBytes val file = outputFile ?: run {
} else { fileError = IllegalStateException("Partial download file is not open")
0L return
} }
val expectedTotal = totalBytesExpectedToWrite
.takeIf { it > 0L } val bytesToWrite = didReceiveData.length.toLong()
?.let { startingBytes + it } val wrote = fwrite(
didReceiveData.bytes,
1.convert(),
bytesToWrite.convert(),
file,
).toLong()
if (wrote != bytesToWrite) {
fileError = IllegalStateException("Failed to write partial download file")
return
}
fflush(file)
bytesWrittenForResponse += bytesToWrite
reportProgress( reportProgress(
downloadedBytes = startingBytes + totalBytesWritten.coerceAtLeast(0L), downloadedBytes = startingBytesForResponse + bytesWrittenForResponse,
totalBytes = expectedTotal, totalBytes = totalBytesForResponse,
) )
} }
@ -254,6 +295,8 @@ private class IosDownloadDelegate(
task: NSURLSessionTask, task: NSURLSessionTask,
didCompleteWithError: NSError?, didCompleteWithError: NSError?,
) { ) {
closeOutputFile()
if (didCompleteWithError != null) { if (didCompleteWithError != null) {
completion.completeExceptionally( completion.completeExceptionally(
IllegalStateException(didCompleteWithError.localizedDescription), IllegalStateException(didCompleteWithError.localizedDescription),
@ -275,6 +318,14 @@ private class IosDownloadDelegate(
backgroundSessionCompletionHandlers.remove(identifier)?.invoke() backgroundSessionCompletionHandlers.remove(identifier)?.invoke()
} }
private fun closeOutputFile() {
outputFile?.let { file ->
fflush(file)
fclose(file)
}
outputFile = null
}
private fun reportProgress( private fun reportProgress(
downloadedBytes: Long, downloadedBytes: Long,
totalBytes: Long?, totalBytes: Long?,
@ -374,9 +425,11 @@ private suspend fun performDownloadRequest(
val session = NSURLSession.sessionWithConfiguration( val session = NSURLSession.sessionWithConfiguration(
configuration = configuration, configuration = configuration,
delegate = delegate, delegate = delegate,
delegateQueue = NSOperationQueue(), delegateQueue = NSOperationQueue().apply {
maxConcurrentOperationCount = 1
},
) )
val task = session.downloadTaskWithRequest(nativeRequest) val task = session.dataTaskWithRequest(nativeRequest)
handle.attach(task, session) handle.attach(task, session)
onProgress(resumeFromBytes.coerceAtLeast(0L), null) onProgress(resumeFromBytes.coerceAtLeast(0L), null)
@ -389,44 +442,6 @@ private suspend fun performDownloadRequest(
} }
} }
@OptIn(ExperimentalForeignApi::class)
private fun appendFile(sourcePath: String, destinationPath: String): Boolean {
val source = fopen(sourcePath, "rb") ?: return false
val destination = fopen(destinationPath, "ab") ?: run {
fclose(source)
return false
}
val buffer = ByteArray(16 * 1024)
return try {
while (true) {
val read = buffer.usePinned { pinned ->
fread(
pinned.addressOf(0),
1.convert(),
buffer.size.convert(),
source,
).toInt()
}
if (read <= 0) break
val wrote = buffer.usePinned { pinned ->
fwrite(
pinned.addressOf(0),
1.convert(),
read.convert(),
destination,
).toInt()
}
if (wrote != read) return false
}
true
} finally {
fclose(source)
fclose(destination)
}
}
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
private fun fileSizeOrNull(path: String): Long? { private fun fileSizeOrNull(path: String): Long? {
val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, error = null) val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, error = null)
@ -439,10 +454,11 @@ private fun fileSizeOrNull(path: String): Long? {
} }
private fun String.toLocalPath(): String? { private fun String.toLocalPath(): String? {
if (startsWith("file://")) { val value = trim()
return removePrefix("file://") if (value.startsWith("file:")) {
return NSURL(string = value).path ?: value.removePrefix("file://")
} }
return takeIf { it.isNotBlank() } return value.takeIf { it.isNotBlank() }
} }
private fun resolveTotalBytes( private fun resolveTotalBytes(

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=48 CURRENT_PROJECT_VERSION=49
MARKETING_VERSION=0.1.0 MARKETING_VERSION=0.1.0

View file

@ -34,6 +34,10 @@ final class OrientationLockAppDelegate: NSObject, UIApplicationDelegate, UNUserN
) )
} }
func applicationDidEnterBackground(_ application: UIApplication) {
DownloadsPlatformDownloader_iosKt.pauseDownloadsForAppBackground()
}
func userNotificationCenter( func userNotificationCenter(
_ center: UNUserNotificationCenter, _ center: UNUserNotificationCenter,
willPresent notification: UNNotification, willPresent notification: UNNotification,

View file

@ -137,12 +137,22 @@ struct TrackInfo {
let selected: Bool let selected: Bool
} }
private struct PendingLoadRequest {
let urlString: String
let audioUrl: String?
let requestHeaders: [String: String]
let queuedAtUptime: TimeInterval
}
// MARK: - MPV Player View Controller // MARK: - MPV Player View Controller
final class MPVPlayerViewController: UIViewController { final class MPVPlayerViewController: UIViewController {
private let errorStateLock = NSLock() private let errorStateLock = NSLock()
private var metalLayer = MetalLayer() private var metalLayer = MetalLayer()
private var lastAppliedDrawableSize: CGSize = .zero
private var pendingLoadRequest: PendingLoadRequest?
private var pendingLoadRetryWorkItem: DispatchWorkItem?
private var mpv: OpaquePointer? private var mpv: OpaquePointer?
private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated) private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated)
private var recentPlaybackLogs: [String] = [] private var recentPlaybackLogs: [String] = []
@ -188,12 +198,14 @@ final class MPVPlayerViewController: UIViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = .black view.backgroundColor = .black
view.layer.masksToBounds = true
metalLayer.frame = view.bounds metalLayer.contentsGravity = .resize
metalLayer.contentsScale = UIScreen.main.nativeScale metalLayer.contentsScale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale
metalLayer.framebufferOnly = true metalLayer.framebufferOnly = true
metalLayer.backgroundColor = UIColor.black.cgColor metalLayer.backgroundColor = UIColor.black.cgColor
view.layer.addSublayer(metalLayer) view.layer.addSublayer(metalLayer)
layoutMetalLayer()
setupMpv() setupMpv()
setupNotifications() setupNotifications()
@ -207,17 +219,42 @@ final class MPVPlayerViewController: UIViewController {
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
metalLayer.frame = view.bounds layoutMetalLayer()
attemptStartPendingLoad()
} }
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
refreshImmersiveSystemUI() refreshImmersiveSystemUI()
attemptStartPendingLoad()
} }
override func viewSafeAreaInsetsDidChange() { override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange() super.viewSafeAreaInsetsDidChange()
layoutMetalLayer()
refreshImmersiveSystemUI() refreshImmersiveSystemUI()
attemptStartPendingLoad()
}
private func layoutMetalLayer() {
let bounds = view.bounds
guard bounds.width > 1, bounds.height > 1 else { return }
let scale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale
let drawableSize = CGSize(
width: (bounds.width * scale).rounded(.toNearestOrAwayFromZero),
height: (bounds.height * scale).rounded(.toNearestOrAwayFromZero)
)
CATransaction.begin()
CATransaction.setDisableActions(true)
metalLayer.contentsScale = scale
metalLayer.frame = CGRect(origin: .zero, size: bounds.size)
if drawableSize != lastAppliedDrawableSize {
metalLayer.drawableSize = drawableSize
lastAppliedDrawableSize = drawableSize
}
CATransaction.commit()
} }
// MARK: - MPV Setup // MARK: - MPV Setup
@ -287,21 +324,80 @@ final class MPVPlayerViewController: UIViewController {
// MARK: - Playback API // MARK: - Playback API
func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:]) { func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:]) {
let request = PendingLoadRequest(
urlString: urlString,
audioUrl: audioUrl,
requestHeaders: requestHeaders,
queuedAtUptime: ProcessInfo.processInfo.systemUptime
)
if Thread.isMainThread {
queueLoad(request)
} else {
DispatchQueue.main.async { [weak self] in
self?.queueLoad(request)
}
}
}
private func queueLoad(_ request: PendingLoadRequest) {
pendingLoadRequest = request
attemptStartPendingLoad()
}
private func attemptStartPendingLoad() {
guard let request = pendingLoadRequest else { return }
guard mpv != nil else { return } guard mpv != nil else { return }
layoutMetalLayer()
guard isViewportReadyForPlayback(queuedAtUptime: request.queuedAtUptime) else {
schedulePendingLoadRetry()
return
}
pendingLoadRequest = nil
pendingLoadRetryWorkItem?.cancel()
pendingLoadRetryWorkItem = nil
startLoad(request)
}
private func startLoad(_ request: PendingLoadRequest) {
guard mpv != nil else { return }
layoutMetalLayer()
clearPlaybackError() clearPlaybackError()
let sanitizedHeaders = sanitizeRequestHeaders(requestHeaders) let sanitizedHeaders = sanitizeRequestHeaders(request.requestHeaders)
activeRequestHeaders = sanitizedHeaders activeRequestHeaders = sanitizedHeaders
applyRequestHeaders(sanitizedHeaders) applyRequestHeaders(sanitizedHeaders)
isPlayerLoading = true isPlayerLoading = true
isPlayerEnded = false isPlayerEnded = false
command("loadfile", args: [urlString, "replace"]) command("loadfile", args: [request.urlString, "replace"])
if let audioUrl, !audioUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let audioUrl = request.audioUrl, !audioUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.command("audio-add", args: [audioUrl, "select"], checkForErrors: false) self?.command("audio-add", args: [audioUrl, "select"], checkForErrors: false)
} }
} }
} }
private func isViewportReadyForPlayback(queuedAtUptime: TimeInterval) -> Bool {
guard isViewLoaded, view.window != nil else { return false }
let bounds = view.bounds
guard bounds.width > 1, bounds.height > 1 else { return false }
if bounds.width >= bounds.height { return true }
let age = ProcessInfo.processInfo.systemUptime - queuedAtUptime
return age >= 0.9
}
private func schedulePendingLoadRetry() {
guard pendingLoadRetryWorkItem == nil else { return }
let workItem = DispatchWorkItem { [weak self] in
self?.pendingLoadRetryWorkItem = nil
self?.attemptStartPendingLoad()
}
pendingLoadRetryWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: workItem)
}
func playPlayback() { func playPlayback() {
guard mpv != nil else { return } guard mpv != nil else { return }
setFlag("pause", false) setFlag("pause", false)
@ -350,8 +446,8 @@ final class MPVPlayerViewController: UIViewController {
checkError(mpv_set_option_string(mpv, "panscan", "1.0")) checkError(mpv_set_option_string(mpv, "panscan", "1.0"))
checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) checkError(mpv_set_option_string(mpv, "video-unscaled", "no"))
case 2: // Zoom case 2: // Zoom
checkError(mpv_set_option_string(mpv, "panscan", "0.0")) checkError(mpv_set_option_string(mpv, "panscan", "1.0"))
checkError(mpv_set_option_string(mpv, "video-unscaled", "downscale-big")) checkError(mpv_set_option_string(mpv, "video-unscaled", "no"))
default: // Fit default: // Fit
checkError(mpv_set_option_string(mpv, "panscan", "0.0")) checkError(mpv_set_option_string(mpv, "panscan", "0.0"))
checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) checkError(mpv_set_option_string(mpv, "video-unscaled", "no"))
@ -432,6 +528,9 @@ final class MPVPlayerViewController: UIViewController {
func destroyPlayer() { func destroyPlayer() {
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
pendingLoadRetryWorkItem?.cancel()
pendingLoadRetryWorkItem = nil
pendingLoadRequest = nil
clearPlaybackError() clearPlaybackError()
guard let ctx = mpv else { return } guard let ctx = mpv else { return }
mpv = nil // nil first so event loop stops reading mpv = nil // nil first so event loop stops reading