diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt index 502c14b1..52c7e112 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt @@ -168,6 +168,24 @@ internal actual object DownloadsPlatformDownloader { if (!tempFile.exists()) return true 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( diff --git a/composeApp/src/androidMain/res/xml/locale_config.xml b/composeApp/src/androidMain/res/xml/locale_config.xml index 930f8bda..5599b948 100644 --- a/composeApp/src/androidMain/res/xml/locale_config.xml +++ b/composeApp/src/androidMain/res/xml/locale_config.xml @@ -3,7 +3,7 @@ - + diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 58880bec..f107fc43 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -1,6 +1,6 @@ - Reconnaissance ouverte et crédits du projet + Reconnaissance et crédits du projet Retour Annuler Fermer @@ -26,29 +26,29 @@ Actualisation %1$d ressources Indisponible - Configurer l\'addon - Supprimer l\'addon + Configurer l'addon + Supprimer l'addon Ajoutez une URL de manifeste pour commencer à charger des catalogues, métadonnées, streams ou sous-titres dans Nuvio. - Aucune addons installée. - Veuillez saisir une URL d\'addon. - URL de l\'addon - Installer l\'addon + Aucun addon installé. + Veuillez saisir une URL d'addon. + URL de l'addon + Installer l'addon Chargement des détails du manifeste… - Validation de l\'URL du manifeste et chargement des détails de l\'addon avant installation. - Vérification de l\'addon - Échec de l\'installation + Validation de l'URL du manifeste et chargement des détails de l'addon avant installation. + Vérification de l'addon + Échec de l'installation %1$s a été validé et ajouté avec succès. - Addon installée - Déplacer l\'addon vers le bas - Déplacer l\'addon vers le haut + Addon installé + Déplacer l'addon vers le bas + Déplacer l'addon vers le haut Actif Addons Catalogues - Actualiser l\'addon - Ajouter une addon - Addons installées + Actualiser l'addon + Ajouter un addon + Addons installés Aperçu - %1$d règles d\'ID + %1$d règles d'ID Version %1$s Sélectionné Copier le JSON @@ -58,28 +58,28 @@ Ajouter un catalogue Ajouter un dossier Tous les genres - Ajoutez des catalogues depuis vos extensions installées pour définir ce qu\'affiche ce dossier. + Ajoutez des catalogues depuis vos addons installés pour définir ce qu'affiche ce dossier. Aucune source de catalogue Choisir Emoji - URL de l\'image + URL de l'image Aucune Couverture Créer une collection Terminé Modifier la collection Modifier le dossier - Configurez l\'identité, la présentation et les sources de catalogue du dossier avec la même structure que l\'éditeur principal de collections. + Configurez l'identité, la présentation et les sources de catalogue du dossier avec la même structure que l'éditeur principal de collections. Ajoutez-en un pour commencer. Aucun dossier Dossiers Filtre de genre - Afficher uniquement l\'image de couverture + Afficher uniquement l'image de couverture Masquer le titre Nouveau dossier - Affiche cette collection au-dessus de tous les catalogues normaux de l\'accueil. Plusieurs collections épinglées suivent l\'ordre de création. + Affiche cette collection au-dessus de tous les catalogues normaux de l'accueil. Plusieurs collections épinglées suivent l'ordre de création. Épingler au-dessus des catalogues - URL de l\'image de fond (facultatif) + URL de l'image de fond (facultatif) Nom du dossier URL du GIF animé (se lit uniquement au focus) Nom de la collection @@ -88,7 +88,7 @@ Apparence Informations de base Sources de catalogue - Choisissez les catalogues d\'extension que ce dossier doit regrouper. + Choisissez les catalogues d'addon que ce dossier doit regrouper. Sélectionner des catalogues Sélectionner un genre %1$d sélectionné(s) @@ -98,26 +98,26 @@ Carré Large Combiner tous les catalogues en un seul onglet - Afficher l\'onglet « Tout » - Lire le GIF configuré à la place de la couverture statique lorsqu\'il est disponible. + Afficher l'onglet « Tout » + Lire le GIF configuré à la place de la couverture statique lorsqu'il est disponible. Afficher le GIF si configuré %1$d source(s) · %2$s Forme de la tuile Lignes Onglets - Mode d\'affichage + Mode d'affichage Sources TMDB Liste publique Production Chaîne Collection Personnalisé - Choisissez une source prédéfinie. Vous pouvez la modifier ou la supprimer après l\'avoir ajoutée. - Collez une URL de liste publique TMDB ou uniquement le numéro de l\'URL. + Choisissez une source prédéfinie. Vous pouvez la modifier ou la supprimer après l'avoir ajoutée. + Collez une URL de liste publique TMDB ou uniquement le numéro de l'URL. Recherchez par nom de studio, ou collez un ID/URL de société TMDB et ajoutez-le directement. Saisissez un ID de chaîne. Les chaînes courantes sont disponibles dans les préréglages et les filtres rapides. - Recherchez le nom d\'une collection de films ou collez l\'ID de collection TMDB. - Créez une ligne TMDB dynamique avec des filtres optionnels. Laissez les champs vides si vous n\'avez pas besoin de ce filtre. + Recherchez le nom d'une collection de films ou collez l'ID de collection TMDB. + Créez une ligne TMDB dynamique avec des filtres optionnels. Laissez les champs vides si vous n'avez pas besoin de ce filtre. Liste publique TMDB ID de chaîne ID de collection @@ -129,12 +129,12 @@ Marvel Studios, 420 ou URL de société Exemples : Marvel Studios, 420 ou https://www.themoviedb.org/company/420. Exemple : Star Wars Collection, Harry Potter Collection ou une URL de collection. - Exemples d\'ID : Netflix 213, HBO 49, Disney+ 2739. + Exemples d'ID : Netflix 213, HBO 49, Disney+ 2739. Exemple : https://www.themoviedb.org/list/8504994 ou 8504994. Titre affiché Affiché comme nom de ligne/onglet. Si vide, Nuvio en génère un depuis la source. Films Marvel, Originaux Netflix, Pixar - Meilleurs films d\'action, drames coréens, animation 2024 + Meilleurs films d'action, drames coréens, animation 2024 Résultats de recherche Collection TMDB Société TMDB %1$d @@ -145,7 +145,7 @@ Les deux Tri Filtres - Laissez les champs vides si vous n\'avez pas besoin de ce filtre. + Laissez les champs vides si vous n'avez pas besoin de ce filtre. Genres rapides Langues rapides Pays rapides @@ -155,7 +155,7 @@ ID de genre Utilisez des numéros de genre TMDB. Séparez plusieurs valeurs par des virgules pour ET, ou des barres verticales pour OU. Date de sortie ou de diffusion depuis - Date de sortie ou de diffusion jusqu\'au + Date de sortie ou de diffusion jusqu'au Utilisez le format AAAA-MM-JJ, ex. 2024-01-01. Note minimale Note maximale @@ -164,7 +164,7 @@ Utilisez ceci pour éviter les titres peu connus avec peu de votes. Exemple : 100. Langue originale Utilisez des codes de langue à deux lettres, ex. en, ko, ja, hi. - Pays d\'origine + Pays d'origine Utilisez des codes de pays à deux lettres, ex. US, KR, JP, IN. ID de mots-clés Utilisez des numéros de mots-clés TMDB. Les puces rapides remplissent des exemples courants. @@ -200,7 +200,7 @@ Inde Royaume-Uni Super-héros - Adapté d\'un roman + Adapté d'un roman Voyage dans le temps Espace Marvel @@ -314,13 +314,13 @@ Ajouter un profil Effacer la recherche Découvrir - Les extensions installées n\'ont retourné aucun résultat de recherche valide. + Les addons installés n'ont retourné aucun résultat de recherche valide. La recherche a échoué - Installez et validez au moins une extension avant de rechercher. - Aucune extension active - Les catalogues installés n\'ont retourné aucun résultat pour cette requête. + Installez et validez au moins un addon avant de rechercher. + Aucun addon active + Les catalogues installés n'ont retourné aucun résultat pour cette requête. Aucun résultat trouvé - Vos extensions installées n\'exposent pas de catalogue de recherche. + Vos addons installés n'exposent pas de catalogue de recherche. Aucun catalogue de recherche Rechercher des films, séries… Recherches récentes @@ -328,11 +328,11 @@ À propos Général Compte - Extensions + Addons Apparence Contenu et découverte Continuer à regarder - Écran d\'accueil + Écran d'accueil Intégrations Notes MDBList Écran méta @@ -347,15 +347,15 @@ À PROPOS Gérez votre compte, déconnectez-vous ou supprimez-le. COMPTE - Ajustez la présentation de l\'accueil et les préférences visuelles. - Rechercher de nouvelles versions de l\'application. + Ajustez la présentation de l'accueil et les préférences visuelles. + Rechercher de nouvelles versions de l'application. Vérifier les mises à jour - Gérez les extensions et sources de découverte. + Gérez les addons et sources de découverte. Gérez vos films et épisodes téléchargés. Téléchargements GÉNÉRAL Connectez les services TMDB et MDBList. - Gérez les alertes de sortie d\'épisodes et envoyez une notification de test. + Gérez les alertes de sortie d'épisodes et envoyez une notification de test. Basculer vers un profil différent. Changer de profil Connectez Trakt, synchronisez des listes et enregistrez des titres directement dans Trakt. @@ -369,8 +369,8 @@ %1$d/10 Avis Spoiler - Aucun avis Trakt pour l\'instant. - %1$d j\'aime + Aucun avis Trakt pour l'instant. + %1$d j'aime Ce commentaire contient des spoilers. Ce commentaire contient des spoilers et a été masqué. Commentaires @@ -407,31 +407,31 @@ Adresse e-mail Non connecté Se déconnecter - Vous serez redirigé vers l\'écran de connexion. + Vous serez redirigé vers l'écran de connexion. Se déconnecter ? Statut Anonyme Connecté Noir AMOLED Utilise des fonds noirs purs pour les écrans OLED. - Langue de l\'application + Langue de l'application Choisir la langue Afficher, masquer et ajuster le bandeau Continuer à regarder. - Ajustez la largeur partagée des cartes d\'affiches et les rayons des coins. + Ajustez la largeur partagée des cartes d'affiches et les rayons des coins. AFFICHAGE ACCUEIL THÈME Collection • %1$s Nom affiché - Installez une extension avec des catalogues compatibles avec les tableaux pour configurer les lignes de l\'écran d\'accueil. - Aucun catalogue d\'accueil + Installez un addon avec des catalogues compatibles avec les tableaux pour configurer les lignes de l'écran d'accueil. + Aucun catalogue d'accueil Source Hero Masqué - Garder l\'accueil en focus + Garder l'accueil en focus %1$s • Limite atteinte (max. %2$d) Aucune source Hero sélectionnée Absent du Hero - Retirez l\'épingle de la collection pour la déplacer + Retirez l'épingle de la collection pour la déplacer Épinglé Épinglé en haut Réorganiser @@ -442,7 +442,7 @@ SOURCES HERO %1$d sur %2$d sélectionnés Afficher le Hero - Afficher un carrousel Hero en vedette en haut de l\'accueil. Choisissez jusqu\'à 2 catalogues sources ci-dessous. + Afficher un carrousel Hero en vedette en haut de l'accueil. Choisissez jusqu'à 2 catalogues sources ci-dessous. %1$d sur %2$d catalogues visibles • %3$d sources Hero sélectionnées Ouvrez un catalogue uniquement si vous avez besoin de le renommer ou de le réorganiser. Visible @@ -451,7 +451,7 @@ STYLE DE CARTE D\'AFFICHE Largeur de carte Personnalisé - Personnalisez la largeur de carte et le rayon des coins pour les cartes d\'affiches partagées dans toute l\'application. + Personnalisez la largeur de carte et le rayon des coins pour les cartes d'affiches partagées dans toute l'application. Masquer les étiquettes Mode paysage pour les affiches dans les rayons Aperçu en direct @@ -470,36 +470,36 @@ Dense Grand Standard - Afficher une invite pour reprendre là où vous en étiez à l\'ouverture de l\'application après avoir quitté le lecteur. + Afficher une invite pour reprendre là où vous en étiez à l'ouverture de l'application après avoir quitté le lecteur. Invite de reprise au démarrage STYLE DE CARTE AU DÉMARRAGE COMPORTEMENT DE LA SUITE VISIBILITÉ - Afficher le bandeau Continuer à regarder sur l\'écran d\'accueil. + Afficher le bandeau Continuer à regarder sur l'écran d'accueil. Afficher Continuer à regarder Affiche - Carte d\'affiche centrée sur la couverture + Carte d'affiche centrée sur la couverture Large Carte horizontale riche en informations - 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. - La suite depuis l\'épisode le plus avancé + 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. + La suite depuis l'épisode le plus avancé ACCUEIL SOURCES Installez, supprimez, mettez à jour et ordonnez vos sources de contenu. Installez des dépôts de scrapers JavaScript et testez des fournisseurs en interne. - Contrôlez quels catalogues apparaissent à l\'accueil et dans quel ordre. + Contrôlez quels catalogues apparaissent à l'accueil et dans quel ordre. Désactivez des sections de détails et réorganisez tout sous le Hero. - Créez des regroupements de catalogues personnalisés avec des dossiers affichés à l\'accueil. + Créez des regroupements de catalogues personnalisés avec des dossiers affichés à l'accueil. INTÉGRATIONS - Enrichissez les pages de détails avec de l\'art, des crédits, des métadonnées d\'épisodes et plus depuis TMDB. - Ajoutez des notes externes d\'IMDb, Rotten Tomatoes, Metacritic et d\'autres aux pages de détails. - Ajoutez votre clé API MDBList ci-dessous avant d\'activer les notes. + Enrichissez les pages de détails avec de l'art, des crédits, des métadonnées d'épisodes et plus depuis TMDB. + Ajoutez des notes externes d'IMDb, Rotten Tomatoes, Metacritic et d'autres aux pages de détails. + Ajoutez votre clé API MDBList ci-dessous avant d'activer les notes. Obtenez une clé sur https://mdblist.com/preferences et collez-la ici. Clé API Clé API MDBList Activer les notes MDBList - Afficher les notes externes de MDBList sur les pages de métadonnées lorsqu\'un ID IMDb est disponible. + Afficher les notes externes de MDBList sur les pages de métadonnées lorsqu'un ID IMDb est disponible. CLÉ API FOURNISSEURS DE NOTES MDBLIST @@ -508,21 +508,21 @@ Casting Liste principale du casting. Fond cinématographique - Fond flou derrière le contenu, similaire à l\'écran de streams. + Fond flou derrière le contenu, similaire à l'écran de streams. Collection Rayon de collection ou de franchise associée. Commentaires Section de commentaires Trakt. Détails Durée, statut, sortie, langue et informations associées. - Cartes d\'épisodes - Choisissez comment les épisodes sont affichés sur l\'écran de métadonnées. + Cartes d'épisodes + Choisissez comment les épisodes sont affichés sur l'écran de métadonnées. Horizontal Cartes en ligne style fond Liste Cartes empilées centrées sur les détails Épisodes - Saisons et liste d\'épisodes pour les séries. + Saisons et liste d'épisodes pour les séries. Groupe %1$d Plus comme ceci Rayon de recommandations. @@ -533,14 +533,14 @@ Studios et chaînes. APPARENCE SECTIONS - Groupe d\'onglets %1$d + Groupe d'onglets %1$d Disposition des onglets - Regroupez les sections en onglets comme dans l\'application TV. Assignez jusqu\'à 3 sections par groupe d\'onglets. + Regroupez les sections en onglets comme dans l'application TV. Assignez jusqu'à 3 sections par groupe d'onglets. Bandes-annonces Rayon de bandes-annonces et raccourcis de lecture. Les notifications sont actuellement désactivées dans Nuvio. - Alertes de sortie d\'épisodes - Programmez des notifications locales lorsqu\'un nouvel épisode d\'une série sauvegardée est disponible. + Alertes de sortie d'épisodes + Programmez des notifications locales lorsqu'un nouvel épisode d'une série sauvegardée est disponible. Les notifications système sont désactivées pour Nuvio. Activez-les pour recevoir des alertes et des notifications de test. Il y a actuellement %1$d alertes de sortie programmées sur cet appareil. ALERTES @@ -548,11 +548,11 @@ Envoyer une notification de test Envoi de la notification de test… Envoyer une notification locale de test pour %1$s. - Sauvegardez d\'abord une série dans votre bibliothèque pour tester les notifications. + Sauvegardez d'abord une série dans votre bibliothèque pour tester les notifications. Notification de test Communauté Découvrez les personnes qui construisent et soutiennent Nuvio sur Mobile, TV et Web. - L\'API des supporters n\'est pas configurée. Ajoutez DONATIONS_BASE_URL dans local.properties. + L\'API des supporters n'est pas configurée. Ajoutez DONATIONS_BASE_URL dans local.properties. Contributeurs Supporters Ouvrir GitHub @@ -582,19 +582,19 @@ Nov Déc %1$s %2$s %3$s - Toutes les extensions + Toutes les addons Tous les plugins - Extensions autorisées + Addons autorisés Plugins autorisés Anime Skip ID client AnimeSkip Saisissez votre ID client API AnimeSkip. Obtenez-en un sur anime-skip.com. Rechercher également des marqueurs de saut sur AnimeSkip (nécessite un ID client). - Lecture automatique de l\'épisode suivant - Rechercher et lire automatiquement l\'épisode suivant lorsque le seuil est atteint. + Lecture automatique de l'épisode suivant + Rechercher et lire automatiquement l'épisode suivant lorsque le seuil est atteint. Appareil uniquement - Préférer l\'application (FFmpeg) - Préférer l\'appareil + Préférer l'application (FFmpeg) + Préférer l'appareil Priorité du décodeur Appuyez en dehors pour fermer Appuyez en dehors pour enregistrer et fermer @@ -606,18 +606,18 @@ Utiliser libass pour afficher les sous-titres ASS/SSA à la place du moteur par défaut. Vitesse au maintien Maintenir pour accélérer - Maintenez appuyé n\'importe où sur la surface du lecteur pour augmenter temporairement la vitesse. + Maintenez appuyé n'importe où sur la surface du lecteur pour augmenter temporairement la vitesse. Modèle regex invalide Durée du cache du dernier lien Mapper DV7 vers HEVC Utiliser Dolby Vision Profil 7 vers HEVC comme alternative pour les appareils non compatibles. Minutes avant la fin - Afficher la carte de l\'épisode suivant ce nombre de minutes avant la fin. + Afficher la carte de l'épisode suivant ce nombre de minutes avant la fin. %1$d min Aucun élément disponible Non défini Par défaut - Langue de l\'appareil + Langue de l'appareil Forcé Aucun Préférer le groupe binge @@ -625,7 +625,7 @@ Langue audio préférée Langue des sous-titres préférée Préréglages - Correspond au nom du stream, à l\'étiquette, à la description, à l\'extension et à l\'URL. + Correspond au nom du stream, à l'étiquette, à la description, à l'addon et à l'URL. Modèle regex 4K|2160p|Remux N\'importe quel 1080p+ @@ -661,18 +661,18 @@ RENDU DES SOUS-TITRES %1$d sélectionné(s) Afficher la superposition de chargement - Afficher la superposition de chargement initiale pendant le démarrage d\'un stream. - Passer l\'intro/outro/récap - Afficher un bouton de saut lors des segments d\'intro, d\'outro et de récapitulatif détectés. + Afficher la superposition de chargement initiale pendant le démarrage d'un stream. + Passer l'intro/outro/récap + Afficher un bouton de saut lors des segments d'intro, d'outro et de récapitulatif détectés. Périmètre des sources - Toutes les extensions - Considérer les streams de toutes les extensions installées. + Toutes les addons + Considérer les streams de toutes les addons installés. Toutes les sources - Considérer les streams des extensions et des plugins. + Considérer les streams des addons et des plugins. Plugins activés uniquement Considérer uniquement les streams des plugins activés. - Extensions installées uniquement - Considérer uniquement les streams des extensions installées. + Addons installés uniquement + Considérer uniquement les streams des addons installés. Mode de sélection du stream Premier stream disponible Lire automatiquement le premier stream trouvé. @@ -680,28 +680,28 @@ Sélectionner les streams manuellement à chaque fois. Correspondance regex Sélectionner automatiquement un stream correspondant à un modèle regex. - Délai d\'expiration du stream + Délai d'expiration du stream Combien de temps attendre les streams avant la sélection automatique. Minutes avant la fin Mode de seuil Minutes avant la fin Pourcentage Pourcentage de seuil - Afficher la carte de l\'épisode suivant lorsque la lecture atteint ce pourcentage. + Afficher la carte de l'épisode suivant lorsque la lecture atteint ce pourcentage. %1$d% Instantané %1$ds Illimité Lecture tunnelisée Active la lecture tunnelisée pour une latence réduite dans la synchronisation audio/vidéo. - Ajoutez votre propre clé API TMDB ci-dessous avant d\'activer l\'enrichissement. + Ajoutez votre propre clé API TMDB ci-dessous avant d'activer l'enrichissement. Clé API TMDB - Activer l\'enrichissement TMDB - 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. + Activer l'enrichissement TMDB + 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. Saisissez votre clé API v3 TMDB. Code de langue Visuels - Remplacer le fond, l\'affiche et le logo par les visuels TMDB. + Remplacer le fond, l'affiche et le logo par les visuels TMDB. Informations de base Utiliser le titre, le synopsis, les genres et la note de TMDB. Collections @@ -717,9 +717,9 @@ Chaînes Utiliser les métadonnées des chaînes TMDB pour les titres TV. Sociétés de production - Utiliser les métadonnées des sociétés de production TMDB sur l\'écran de détails. + Utiliser les métadonnées des sociétés de production TMDB sur l'écran de détails. Affiches de saison - Utiliser les affiches de saison TMDB dans le sélecteur de saisons de l\'écran de métadonnées pour les séries. + Utiliser les affiches de saison TMDB dans le sélecteur de saisons de l'écran de métadonnées pour les séries. Bandes-annonces Récupérer et afficher la section des bandes-annonces TMDB sur les pages de détails. Clé API personnelle @@ -737,13 +737,13 @@ Connecté en tant que %1$s Utilisateur Trakt Déconnecter - Impossible d\'ouvrir le navigateur + Impossible d'ouvrir le navigateur FONCTIONNALITÉS Terminez la connexion Trakt dans votre navigateur Suivez ce que vous regardez, enregistrez dans votre liste ou vos listes personnalisées et gardez votre bibliothèque synchronisée avec Trakt. Identifiants Trakt manquants dans local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). Ouvrir la connexion Trakt - Vos actions d\'enregistrement peuvent maintenant cibler la watchlist Trakt et vos listes personnelles. + Vos actions d'enregistrement peuvent maintenant cibler la watchlist Trakt et vos listes personnelles. Connectez-vous avec Trakt pour activer la sauvegarde basée sur les listes et le mode bibliothèque Trakt. Score du public IMDb @@ -763,11 +763,11 @@ Épisode suivant Recherche de la source… Lecture via %1$s dans %2$d… - Miniature de l\'épisode suivant + Miniature de l'épisode suivant Non diffusé Passer - Passer l\'intro - Passer l\'outro + Passer l'intro + Passer l'outro Passer le récap Aucun sous-titre trouvé Afrikaans @@ -856,32 +856,32 @@ Non Mettre à jour Oui - Voulez-vous quitter l\'application ? - Quitter l\'application - Ce catalogue n\'a retourné aucun élément. + Voulez-vous quitter l'application ? + Quitter l'application + Ce catalogue n'a retourné aucun élément. Aucun titre trouvé Vérifiez votre connexion Wi‑Fi ou données mobiles et réessayez. Réalisateur Échec du chargement Plus comme ceci Saisons - Cette extension a retourné des vidéos pour la série, mais aucune n\'incluait de numéros de saison ou d\'épisode. - Cette extension n\'a fourni aucune métadonnée d\'épisode pour cette série. - Cette extension n\'a pas encore publié d\'épisodes. - Votre appareil est en ligne, mais Nuvio n\'a pas pu se connecter aux serveurs nécessaires. + Cet addon a retourné des vidéos pour la série, mais aucune n'incluait de numéros de saison ou d'épisode. + Cet addon n'a fourni aucune métadonnée d'épisode pour cette série. + Cet addon n'a pas encore publié d'épisodes. + Votre appareil est en ligne, mais Nuvio n'a pas pu se connecter aux serveurs nécessaires. Afficher moins Afficher plus ▾ Scénariste Tous les genres Catalogue %1$s • %2$s - Le catalogue sélectionné n\'a retourné aucun élément de découverte. + Le catalogue sélectionné n'a retourné aucun élément de découverte. Impossible de charger Découvrir - Les extensions installées n\'exposent pas de catalogues compatibles avec le tableau pour Découvrir. + Les addons installés n'exposent pas de catalogues compatibles avec le tableau pour Découvrir. Aucun catalogue de découverte - Le catalogue et les filtres sélectionnés n\'ont retourné aucun élément. + Le catalogue et les filtres sélectionnés n'ont retourné aucun élément. Aucun titre trouvé - Installez et validez au moins une extension avant d\'explorer les catalogues dans Découvrir. + Installez et validez au moins un addon avant d'explorer les catalogues dans Découvrir. Sélectionner un catalogue Sélectionner un genre Sélectionner un type @@ -894,9 +894,9 @@ Marquer comme vu Suivant %1$s vu - Installez et validez au moins une extension avant de charger des lignes de catalogue à l\'accueil. - Les extensions installées n\'exposent actuellement aucun catalogue compatible avec le tableau sans extras requis. - Aucune ligne d\'accueil disponible + Installez et validez au moins un addon avant de charger des lignes de catalogue à l'accueil. + Les addons installés n'exposent actuellement aucun catalogue compatible avec le tableau sans extras requis. + Aucune ligne d'accueil disponible Voir les détails Contrôles pour lire et enregistrer. Actions @@ -906,7 +906,7 @@ Section de commentaires Trakt. Durée, statut, date de sortie, langue et informations associées. Détails - Saisons et liste d\'épisodes pour les séries. + Saisons et liste d'épisodes pour les séries. Rayon de recommandations. Plus comme ceci Synopsis, notes, genres et crédits principaux. @@ -915,7 +915,7 @@ Production Rayon de bandes-annonces et raccourcis de lecture. De nouveau en ligne - Impossible d\'atteindre les serveurs + Impossible d'atteindre les serveurs Pas de connexion Internet (âge %1$d) Né(e) le %1$s%2$s @@ -933,7 +933,7 @@ Code PIN oublié ? Code PIN incorrect Bloqué. Réessayez dans %1$ds - Les options d\'avatar apparaîtront ici une fois le catalogue chargé. + Les options d'avatar apparaîtront ici une fois le catalogue chargé. Avatar : %1$s Choisir un avatar Choisissez un avatar ci-dessous. @@ -949,32 +949,32 @@ Gérer les profils Nom du profil Nouveau profil - Extensions principales désactivées - Extensions principales activées + Addons principaux désactivés + Addons principaux activés Supprimer le code PIN pour %1$s Supprimer le verrouillage PIN Enregistrement… Sécurité - Ajoutez un code PIN si vous souhaitez que ce profil soit verrouillé avant d\'y accéder. + Ajoutez un code PIN si vous souhaitez que ce profil soit verrouillé avant d'y accéder. Ce profil est protégé par un code PIN. Sélectionnez un avatar pour ce profil. Configurer le verrouillage PIN Profil sans nom - Utiliser les extensions principales - Partager la configuration des extensions du profil principal plutôt que de gérer une liste séparée. + Utiliser les addons principales + Partager la configuration des addons du profil principal plutôt que de gérer une liste séparée. Qui regarde ? Téléchargé Reprendre Scrapers actifs - Vérification d\'autres extensions… + Vérification d'autres addons… Copier le lien du stream Télécharger le fichier - Les extensions de streams installées n\'ont pas retourné de réponse valide. + Les addons de streams installés n'ont pas retourné de réponse valide. Impossible de charger les streams - Installez d\'abord une extension pour charger les streams de ce titre. - Vos extensions installées ne fournissent pas de streams pour ce type de titre. - Aucune extension de streams disponible - Aucune de vos extensions installées n\'a retourné de streams pour ce titre. + Installez d'abord un addon pour charger les streams de ce titre. + Vos addons installés ne fournissent pas de streams pour ce type de titre. + Aucun addon de streams disponible + Aucune de vos addons installés n'a retourné de streams pour ce titre. S%1$d E%2$d Épisode S%1$dE%2$d - %3$s @@ -995,10 +995,10 @@ %1$s • %2$s Échec de la vérification des mises à jour Échec du téléchargement - Téléchargement %1$d% - Impossible de démarrer l\'installation + Téléchargement %1$d%% + Impossible de démarrer l'installation Vous utilisez la version la plus récente. - Activez l\'installation d\'applications pour Nuvio puis revenez pour continuer. + Activez l'installation d'applications pour Nuvio puis revenez pour continuer. Téléchargement de la mise à jour… Aucune mise à jour trouvée. Une nouvelle version est prête à être installée. @@ -1008,22 +1008,22 @@ Autoriser les installations pour continuer Mise à jour disponible Statut de la mise à jour - Cette extension est déjà installée. - Veuillez saisir une URL d\'extension valide + Cet addon est déjà installé. + Veuillez saisir une URL d'addon valide Impossible de charger le manifeste Nuvio Impossible de supprimer le compte Échec de la connexion Échec de la déconnexion - Échec de l\'inscription + Échec de l'inscription Impossible de charger les éléments du catalogue. À suivre À suivre • S%1$dE%2$d logo de %1$s Impossible de charger les commentaires - Impossible de charger les détails depuis aucune extension. + Impossible de charger les détails depuis aucun addon. Réseaux - Aucune extension ne fournit de métadonnées pour ce contenu. + Aucun addon ne fournit de métadonnées pour ce contenu. Téléchargement échoué Affiche la progression en direct et les contrôles de téléchargement. Téléchargements @@ -1036,18 +1036,18 @@ Supprimer %1$s de votre bibliothèque ? Retirer de la bibliothèque ? Film - Alertes lorsqu\'un nouvel épisode d\'une série sauvegardée est disponible. - Aperçu de l\'alerte de sortie d\'épisode. - Impossible d\'envoyer une notification de test. + Alertes lorsqu'un nouvel épisode d'une série sauvegardée est disponible. + Aperçu de l'alerte de sortie d'épisode. + Impossible d'envoyer une notification de test. Notification de test envoyée pour %1$s. Impossible de lire ce stream. Le code PIN de ce profil a changé. Connectez-vous une fois pour mettre à jour le verrouillage sur cet appareil. Impossible de supprimer le verrouillage PIN. Veuillez réessayer. Connectez-vous à Internet pour supprimer le verrouillage PIN. - 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. + 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. Impossible de définir le code PIN. Veuillez réessayer. Connectez-vous à Internet pour définir un code PIN. - Ce profil utilise les extensions principales. + Ce profil utilise les addons principales. Impossible de charger %1$s Source Intégré @@ -1058,7 +1058,7 @@ Réponse de jeton Trakt invalide Impossible de charger la bibliothèque Trakt Liste %1$d - Trakt n\'a pas retourné de code d\'autorisation + Trakt n'a pas retourné de code d'autorisation Identifiants Trakt manquants Impossible de charger la progression Trakt Impossible de terminer la connexion Trakt @@ -1066,18 +1066,18 @@ Liste de suivi Bande-annonce Inconnu - Extension + Addon Enregistré Lire %1$s Reprendre %1$s Le JSON est vide. La collection %1$d a un ID vide. - La collection \'%1$s\' a un titre vide. - Le dossier %1$d dans \'%2$s\' a un ID vide. + La collection \'%1$s' a un titre vide. + Le dossier %1$d dans \'%2$s' a un ID vide. Le dossier \'%1$s\' dans \'%2$s\' a un titre vide. La source %1$d dans le dossier \'%2$s\' a des champs vides. JSON invalide : %1$s - Extension introuvable : %1$s + Addon introuvable : %1$s Janvier Février Mars @@ -1112,7 +1112,7 @@ Classification Détails du film Langue originale - Pays d\'origine + Pays d'origine Informations de sortie Durée Affiches @@ -1127,7 +1127,7 @@ Format de stream non pris en charge pour les téléchargements Corps de réponse vide La requête a échoué avec HTTP %1$d - Le système de téléchargement n\'est pas initialisé + Le système de téléchargement n'est pas initialisé La requête de téléchargement a échoué %1$s - %2$s Les titres enregistrés apparaîtront ici après avoir appuyé sur Enregistrer dans un écran de détails. @@ -1148,7 +1148,7 @@ %1$s • %2$s est maintenant disponible Un nouvel épisode est maintenant disponible %1$s est maintenant disponible - Sorties d\'épisodes + Sorties d'épisodes Créateur Réalisateur Scénariste diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 1e6ce43e..fca49b5c 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -94,7 +94,7 @@ Quadrato Orizzontale Combina tutti i cataloghi in una singola scheda - Mostra scheda \"Tutti\" + Mostra scheda "Tutti" Riproduci la GIF configurata al posto della copertina statica quando disponibile. Mostra GIF se configurata %1$d sorgenti · %2$s @@ -297,7 +297,7 @@ Usa sfondi neri assoluti per schermi OLED. Lingua app Scegli lingua - Mostra, nascondi e personalizza lo stile della riga \"Continua a guardare\". + Mostra, nascondi e personalizza lo stile della riga "Continua a guardare". Regola la larghezza delle locandine e i preset del raggio degli angoli. DISPLAY HOME @@ -355,15 +355,15 @@ Richiesta ripresa all'avvio STILE SCHEDA ALL'AVVIO - COMPORTAMENTO \"PROSSIMO EPISODIO\" + COMPORTAMENTO "PROSSIMO EPISODIO" VISIBILITÀ - Mostra la riga \"Continua a guardare\" nella schermata Home. + Mostra la riga "Continua a guardare" nella schermata Home. Mostra Continua a guardare Locandina Scheda focalizzata sulla locandina Orizzontale Scheda orizzontale ricca di informazioni - 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. + 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. Prossimo episodio dall'ultimo visto HOME SORGENTI @@ -502,7 +502,7 @@ Forzati Nessuno Preferisci Binge Group - Durante la riproduzione automatica, preferisci un flusso dello stesso \"binge group\" di quello attuale. + Durante la riproduzione automatica, preferisci un flusso dello stesso "binge group" di quello attuale. Lingua audio preferita Lingua sottotitoli preferita Preset @@ -544,7 +544,7 @@ Mostra overlay di caricamento Mostra una schermata di caricamento all'avvio della riproduzione di un flusso. Salta Intro/Outro/Recap - Mostra il pulsante \"salta\" durante i segmenti rilevati di introduzione, chiusura e riassunto. + Mostra il pulsante "salta" durante i segmenti rilevati di introduzione, chiusura e riassunto. Ambito sorgente Tutti gli addon Considera i flussi da tutti gli addon installati. @@ -819,7 +819,7 @@ Scegli un avatar Scegli un avatar qui sotto. Crea profilo - Tutti i dati di \"%1$s\" verranno eliminati permanentemente. + Tutti i dati di "%1$s" verranno eliminati permanentemente. Elimina profilo Aggiungi profilo Modifica profilo @@ -1040,4 +1040,134 @@ KB MB GB + %1$d selezionati + %1$d cataloghi + %1$d selezionati + Sorgenti TMDB + Lista pubblica + Produzione + Network + Collezione + Persona + Regista + Personalizzato + Scegli una sorgente pronta all\'uso. Puoi modificarla o rimuoverla dopo averla aggiunta. + Incolla l\'URL di una lista pubblica TMDB o solo l\'ID numerico dall\'URL. + Cerca per nome dello studio, oppure incolla l\'ID/URL di una casa di produzione TMDB per aggiungerla direttamente. + Inserisci un ID network. I network più comuni sono disponibili nei Preset e nei filtri rapidi. + Cerca il nome di una collezione di film o incolla l\'ID collezione da TMDB. + Inserisci l\'ID o l\'URL di una persona su TMDB per creare una riga basata sul cast. + Inserisci l\'ID o l\'URL di una persona su TMDB per creare una riga basata sulla regia. + Crea una riga dinamica TMDB usando filtri opzionali. Lascia i campi vuoti se non ti serve un filtro specifico. + Lista pubblica TMDB + ID Network + ID Collezione + ID Persona + Nome, ID o URL casa di produzione + ID o URL TMDB + https://www.themoviedb.org/list/8504994 o 8504994 + 213 per Netflix, 49 per HBO, 2739 per Disney+ + 10 per Star Wars Collection + Marvel Studios, 420, o URL società + 31 per Tom Hanks, o URL persona + Esempi: Marvel Studios, 420, o https://www.themoviedb.org/company/420. + Esempio: Star Wars Collection, Harry Potter Collection, o URL collezione. + Esempi ID: Netflix 213, HBO 49, Disney+ 2739. + Esempio: https://www.themoviedb.org/list/8504994 o 8504994. + Esempio: https://www.themoviedb.org/person/31-tom-hanks o 31. + Titolo visualizzato + Appare come nome della riga/scheda. Se vuoto, Nuvio ne creerà uno dalla sorgente. + Film Marvel, Originali Netflix, Pixar + Film con Tom Hanks, Attori preferiti + Film di Christopher Nolan, Registi preferiti + Migliori film d\'azione, Drama coreani, Animazione 2024 + Risultati della ricerca + Collezione TMDB + Società TMDB %1$d + Collezione TMDB %1$d + Tipo + Film + Serie TV + Entrambi + Ordina + Filtri + Lascia i campi vuoti se non ti serve quel filtro. + Generi rapidi + Lingue rapide + Paesi rapidi + Parole chiave rapide + Studi rapidi + Network rapidi + ID Generi + Usa i numeri dei generi TMDB. Separa con la virgola per AND, o con la barra verticale (pipe) per OR. + Data uscita dal + Data uscita al + Usa AAAA-MM-GG, ad esempio 2024-01-01. + Voto minimo + Voto massimo + Valutazione TMDB da 0 a 10. Esempio: 7.0. + Voti minimi + Usa questo per evitare titoli poco noti con pochi voti. Esempio: 100. + Lingua originale + Usa codici lingua a due lettere, ad esempio it, en, ko. + Paese d\'origine + Usa codici paese a due lettere, ad esempio IT, US, KR. + ID Parole chiave + Usa i numeri delle parole chiave TMDB. I suggerimenti rapidi contengono esempi comuni. + 9715 per supereroi + ID Società + Usa gli ID degli studi/società. I suggerimenti rapidi contengono esempi comuni. + 420 per Marvel Studios + ID Network + Solo per le serie TV. Usa ID network come Netflix (213) o HBO (49). + 213 per Netflix + Anno + Usa l\'anno a quattro cifre, ad esempio 2024. + Preset + Cerca + Aggiungi sorgente + Azione + Avventura + Animazione + Commedia + Horror + Fantascienza + Dramma + Crime + Reality + Inglese + Coreano + Giapponese + Hindi + Spagnolo + Stati Uniti + Corea + Giappone + India + Regno Unito + Supereroi + Basato su un romanzo + Viaggio nel tempo + Spazio + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Originale + Popolari + Più votati + Recenti + Lista TMDB + Collezione film TMDB + Produzione + Network + Persona + Regista + TMDB Discover diff --git a/composeApp/src/commonMain/composeResources/values-pt-rPT/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings similarity index 79% rename from composeApp/src/commonMain/composeResources/values-pt-rPT/strings.xml rename to composeApp/src/commonMain/composeResources/values-pt/strings index 9cf50f3c..e5049d24 100644 --- a/composeApp/src/commonMain/composeResources/values-pt-rPT/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings @@ -1,6 +1,6 @@ Reconhecimento aberto e créditos do projeto - Retroceder + Voltar Cancelar Fechar Eliminar @@ -27,13 +27,13 @@ Indisponível Configurar addon Eliminar addon - Adiciona um URL de manifesto para começares a carregar catálogos, metadados, transmissões ou legendas no Nuvio. - Ainda não existem addons instalados. + Adiciona um URL de manifesto para começares a carregar catálogos, metadados, streams ou legendas no Nuvio. + Ainda não tens addons instalados. Introduz um URL de addon. URL do Addon Instalar Addon A carregar detalhes do manifesto... - A validar o URL do manifesto e a carregar os detalhes do addon antes da instalação. + A validar o URL do manifesto e a carregar detalhes do addon antes de instalar. A verificar Addon Falha na Instalação %1$s foi validado e adicionado com sucesso. @@ -46,19 +46,19 @@ Atualizar addon Adicionar Addon Addons Instalados - Visão Geral - %1$d regras de ID + Resumo + %1$d regras de id Versão %1$s Selecionado Copiar JSON %1$d coleção(ões), %2$d pasta(s) - Eliminar \"%1$s\"? Esta ação não pode ser desfeita. + Eliminar "%1$s"? Não poderás desfazer esta ação. Eliminar Coleção Adicionar Catálogo Adicionar Pasta Todos os géneros - Adiciona catálogos dos teus addons instalados para definires o que esta pasta mostra. - Ainda não existem fontes de catálogo + Adiciona catálogos dos teis addons instalados para definires o que esta pasta mostra. + Ainda sem fontes de catálogo Escolher Emoji URL da Imagem @@ -68,15 +68,15 @@ Concluído Editar Coleção Editar Pasta - Define a identidade da pasta, a apresentação e as fontes de catálogo com a mesma estrutura do editor de coleções principal. + Define a identidade da pasta, apresentação e fontes de catálogo com a mesma estrutura do editor de coleções principal. Adiciona uma para começar. - Ainda não existem pastas + Ainda sem pastas Pastas Filtro de Género Mostrar apenas a imagem de capa Ocultar Título Nova Pasta - Mostrar esta coleção acima de todos os catálogos normais do ecrã inicial. Múltiplas coleções afixadas seguem a ordem de criação. + Mostrar esta coleção acima de todos os catálogos normais do início. Múltiplas coleções afixadas seguem a ordem de criação. Afixar Acima dos Catálogos URL da imagem de fundo (opcional) Nome da pasta @@ -84,7 +84,7 @@ Nome da coleção Guardar Alterações Guardar - Aparência + Aspeto Básico Fontes de Catálogo Escolhe os catálogos de addons que esta pasta deve agregar. @@ -93,46 +93,55 @@ %1$d selecionados %1$d catálogos %1$d selecionados - Poster + Póster Quadrado Panorâmico Combinar todos os catálogos num único separador Mostrar Separador \"Tudo\" - Reproduzir o GIF configurado em vez da capa estática, quando disponível. - Mostrar GIF quando Configurado + Reproduzir o GIF configurado em vez da capa estática quando disponível. + Mostrar GIF Quando Configurado %1$d fonte(s) · %2$s - Forma do Cartão + Formato do Cartão Linhas Separadores Modo de Visualização Fontes TMDB Lista Pública Produção - Rede/Canal + Canal/Rede Coleção + Pessoa + Realizador Personalizado - Escolhe uma fonte predefinida. Podes editar ou remover após adicionar. - Cola o URL de uma lista pública do TMDB ou apenas o número presente no URL. - Pesquisa pelo nome do estúdio ou cola o ID/URL de uma empresa no TMDB para adicionar diretamente. - Introduz um ID de rede. As redes comuns estão disponíveis nas Predefinições e filtros rápidos. - Pesquisa pelo nome de uma coleção de filmes ou cola o ID da coleção do TMDB. - Cria uma linha TMDB dinâmica usando filtros opcionais. Deixa os campos vazios quando não precisares de um filtro. - Lista pública TMDB - ID da Rede + Escolhe uma fonte pronta a usar. Podes editá-la ou removê-la depois de adicionar. + Cola um URL de uma lista pública do TMDB ou apenas o número do URL. + Pesquisa pelo nome do estúdio, ou cola um ID/URL de empresa do TMDB para adicionar diretamente. + Introduz um ID de canal. Os canais comuns estão disponíveis nos Presets e filtros rápidos. + Pesquisa o nome de uma coleção de filmes ou cola o ID da coleção do TMDB. + Introduz o ID ou URL de uma pessoa do TMDB para criar uma linha baseada no elenco. + Introduz o ID ou URL de uma pessoa do TMDB para criar uma linha baseada no trabalho de realização. + Cria uma linha TMDB dinâmica usando filtros opcionais. Deixa os campos vazios se não precisares do filtro. + Lista pública do TMDB + ID do Canal ID da Coleção + ID da Pessoa Nome da produtora, ID ou URL ID ou URL do TMDB https://www.themoviedb.org/list/8504994 ou 8504994 213 para Netflix, 49 para HBO, 2739 para Disney+ 10 para Coleção Star Wars Marvel Studios, 420 ou URL da empresa + 31 para Tom Hanks ou URL da pessoa Exemplos: Marvel Studios, 420 ou https://www.themoviedb.org/company/420. Exemplo: Coleção Star Wars, Coleção Harry Potter ou um URL de coleção. - Exemplos de IDs: Netflix 213, HBO 49, Disney+ 2739. + IDs de exemplo: Netflix 213, HBO 49, Disney+ 2739. Exemplo: https://www.themoviedb.org/list/8504994 ou 8504994. - Título a exibir - Exibido como o nome da linha/separador. Se estiver em branco, o Nuvio cria um a partir da fonte. - Filmes Marvel, Originais Netflix, Pixar + Exemplo: https://www.themoviedb.org/person/31-tom-hanks ou 31. + Título de exibição + Aparece como o nome da linha/separador. Se estiver vazio, o Nuvio cria um a partir da fonte. + Filmes da Marvel, Originais Netflix, Pixar + Filmes do Tom Hanks, Atores Favoritos + Filmes do Christopher Nolan, Realizadores Favoritos Melhores Filmes de Ação, Dramas Coreanos, Animação 2024 Resultados da Pesquisa Coleção TMDB @@ -144,21 +153,21 @@ Ambos Ordenar Filtros - Deixa os campos vazios quando não precisares de um filtro. + Deixa os campos vazios se não precisares desse filtro. Géneros rápidos Idiomas rápidos Países rápidos Palavras-chave rápidas Estúdios rápidos - Redes rápidas + Canais rápidos IDs de Género - Usa números de género TMDB. Separa múltiplos com vírgulas para AND, ou barras verticais para OR. - Data de lançamento desde - Data de lançamento até + Usa números de género do TMDB. Separa múltiplos com vírgulas para E, ou barras verticais para OU. + Data de lançamento/emissão de + Data de lançamento/emissão até Usa AAAA-MM-DD, por exemplo 2024-01-01. - Avaliação mínima - Avaliação máxima - Avaliação TMDB de 0 a 10. Exemplo: 7.0. + Classificação mínima + Classificação máxima + Classificação TMDB de 0 a 10. Exemplo: 7.0. Mínimo de votos Usa isto para evitar títulos obscuros com poucos votos. Exemplo: 100. Idioma original @@ -166,17 +175,17 @@ País de origem Usa códigos de país de duas letras, por exemplo US, KR, JP, IN. IDs de Palavras-chave - Usa números de palavras-chave TMDB. Os botões rápidos preenchem exemplos comuns. + Usa números de palavras-chave do TMDB. Os botões rápidos preenchem exemplos comuns. 9715 para super-herói IDs de Empresas - Usa IDs de estúdio/empresa. Os botões rápidos preenchem exemplos comuns. + Usa IDs de estúdios/empresas. Os botões rápidos preenchem exemplos comuns. 420 para Marvel Studios - IDs de Redes - Apenas para séries. Usa IDs de rede como Netflix 213 ou HBO 49. + IDs de Canais + Apenas para séries. Usa IDs de canais como Netflix 213 ou HBO 49. 213 para Netflix Ano Usa um ano com quatro dígitos, por exemplo 2024. - Predefinições + Presets Pesquisar Adicionar Fonte Ação @@ -199,7 +208,7 @@ Índia Reino Unido Super-herói - Baseado em Romance/Livro + Baseado num Livro Viagem no Tempo Espaço Marvel @@ -212,23 +221,26 @@ Disney+ Prime Video Hulu + Original Popular Melhor Classificados - Recentes + Recente Lista TMDB Coleção de Filmes TMDB Produção - Rede/Canal - Descobrir TMDB + Canal + Pessoa + Realizador + Descoberta TMDB Cria uma para organizares os teus catálogos. - Ainda não existem coleções + Ainda sem coleções %1$d pasta(s) Nenhum item encontrado Pasta não encontrada Coleções Importar Coleções JSON - Cola o JSON das tuas coleções abaixo. + Cola abaixo o JSON das tuas coleções. Importar Nova Coleção Afixado @@ -245,28 +257,28 @@ Criar Conta Não tens uma conta? E-mail -   ou  + ou Palavra-passe Inicia sessão para acederes à tua biblioteca e progresso Iniciar Sessão - Regista-te para sincronizares os teus dados entre dispositivos - Registar + Cria conta para sincronizares os teus dados entre dispositivos + Criar Conta Os teus dados serão guardados apenas localmente - Transmite tudo, em qualquer lugar + Tudo em stream, em qualquer lugar Bem-vindo de volta Biblioteca Biblioteca Trakt Início Biblioteca Perfil - Pesquisa + Pesquisar Faixas de Áudio Áudio Integrado Ajuste Inferior Fechar reprodutor Cor - A reproduzir + A reproduzir agora E%1$d S%1$dE%2$d S%1$dE%2$d • %3$s @@ -276,15 +288,15 @@ Bloquear controlos Nenhuma faixa de áudio disponível Nenhum episódio disponível - Nenhuma transmissão encontrada + Nenhum stream encontrado Nenhum Contorno Episódios Fontes - Transmissões + Streams Erro de reprodução A reproduzir - Toca para obter legendas + Toca para procurar legendas Voltar Repor Predefinições Preencher @@ -298,13 +310,13 @@ Avançar 10 segundos Fontes Estilo - Leg. + Legs Legendas Brilho %1$s Volume %1$s Sem som Transferido - Emitido em + Emissão A anunciar Toca para desbloquear Faixa %1$d @@ -317,9 +329,9 @@ Falha na pesquisa Instala e valida pelo menos um addon antes de pesquisares. Sem addons ativos - Os catálogos pesquisáveis instalados não encontraram correspondências para esta consulta. + Os catálogos pesquisáveis instalados não encontraram nada para esta procura. Nenhum resultado encontrado - Os teus addons instalados não disponibilizam pesquisa de catálogo. + Os teus addons instalados não permitem pesquisa em catálogos. Sem catálogos pesquisáveis Pesquisa filmes, séries... Pesquisas Recentes @@ -328,7 +340,7 @@ Geral Conta Addons - Aparência + Aspeto Conteúdo e Descoberta Continuar a Ver Ecrã Inicial @@ -338,7 +350,7 @@ Notificações Reprodução Plugins - Personalização de Posters + Personalização de Pósteres Definições Apoiantes e Colaboradores Enriquecimento TMDB @@ -346,16 +358,16 @@ SOBRE Gere a tua conta, termina sessão ou elimina-a. CONTA - Ajusta a apresentação inicial e preferências visuais. - Verifica se existem novas versões da aplicação. - Procurar atualizações + Afina a apresentação do início e as preferências visuais. + Verifica se há novas versões da aplicação. + Verificar atualizações Gere addons e fontes de descoberta. Gere os teus filmes e episódios transferidos. Transferências GERAL Liga os serviços TMDB e MDBList. Gere alertas de lançamento de episódios e envia uma notificação de teste. - Mudar para um perfil diferente. + Muda para um perfil diferente. Mudar de Perfil Liga o Trakt, sincroniza listas e guarda títulos diretamente no Trakt. A carregar as tuas listas do Trakt… @@ -363,12 +375,12 @@ Doar Ver detalhes Remover - Começar do início + Ver do início Reproduzir %1$d/10 Crítica Spoiler - Ainda não existem críticas do Trakt disponíveis. + Ainda não há críticas do Trakt disponíveis. %1$d gostos Este comentário contém spoilers. Este comentário contém spoilers e foi ocultado. @@ -377,57 +389,57 @@ %1$s (%2$d) Trailers Sem episódios concluídos - Ainda não existem transferências + Ainda sem transferências %1$d episódio(s) transferido(s) - Ativas + Ativo Filmes Séries - Ver Transferências - Concluída • %1$s + Mostrar Transferências + Concluído • %1$s A transferir • %1$s Falhou - Pausada • %1$s + Pausado • %1$s Visto Temporada %1$d Especiais - Continua de onde ficaste + Continua de onde paraste Adicionar à biblioteca Marcar como não visto Marcar como visto Remover da biblioteca Ver Tudo Reproduzir manualmente - Logótipo %1$s + Logótipo de %1$s Conta Eliminar Conta - Isto eliminará permanentemente a tua conta e todos os dados associados. + Isto irá eliminar permanentemente a tua conta e todos os dados associados. Esta ação não pode ser desfeita. Todos os teus dados, perfis e histórico de sincronização serão removidos permanentemente. Eliminar Conta? E-mail Sessão não iniciada Terminar Sessão - Serás redirecionado para o ecrã de início de sessão. + Irás regressar ao ecrã de início de sessão. Terminar Sessão? Estado Anónimo Sessão Iniciada Preto AMOLED - Utiliza fundos pretos puros para ecrãs OLED. + Usa fundos pretos puros para ecrãs OLED. Idioma da Aplicação Escolher Idioma - Mostra, oculta e personaliza o aspeto da secção \"Continuar a Ver\". - Ajusta a largura e o arredondamento dos cantos dos posters. + Mostra, oculta e personaliza a linha \"Continuar a Ver\". + Ajusta a largura do cartão e as predefinições do raio dos cantos. ECRÃ INÍCIO TEMA Coleção • %1$s Nome de Exibição Instala um addon com catálogos compatíveis para configurares as linhas do Ecrã Inicial. - Sem catálogos iniciais - Fonte do destaque (Hero) + Sem catálogos para o início + Fonte do Destaque Oculto Manter Início focado - %1$s • Limite atingido (máx. %2$d) + %1$s • Limite atingido (máx %2$d) Nenhuma fonte de destaque selecionada Fora do destaque Remove a afixação no topo da coleção para a moveres @@ -446,59 +458,59 @@ Abre um catálogo apenas quando precisares de o renomear ou reordenar. Visível Reprodutor, legendas e reprodução automática - Arredondamento do Cartão - ESTILO DO CARTÃO DO POSTER + Raio do Cartão + ESTILO DO CARTÃO Largura do Cartão Personalizado - Personaliza a largura e o arredondamento dos cantos para os cartões de poster em toda a aplicação. + Personaliza a largura e o raio dos cantos dos cartões em toda a aplicação. Ocultar etiquetas - Modo panorâmico para posters em prateleira - Pré-visualização em Tempo Real + Modo panorâmico para pósteres em linha + Pré-visualização ao Vivo %1$s (%2$s) - Raio dos cantos: %1$ddp + Raio do canto: %1$ddp Altura: %1$ddp Largura: %1$ddp Clássico Pílula Arredondado Afiado - Subtil + Suave Equilibrado - Conforto + Confortável Compacto Denso Grande Padrão - Mostra um aviso para continuares de onde ficaste ao abrir a aplicação após saíres do reprodutor. + Mostra um aviso para continuares de onde paraste ao abrir a aplicação após saíres do reprodutor. Aviso de retoma ao iniciar ESTILO DO CARTÃO AO INICIAR COMPORTAMENTO DO SEGUINTE VISIBILIDADE - Exibe a secção \"Continuar a Ver\" no ecrã Inicial. + Exibe a linha \"Continuar a Ver\" no ecrã inicial. Mostrar Continuar a Ver - Poster + Póster Cartão focado na imagem Panorâmico - Cartão horizontal denso em informação - Quando ativado, o \"Seguinte\" continua sempre a partir do último episódio visto. Quando desativado, segue a partir do episódio visto mais recentemente. Útil se costumas rever episódios anteriores. - Seguinte a partir do último episódio + Cartão horizontal rico em informação + Quando ativado, o \"Seguinte\" continua sempre a partir do episódio mais avançado que foi visto. Quando desativado, segue a partir do último visto. Útil se costumas rever episódios antigos. + Seguinte do episódio mais avançado INÍCIO FONTES Instala, remove, atualiza e ordena as tuas fontes de conteúdo. Instala repositórios de scrapers JavaScript e testa fornecedores internamente. Controla quais os catálogos que aparecem no Início e por que ordem. - Desativa secções de detalhes e reordena tudo abaixo do Destaque. + Desativa secções de detalhes e reordena tudo o que aparece abaixo do Destaque. Cria agrupamentos de catálogos personalizados com pastas exibidas no Início. INTEGRAÇÕES - Melhora as páginas de detalhes com imagens TMDB, créditos, metadados de episódios e mais. - Adiciona classificações externas do IMDb, Rotten Tomatoes, Metacritic e outros. + Melhora as páginas de detalhes com imagens, créditos e metadados do TMDB. + Adiciona classificações do IMDb, Rotten Tomatoes, Metacritic e outros às páginas de detalhes. Adiciona a tua chave API do MDBList abaixo antes de ativares as classificações. Obtém uma chave em https://mdblist.com/preferences e cola-a aqui. Chave API Chave API MDBList Ativar classificações MDBList - Mostra classificações externas do MDBList nas páginas de metadados quando o ID do IMDb estiver disponível. + Mostra classificações externas do MDBList nas páginas de metadados quando um ID IMDb está disponível. CHAVE API FORNECEDORES DE CLASSIFICAÇÃO MDBLIST @@ -507,19 +519,19 @@ Elenco Lista do elenco principal. Fundo Cinemático - Fundo desfocado atrás do conteúdo, semelhante ao ecrã de transmissão. + Fundo desfocado atrás do conteúdo, semelhante ao ecrã de stream. Coleção Linha de coleções ou franchises relacionados. Comentários Secção de comentários do Trakt. Detalhes - Duração, estado, lançamento, idioma e informações relacionadas. + Duração, estado, lançamento, idioma e info relacionada. Cartões de Episódio Escolhe como os episódios são apresentados no ecrã de metadados. Horizontal - Cartões em linha tipo imagem de fundo + Cartões em linha tipo miniatura Lista - Cartões empilhados focados nos detalhes + Cartões empilhados com foco no detalhe Episódios Lista de temporadas e episódios para séries. Grupo %1$d @@ -529,29 +541,29 @@ Resumo Sinopse, classificações, géneros e créditos principais. Produção - Estúdios e redes/canais. - APARÊNCIA + Estúdios e canais. + ASPETO SECÇÕES Grupo de Separadores %1$d - Esquema em Separadores - Agrupa secções em separadores. Atribui até 3 secções por grupo de separadores. + Layout de Separadores + Agrupa secções em separadores como na aplicação de TV. Atribui até 3 secções por grupo. Trailers Linha de trailers e atalhos de reprodução. As notificações estão atualmente desativadas no Nuvio. Alertas de lançamento de episódios - Agenda notificações locais quando um novo episódio de uma série guardada ficar disponível. - As notificações do sistema estão desativadas para o Nuvio. Ativa-as para receberes alertas. - %1$d alertas de lançamento estão agendados neste dispositivo. + Agenda notificações locais para quando um novo episódio de uma série guardada ficar disponível. + As notificações do sistema estão desativadas para o Nuvio. Ativa-as para receberes alertas e notificações de teste. + %1$d alertas de lançamento agendados neste dispositivo. ALERTAS TESTE Enviar Notificação de Teste - A enviar notificação de teste... + A enviar Notificação de Teste... Enviar uma notificação de teste local para %1$s. Guarda primeiro uma série na tua biblioteca para testares as notificações. Notificação de teste Comunidade - Conhece as pessoas que constroem e apoiam o Nuvio em Mobile, TV e Web. - API de Apoiantes não configurada. + Vê quem está a construir e a apoiar o Nuvio em Mobile, TV e Web. + A API de Apoiantes não está configurada. Adiciona DONATIONS_BASE_URL ao local.properties. Colaboradores Apoiantes Abrir GitHub @@ -563,10 +575,10 @@ Não foi possível carregar os apoiantes Nenhum colaborador encontrado. Nenhum apoiante encontrado. - Incapaz de carregar colaboradores. - Incapaz de carregar apoiantes. - Não foi possível carregar os colaboradores agora. - Não foi possível carregar os apoiantes agora. + Não foi possível carregar os colaboradores. + Não foi possível carregar os apoiantes. + Não foi possível carregar os colaboradores neste momento. + Não foi possível carregar os apoiantes neste momento. %1$d commits no total Jan Fev @@ -587,10 +599,10 @@ Plugins Permitidos Anime Skip ID de Cliente AnimeSkip - Introduz o teu ID de cliente API do AnimeSkip. - Pesquisar também no AnimeSkip por marcas de tempo para saltar (requer ID de cliente). - Reproduzir Episódio Seguinte Automaticamente - Encontra e reproduz automaticamente o episódio seguinte quando o limite é atingido. + Insere o teu ID de cliente da API AnimeSkip. Obtém um em anime-skip.com. + Pesquisa também no AnimeSkip por marcas de tempo para saltar partes (requer ID de cliente). + Reproduzir Próximo Episódio Automaticamente + Procura e reproduz automaticamente o próximo episódio quando o limite for atingido. Apenas Dispositivo Preferir Aplicação (FFmpeg) Preferir Dispositivo @@ -602,29 +614,29 @@ %1$d hora %1$d horas Ativar libass - Usa o libass para renderização de legendas ASS/SSA em vez do renderizador padrão. - Velocidade ao Premir - Premir para Acelerar - Prime longamente em qualquer parte do reprodutor para aumentar temporariamente a velocidade. + Usa o libass para a renderização de legendas ASS/SSA em vez do renderizador padrão. + Manter Velocidade + Manter para Acelerar + Prime longamente em qualquer parte do reprodutor para aumentar temporariamente a velocidade de reprodução. Padrão regex inválido Duração do Cache do Último Link Mapear DV7 para HEVC - Alternativa de Dolby Vision Profile 7 para HEVC em dispositivos não suportados. + Fallback de Dolby Vision Profile 7 para HEVC para dispositivos não suportados. Minutos Antes do Fim Mostra o cartão do próximo episódio estes minutos antes do fim. %1$d min Nenhum item disponível Não definido - Predefinição + Padrão Idioma do Dispositivo Forçadas Nenhum Preferir Grupo de Maratona - Ao reproduzir automaticamente, prefere uma transmissão do mesmo grupo que a atual. + Ao reproduzir automaticamente, prefere uma transmissão do mesmo grupo de maratona da atual. Idioma de Áudio Preferido Idioma de Legendas Preferido Predefinições - Compara com nome da stream, etiqueta, descrição, addon e URL. + Compara com o nome da transmissão, etiqueta, descrição, addon e URL. Padrão Regex 4K|2160p|Remux Qualquer 1080p+ @@ -642,16 +654,16 @@ Fontes WEB Tipo de Renderização Padrão (Cues) - Canvas de Efeitos - OpenGL de Efeitos - Canvas de Sobreposição - OpenGL de Sobreposição + Effects Canvas + Effects OpenGL + Overlay Canvas + Overlay OpenGL Reutilizar Último Link Reproduz automaticamente a última transmissão funcional para este filme/episódio enquanto o cache for válido. Idioma de Áudio Secundário Idioma de Legendas Secundário DESCODIFICADOR - EPISÓDIO SEGUINTE + PRÓXIMO EPISÓDIO REPRODUTOR SALTAR SEGMENTOS REPRODUÇÃO AUTOMÁTICA @@ -659,91 +671,91 @@ LEGENDAS E ÁUDIO RENDERIZAÇÃO DE LEGENDAS %1$d selecionados - Mostrar Sobreposição de Carga - Mostra a animação de carregamento inicial enquanto uma transmissão começa. + Mostrar Sobreposição de Carregamento + Mostra a sobreposição inicial enquanto uma transmissão começa a ser reproduzida. Saltar Introdução/Créditos/Resumo Mostra o botão de saltar durante segmentos detetados de introdução, créditos e resumo. Âmbito da Fonte Todos os Addons - Considerar transmissões de todos os addons instalados. + Considera transmissões de todos os addons instalados. Todas as Fontes - Considerar transmissões tanto de addons como de plugins. - Apenas Plugins Ativados - Considerar apenas transmissões de plugins ativados. + Considera transmissões de addons e plugins. + Apenas Plugins Ativos + Considera apenas transmissões de plugins ativos. Apenas Addons Instalados - Considerar apenas transmissões de addons instalados. + Considera apenas transmissões de addons instalados. Modo de Seleção de Transmissão - Primeira Disponível + Primeira Transmissão Disponível Reproduz automaticamente a primeira transmissão encontrada. Manual - Selecionar transmissões manualmente de cada vez. + Seleciona as transmissões manualmente de cada vez. Correspondência Regex - Auto-seleciona uma stream que corresponda a um padrão regex. + Seleciona automaticamente uma transmissão que corresponda a um padrão regex. Tempo Limite da Transmissão - Quanto tempo esperar por transmissões antes da auto-seleção. + Quanto tempo esperar pelas transmissões antes da seleção automática. Minutos Antes do Fim Modo de Limite Minutos Antes do Fim Percentagem - Percentagem de Limite - Mostra o cartão do próximo episódio quando a reprodução atinge esta percentagem. - %1$d% + Percentagem Limite + Mostra o cartão do próximo episódio quando a reprodução atingir esta percentagem. + %1$d%% Instantâneo %1$ds Ilimitado - Reprodução Tunelada - Ativa a reprodução tunelada para menor latência na sincronização áudio/vídeo. - Adiciona a tua própria chave API do TMDB abaixo antes de ativares o enriquecimento. + Reprodução em Túnel + Ativa a reprodução em túnel para menor latência na sincronização de áudio/vídeo. + Adiciona a tua própria chave API do TMDB abaixo antes de ativares o enriquecimento de dados. Chave API TMDB Ativar enriquecimento TMDB - Usa a tua chave API do TMDB para enriquecer metadados no ecrã de detalhes quando um ID TMDB ou IMDb está disponível. - Introduz a tua chave API v3 do TMDB. + Usa a tua chave API do TMDB para enriquecer os metadados do addon no ecrã de detalhes quando um ID TMDB ou IMDb está disponível. + Insere a tua chave API TMDB v3. Código de idioma - Arte Visual - Substitui fundo, poster e logótipo por arte do TMDB. + Imagens (Artwork) + Substitui o fundo, o póster e o logótipo por imagens do TMDB. Informação básica - Usa título, sinopse, géneros e classificação do TMDB. + Usa o título, sinopse, géneros e classificação do TMDB. Coleções - Mostra linhas de franchises e coleções para filmes quando disponíveis. + Mostra linhas de franchises e coleções para filmes quando o TMDB as fornece. Créditos Usa criadores, realizadores, argumentistas e fotos do elenco do TMDB. Detalhes Usa info de lançamento, duração, classificação etária, estado, país e idioma do TMDB. Episódios - Usa títulos, miniaturas, descrições e durações de episódios do TMDB para séries. + Usa títulos de episódios, miniaturas, descrições e durações do TMDB para séries. Mais como este Mostra recomendações do TMDB no fundo das páginas de detalhes. - Redes/Canais - Usa metadados de redes do TMDB para títulos de TV. + Canais (Networks) + Usa metadados de canais do TMDB para títulos de TV. Produtoras Usa metadados de produtoras do TMDB no ecrã de detalhes. - Posters de temporadas - Usa posters de temporadas do TMDB no seletor de temporadas para séries. + Pósteres de temporadas + Usa pósteres de temporadas do TMDB no seletor de temporadas do ecrã de metadados. Trailers - Procura e exibe a secção de trailers do TMDB nas páginas de detalhes. + Obtém e mostra a secção de trailers de vídeo do TMDB nas páginas de detalhes. Chave API pessoal Idioma preferido - Define o código de idioma TMDB para metadados localizados (ex: `pt-PT`, `en-US`). + Define o código de idioma do TMDB para metadados localizados, por exemplo: `pt-PT`, `en-GB` ou `en-US`. CREDENCIAIS LOCALIZAÇÃO MÓDULOS TMDB - Após aprovação, serás redirecionado de volta automaticamente. + Após a aprovação, serás redirecionado de volta automaticamente. AUTENTICAÇÃO Comentários - Mostrar comentários do Trakt nos detalhes de filmes e séries - Ligar ao Trakt + Mostra comentários do Trakt nos detalhes de filmes e séries + Ligar Trakt Ligado como %1$s Utilizador Trakt Desligar Falha ao abrir o navegador FUNCIONALIDADES Conclui o início de sessão do Trakt no teu navegador - Monitoriza o que vês, guarda na lista de interesse ou listas personalizadas e mantém a biblioteca sincronizada. - Faltam credenciais do Trakt em local.properties. + Regista o que vês, guarda na lista de interesses ou em listas personalizadas e mantém a tua biblioteca sincronizada com o Trakt. + Faltam credenciais do Trakt no local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). Abrir Login do Trakt - As tuas ações de \"Guardar\" podem agora visar a lista de interesse e listas pessoais do Trakt. - Inicia sessão com o Trakt para ativar gravação baseada em listas e modo de biblioteca Trakt. + As tuas ações de Guardar podem agora visar a lista de interesses e listas pessoais do Trakt. + Inicia sessão com o Trakt para ativares o salvamento em listas e o modo de biblioteca Trakt. Pontuação do Público IMDb Letterboxd @@ -769,7 +781,7 @@ Saltar Créditos Saltar Resumo Nenhuma legenda encontrada - Africâner + Africânder Albanês Amárico Árabe @@ -788,7 +800,7 @@ Croata Checo Dinamarquês - Holandês + Neerlandês Inglês Estónio Filipino @@ -838,7 +850,7 @@ Suaíli Sueco Tâmil - Telugo + Telugu Tailandês Turco Ucraniano @@ -864,7 +876,7 @@ Falha ao carregar Mais Como Este Temporadas - Este addon devolveu vídeos, mas nenhum inclui números de temporada ou episódio. + Este addon devolveu vídeos para a série, mas nenhum incluía números de temporada ou episódio. Este addon não forneceu metadados de episódios para esta série. Os episódios ainda não foram publicados por este addon. O teu dispositivo está online, mas o Nuvio não conseguiu contactar os servidores necessários. @@ -875,12 +887,12 @@ Catálogo %1$s • %2$s O catálogo selecionado falhou ao devolver itens de descoberta. - Não foi possível carregar descoberta - Os addons instalados não expõem catálogos compatíveis para descoberta. + Não foi possível carregar a descoberta + Os addons instalados não expõem catálogos compatíveis com o painel de descoberta. Sem catálogos de descoberta - O catálogo e filtros selecionados não devolveram nenhuns itens. + O catálogo e os filtros selecionados não devolveram nenhum item. Nenhum título encontrado - Instala e valida pelo menos um addon antes de navegar nos catálogos. + Instala e valida pelo menos um addon antes de navegar nos catálogos de descoberta. Selecionar Catálogo Selecionar Género Selecionar Tipo @@ -891,11 +903,11 @@ Marcar %1$s como vista Marcar como não visto Marcar como visto - Próximo + A seguir %1$s visto - Instala e valida pelo menos um addon antes de carregar o Início. - Os addons instalados não expõem catálogos compatíveis de momento. - Nenhuma linha disponível no Início + Instala e valida pelo menos um addon antes de carregar as linhas de catálogo no Início. + Os addons instalados não expõem atualmente catálogos compatíveis sem extras obrigatórios. + Sem linhas disponíveis no início Ver Detalhes Controlos de reprodução e gravação. Ações @@ -903,34 +915,34 @@ Linha de coleção ou franchise relacionada. Coleção Secção de comentários do Trakt. - Duração, estado, lançamento, idioma e informações relacionadas. + Duração, estado, lançamento, idioma e info relacionada. Detalhes Lista de temporadas e episódios para séries. Linha de recomendações. Mais Como Este Sinopse, classificações, géneros e créditos principais. - Visão Geral - Estúdios e redes/canais. + Resumo + Estúdios e canais. Produção Linha de trailers e atalhos de reprodução. Novamente online - Não é possível contactar os servidores - Sem ligação à internet + Não foi possível contactar os servidores + Sem ligação à Internet (%1$d anos) Nascimento: %1$s%2$s Falecimento: %1$s Conhecido por: %1$s - Mais recente - Não foi possível carregar os detalhes de %1$s + Mais Recente + Não foi possível carregar detalhes de %1$s Popular Algo correu mal - Próximos lançamentos - Apagar + Brevemente + Retroceder Cancelar Introduzir PIN - Introduz o PIN para %1$s + Introduzir PIN de %1$s Esqueceste-te do PIN? - PIN incorreto + PIN Incorreto Bloqueado. Tenta novamente em %1$ds As opções de avatar aparecerão aqui quando o catálogo carregar. Avatar: %1$s @@ -948,8 +960,8 @@ Gerir Perfis Nome do perfil Novo perfil - Addons primários desativados - Addons primários ativados + Addons principais desativados + Addons principais ativos Remover PIN de %1$s Remover Bloqueio por PIN A guardar... @@ -959,18 +971,18 @@ Seleciona um avatar para este perfil. Definir Bloqueio por PIN Perfil sem nome - Usar Addons Primários + Usar Addons Principais Partilha a configuração de addons do perfil principal em vez de gerir uma lista separada. Quem está a ver? - Descarregado + Transferido Retomar Scrapers ativos A verificar mais addons… - Copiar link da stream - Descarregar ficheiro + Copiar link da transmissão + Transferir ficheiro Os addons de transmissão instalados falharam ao devolver uma resposta válida. Não foi possível carregar as transmissões - Instala um addon primeiro para carregar transmissões para este título. + Instala primeiro um addon para carregar transmissões para este título. Os teus addons instalados não fornecem transmissões para este tipo de título. Nenhum addon de transmissão disponível Nenhum dos teus addons instalados devolveu transmissões para este título. @@ -980,31 +992,31 @@ A obter… A procurar fonte… A procurar transmissões… - Link da stream copiado - Nenhum link direto disponível - Nenhuns metadados disponíveis + Link da transmissão copiado + Nenhum link direto de transmissão disponível + Nenhum metadado disponível Atualizar transmissões - Retomar de %1$d% + Retomar de %1$d%% Retomar de %1$s TAMANHO %1$s Fechar trailer - Não é possível reproduzir o trailer - Falha ao carregar listas do Trakt - Falha ao atualizar listas do Trakt + Não foi possível reproduzir o trailer + Falha ao carregar as listas do Trakt + Falha ao atualizar as listas do Trakt %1$s • %2$s - Falha ao verificar atualizações - Falha no download - A descarregar %1$d% + Falha na verificação de atualizações + Falha na transferência + A transferir %1$d%% Não foi possível iniciar a instalação Estás a usar a versão mais recente. Ativa a instalação de aplicações para o Nuvio, depois volta e continua. - A descarregar atualização... + A transferir atualização... Nenhuma atualização encontrada. Uma nova versão está pronta para instalar. - As atualizações na aplicação não estão disponíveis nesta versão. - A preparar download + As atualizações na aplicação não estão disponíveis nesta versão (build). + A preparar a transferência Notas de lançamento - Permitir que a instalação continue + Permitir que as instalações continuem Atualização disponível Estado da atualização Esse addon já está instalado. @@ -1014,55 +1026,55 @@ Falha ao eliminar conta Falha ao iniciar sessão Falha ao terminar sessão - Falha ao criar conta + Falha no registo Não foi possível carregar os itens do catálogo. - A Seguir - A Seguir • T%1$dE%2$d - Logótipo de %1$s + A seguir + A seguir • T%1$dE%2$d + Logótipo %1$s Falha ao carregar comentários Não foi possível carregar detalhes de nenhum addon. - Redes/Canais + Canais/Redes Nenhum addon fornece metadados para este conteúdo. - Falha no download - Mostra o progresso e controlos de downloads em direto. - Downloads - Download concluído - A descarregar %1$s • %2$s - A descarregar %1$s • %2$s / %3$s - Falha no download - Em pausa %1$s + Falha na transferência + Mostra o progresso e controlos das transferências em direto. + Transferências + Transferência concluída + A transferir %1$s • %2$s + A transferir %1$s • %2$s / %3$s + Falha na transferência + Pausado %1$s Remover Remover %1$s da tua biblioteca? Remover da Biblioteca? Filme - Alertas para quando um novo episódio de uma série guardada é lançado. + Alertas quando um novo episódio de uma série guardada é lançado. Pré-visualização do alerta de lançamento de episódio. Falha ao enviar uma notificação de teste. Notificação de teste enviada para %1$s. - Não é possível reproduzir esta transmissão. + Não foi possível reproduzir esta transmissão. O PIN deste perfil mudou. Liga-te uma vez para atualizar o bloqueio neste dispositivo. - Não foi possível remover o bloqueio por PIN. Tenta novamente. - Liga-te à internet para remover o bloqueio por PIN. + Não foi possível remover o PIN. Tenta novamente. + Liga-te à Internet para remover o bloqueio por PIN. Este PIN ainda não pode ser verificado offline neste dispositivo. Liga-te e desbloqueia-o online primeiro. Não foi possível definir o PIN. Tenta novamente. - Liga-te à internet para definir um PIN. - Este perfil utiliza addons primários. + Liga-te à Internet para definir um PIN. + Este perfil utiliza addons principais. Falha ao carregar %1$s Transmissão Incorporado Autorização negada - Conclui o início de sessão do Trakt no teu navegador + Conclui o início de sessão no Trakt no teu browser Callback do Trakt inválido Estado de callback do Trakt inválido Resposta de token do Trakt inválida - Falha ao carregar biblioteca do Trakt + Falha ao carregar a biblioteca do Trakt Lista %1$d O Trakt não devolveu um código de autorização - Faltam credenciais do Trakt - Falha ao carregar progresso do Trakt + Credenciais do Trakt em falta + Falha ao carregar o progresso do Trakt Falha ao concluir o início de sessão no Trakt Utilizador Trakt - Lista de interesse + Lista de Interesses Trailer Desconhecido Addon @@ -1102,51 +1114,51 @@ Nov Dez Produtora - Rede/Canal + Canal Não foi possível carregar %1$s Popular Recente %1$s • %2$s Melhor Classificados - Certificação + Classificação Etária Detalhes do Filme Idioma Original País de Origem Info de Lançamento Duração - Posters + Pósteres Texto Detalhes da Série Estado Vídeos - Ficheiro - Nenhum link direto disponível - Download anterior substituído - Download iniciado - Formato de transmissão não suportado para downloads + FICH. + Nenhum link direto de transmissão disponível + Transferência anterior substituída + Transferência iniciada + Formato de transmissão não suportado para transferências Corpo da resposta vazio - O pedido falhou com HTTP %1$d - O sistema de downloads não foi inicializado - Pedido de download falhou + Pedido falhou com HTTP %1$d + O sistema de transferências não foi inicializado + Pedido de transferência falhou %1$s - %2$s - Os títulos guardados aparecerão aqui após tocares em \"Guardar\" no ecrã de detalhes. + Os títulos guardados aparecerão aqui depois de tocares em Guardar no ecrã de detalhes. A tua biblioteca está vazia Não foi possível carregar a biblioteca Outro Biblioteca - Liga o Trakt e guarda títulos na tua lista de interesse ou listas pessoais. - A tua biblioteca Trakt está vazia - Não foi possível carregar a biblioteca Trakt + Liga-te ao Trakt e guarda títulos na tua lista de interesses ou listas pessoais. + A tua biblioteca do Trakt está vazia + Não foi possível carregar a biblioteca do Trakt Biblioteca Trakt Anime Canais Filmes Séries - TV - %1$s já está disponível - %1$s • %2$s já está disponível - Um novo episódio já está disponível - %1$s já está disponível + Televisão + %1$s lançado(s) + %1$s • %2$s já lançado(s) + Um novo episódio lançado + %1$s já lançado(s) Lançamentos de Episódios Criador Realizador diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 1d7de0e3..7cbb3f3e 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -188,6 +188,27 @@ Presets Search Add Source + Add Trakt List + Edit Trakt List + Trakt Lists + Trakt list + Search title, Trakt URL, or list ID + Use a public Trakt list URL or numeric list ID, or search by name. + Weekend Watch, Award Winners + Search Results + Trending Lists + Popular Lists + Direction + Ascending + Descending + List Order + Recently Added + Title + Released + Runtime + Popular + Percentage + Votes Action Adventure Animation @@ -1091,6 +1112,7 @@ Folder %1$d in '%2$s' has blank id. Folder '%1$s' in '%2$s' has blank title. Source %1$d in folder '%2$s' has blank fields. + Source %1$d in folder '%2$s' is missing a Trakt list ID. Invalid JSON: %1$s Addon not found: %1$s January diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 4e68adf2..f9e85f6c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -596,7 +596,9 @@ private fun MainAppContent( NetworkCondition.ServersUnreachable, -> { offlineLaunchRouteHandled = true - val hasPlayableDownload = downloadsUiState.completedItems.any { it.isPlayable } + val hasPlayableDownload = downloadsUiState.completedItems.any { + DownloadsRepository.playableLocalFileUri(it) != null + } if (hasPlayableDownload) { selectedTab = AppScreenTab.Settings navController.navigate(DownloadsSettingsRoute) { @@ -689,7 +691,7 @@ private fun MainAppContent( episodeNumber = episodeNumber, videoId = videoId, ) - val localSourceUrl = downloadedItem?.localFileUri + val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri) if (!localSourceUrl.isNullOrBlank()) { val launchId = PlayerLaunchStore.put( PlayerLaunch( @@ -1327,6 +1329,7 @@ private fun MainAppContent( ) ) StreamsRepository.consumeAutoPlay() + StreamsRepository.cancelLoading() navController.navigate(PlayerRoute(launchId = launchId)) { popUpTo { inclusive = true } } @@ -1405,6 +1408,7 @@ private fun MainAppContent( initialProgressFraction = resolvedResumeProgressFraction, ) ) + StreamsRepository.cancelLoading() navController.navigate( PlayerRoute(launchId = launchId) ) @@ -1531,7 +1535,7 @@ private fun MainAppContent( DownloadsScreen( onBack = onBack, onOpenDownload = { item -> - val sourceUrl = item.localFileUri ?: return@DownloadsScreen + val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen val resumeEntry = item.videoId .takeIf { it.isNotBlank() } ?.let(WatchProgressRepository::progressForVideo) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt index f7597072..0a31a9d7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt @@ -2,6 +2,8 @@ package com.nuvio.app.features.collection import co.touchlab.kermit.Logger 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.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -27,6 +29,8 @@ data class CollectionEditorUiState( val showFolderEditor: Boolean = false, val showCatalogPicker: Boolean = false, val showTmdbSourcePicker: Boolean = false, + val showTraktSourcePicker: Boolean = false, + val editingTraktSourceIndex: Int? = null, val genrePickerSourceIndex: Int? = null, val tmdbBuilderMode: TmdbBuilderMode = TmdbBuilderMode.PRESETS, val tmdbInput: String = "", @@ -38,6 +42,16 @@ data class CollectionEditorUiState( val tmdbCompanyResults: List = emptyList(), val tmdbCollectionResults: List = emptyList(), 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 = emptyList(), + val traktTrendingResults: List = emptyList(), + val traktPopularResults: List = emptyList(), + val traktSearchError: String? = null, ) enum class TmdbBuilderMode { @@ -246,7 +260,7 @@ object CollectionEditorRepository { fun updateCatalogSourceGenre(index: Int, genre: String?) { val folder = _uiState.value.editingFolder ?: return 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() updated[index] = updated[index].copy(genre = genre) _uiState.value = _uiState.value.copy( @@ -258,7 +272,11 @@ object CollectionEditorRepository { val folder = _uiState.value.editingFolder ?: return val sources = folder.resolvedSources 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) { removeCatalogSource(existingIndex) @@ -271,6 +289,8 @@ object CollectionEditorRepository { _uiState.value = _uiState.value.copy( showCatalogPicker = true, showTmdbSourcePicker = false, + showTraktSourcePicker = false, + editingTraktSourceIndex = null, genrePickerSourceIndex = null, ) } @@ -283,6 +303,8 @@ object CollectionEditorRepository { _uiState.value = _uiState.value.copy( showTmdbSourcePicker = true, showCatalogPicker = false, + showTraktSourcePicker = false, + editingTraktSourceIndex = null, genrePickerSourceIndex = null, tmdbSearchError = null, ) @@ -292,14 +314,139 @@ object CollectionEditorRepository { _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) { val folder = _uiState.value.editingFolder ?: return 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( genrePickerSourceIndex = index, showCatalogPicker = false, showTmdbSourcePicker = false, + showTraktSourcePicker = false, ) } @@ -322,6 +469,8 @@ object CollectionEditorRepository { showFolderEditor = false, showCatalogPicker = false, showTmdbSourcePicker = false, + showTraktSourcePicker = false, + editingTraktSourceIndex = null, genrePickerSourceIndex = null, ) } @@ -332,6 +481,8 @@ object CollectionEditorRepository { showFolderEditor = false, showCatalogPicker = false, showTmdbSourcePicker = false, + showTraktSourcePicker = false, + editingTraktSourceIndex = 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, 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 { val state = _uiState.value if (state.title.isBlank()) return false @@ -593,10 +841,18 @@ private fun CollectionFolder.withSources(nextSources: List): C ) private fun collectionSourceKey(source: CollectionSource): String = - if (source.isTmdb) { - "tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}" - } else { - "addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}" + when { + source.isTmdb -> { + "tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}" + } + + 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( @@ -630,7 +886,22 @@ private fun titleForMedia( return "$title $suffix" } +private fun selectedTraktMediaTypes(state: CollectionEditorUiState): List = + if (state.traktMediaBoth) { + listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV) + } else { + listOf(state.traktMediaType) + } + private fun CollectionSource.tmdbType(): TmdbCollectionSourceType = tmdbSourceType ?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() } ?: 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) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt index a47e36ab..1114ac1b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState 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.Menu import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip 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.PlatformBackHandler import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.trakt.TraktPublicListSearchResult import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource import sh.calvin.reorderable.ReorderableCollectionItemScope @@ -107,6 +108,14 @@ fun CollectionEditorScreen( return } + if (state.showTraktSourcePicker) { + TraktSourcePickerScreen( + state = state, + onBack = { CollectionEditorRepository.hideTraktSourcePicker() }, + ) + return + } + val genrePickerIndex = state.genrePickerSourceIndex val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) } val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource() @@ -158,6 +167,14 @@ fun CollectionEditorScreen( return } + if (state.showTraktSourcePicker) { + TraktSourcePickerScreen( + state = state, + onBack = { CollectionEditorRepository.hideTraktSourcePicker() }, + ) + return + } + Box(modifier = Modifier.fillMaxSize()) { NuvioScreen( modifier = Modifier.fillMaxSize(), @@ -704,7 +721,10 @@ private fun FolderEditorPage( FolderEditorSection( title = stringResource(Res.string.collections_editor_section_catalog_sources), actions = { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { TextButton(onClick = { CollectionEditorRepository.showTmdbSourcePicker() }) { Icon( imageVector = Icons.Rounded.Add, @@ -714,6 +734,15 @@ private fun FolderEditorPage( Spacer(modifier = Modifier.width(4.dp)) 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() }) { Icon( imageVector = Icons.Rounded.Add, @@ -752,6 +781,12 @@ private fun FolderEditorPage( source = source, onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, ) + } else if (source.isTrakt) { + FolderTraktSourceCard( + source = source, + onEdit = { CollectionEditorRepository.editTraktSource(index) }, + onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, + ) } else if (addonSource != null) { FolderCatalogSourceCard( 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, +) { + if (results.isEmpty()) return + item { + PickerSectionLabel(title) + } + itemsIndexed(results) { _, result -> + PickerOptionRow( + title = result.title, + subtitle = result.subtitle, + selected = false, + onClick = { CollectionEditorRepository.addTraktSourceFromResult(result) }, + ) + } +} + @Composable private fun PickerPanel( 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) @Composable 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) } +@Composable +private fun traktSortOptions(): List> = + 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 private fun tmdbSourceSubtitle(source: CollectionSource): String { val media = when (TmdbCollectionMediaType.fromString(source.mediaType)) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt index 660e8a45..fa04e5f8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt @@ -144,17 +144,27 @@ internal object CollectionJsonPreserver { private fun unifiedSourceKey(element: JsonElement): String? { val obj = element as? JsonObject ?: return null val provider = obj["provider"]?.jsonPrimitive?.contentOrNull ?: "addon" - return if (provider.equals("tmdb", ignoreCase = true)) { - val sourceType = obj["tmdbSourceType"]?.jsonPrimitive?.contentOrNull ?: return null - val tmdbId = obj["tmdbId"]?.jsonPrimitive?.contentOrNull.orEmpty() - val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty() - val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty() - "$provider|$sourceType|$tmdbId|$mediaType|$sortBy" - } 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" + return when { + provider.equals("tmdb", ignoreCase = true) -> { + val sourceType = obj["tmdbSourceType"]?.jsonPrimitive?.contentOrNull ?: return null + val tmdbId = obj["tmdbId"]?.jsonPrimitive?.contentOrNull.orEmpty() + val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty() + val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty() + "$provider|$sourceType|$tmdbId|$mediaType|$sortBy" + } + provider.equals("trakt", ignoreCase = true) -> { + val listId = obj["traktListId"]?.jsonPrimitive?.contentOrNull ?: return null + 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" + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt index f0780ad2..3a6a6013 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt @@ -41,15 +41,20 @@ data class CollectionSource( val tmdbSourceType: String? = null, val title: String? = null, val tmdbId: Int? = null, + val traktListId: Long? = null, val mediaType: String? = null, val sortBy: String? = null, + val sortHow: String? = null, val filters: TmdbCollectionFilters? = null, ) { val isTmdb: Boolean get() = provider.equals("tmdb", ignoreCase = true) + val isTrakt: Boolean + get() = provider.equals("trakt", ignoreCase = true) + fun addonCatalogSource(): CollectionCatalogSource? { - if (isTmdb) return null + if (isTmdb || isTrakt) return null val sourceAddonId = addonId?.takeIf { it.isNotBlank() } ?: return null val sourceType = type?.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 enum class TmdbCollectionSourceType { LIST, @@ -95,6 +103,36 @@ enum class TmdbCollectionSort(val value: String) { 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 @Serializable data class TmdbCollectionFilters( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt index 0e9553ae..39916184 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt @@ -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_invalid_json 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 kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -185,7 +186,20 @@ object CollectionRepository { ) } 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()) val invalidTmdb = s.isTmdb && s.tmdbSourceType.isNullOrBlank() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt index 36698b25..65c0101e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt @@ -10,6 +10,7 @@ import com.nuvio.app.core.i18n.localizedMediaTypeLabel import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.stableKey +import com.nuvio.app.features.trakt.TraktPublicListSourceResolver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -148,6 +149,25 @@ object FolderDetailRepository { 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 { val catalogSource = source.addonCatalogSource() ?: return@forEach val resolvedCatalog = addons.findCollectionCatalog(catalogSource) @@ -188,7 +208,7 @@ object FolderDetailRepository { val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex val catalogSource = source.addonCatalogSource() val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) } - if (!source.isTmdb && resolvedCatalog == null) { + if (!source.isTmdb && !source.isTrakt && resolvedCatalog == null) { updateTab(tabIndex) { it.copy( isLoading = false, @@ -254,7 +274,12 @@ object FolderDetailRepository { private fun loadTabPage(index: Int, reset: Boolean) { val currentTab = _uiState.value.tabs.getOrNull(index) ?: 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 -> if (reset) { @@ -277,13 +302,18 @@ object FolderDetailRepository { val job = scope.launch { runCatching { val source = currentTab.source - if (source?.isTmdb == true) { - TmdbCollectionSourceResolver.resolve( + when { + source?.isTmdb == true -> TmdbCollectionSourceResolver.resolve( source = source, 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), type = currentTab.type, catalogId = currentTab.catalogId, @@ -399,3 +429,13 @@ private fun tmdbCatalogId(source: CollectionSource): String = append("_") 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("_") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt index 3c3374fa..bf4b6744 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt @@ -85,19 +85,35 @@ internal fun MetaDetails.nextReleasedEpisodeAfter( seasonNumber = seasonNumber, episodeNumber = episodeNumber, ) - val candidates = sortedEpisodes - .dropWhile { episode -> - buildPlaybackVideoId( - content = WatchingContentRef(type = type, id = id), - seasonNumber = episode.season, - episodeNumber = episode.episode, - fallbackVideoId = episode.id, - ) != watchedVideoId + var watchedIndex = sortedEpisodes.indexOfFirst { episode -> + buildPlaybackVideoId( + content = WatchingContentRef(type = type, id = id), + seasonNumber = episode.season, + episodeNumber = episode.episode, + fallbackVideoId = episode.id, + ) == 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 -> shouldSurfaceNextEpisode( - watchedSeasonNumber = seasonNumber, + watchedSeasonNumber = watchedEpisodeSeason, candidateSeasonNumber = episode.season, todayIsoDate = todayIsoDate, releasedDate = episode.released, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt index b2a331ad..9fb32ced 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt @@ -21,4 +21,6 @@ internal expect object DownloadsPlatformDownloader { fun removeFile(localFileUri: String?): Boolean fun removePartialFile(destinationFileName: String): Boolean + + fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt index f6e715ba..7ed74677 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt @@ -43,7 +43,7 @@ object DownloadsRepository { val normalizedVideoId = videoId?.trim().orEmpty() if (normalizedVideoId.isBlank()) return null 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.seasonNumber == seasonNumber && item.episodeNumber == episodeNumber && - item.isPlayable && - !item.localFileUri.isNullOrBlank() + item.hasPlayableLocalFile() } } else { items.firstOrNull { item -> item.parentMetaId == normalizedParentMetaId && item.seasonNumber == null && item.episodeNumber == null && - item.isPlayable && - !item.localFileUri.isNullOrBlank() + item.hasPlayableLocalFile() } } } + 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( contentType: String, videoId: String, @@ -117,7 +139,7 @@ object DownloadsRepository { if (existing != null) { replacedExisting = true activeHandles.remove(existing.id)?.cancel() - DownloadsPlatformDownloader.removeFile(existing.localFileUri) + DownloadsPlatformDownloader.removeFile(playableLocalFileUri(existing) ?: existing.localFileUri) DownloadsPlatformDownloader.removePartialFile(existing.fileName) 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) { ensureLoaded() 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 activeHandles.remove(downloadId)?.cancel() - DownloadsPlatformDownloader.removeFile(item.localFileUri) + DownloadsPlatformDownloader.removeFile(playableLocalFileUri(item) ?: item.localFileUri) DownloadsPlatformDownloader.removePartialFile(item.fileName) publish(_uiState.value.items.filterNot { it.id == downloadId }) @@ -233,9 +263,10 @@ object DownloadsRepository { return } + var shouldPersistNormalized = false val normalized = DownloadsCodec.decodeItems(payload) .map { item -> - if (item.status == DownloadStatus.Downloading) { + val statusNormalized = if (item.status == DownloadStatus.Downloading) { item.copy( status = DownloadStatus.Paused, errorMessage = null, @@ -243,10 +274,19 @@ object DownloadsRepository { } else { item } + + val localUriNormalized = normalizeCompletedLocalFileUri(statusNormalized) + if (localUriNormalized != item) { + shouldPersistNormalized = true + } + localUriNormalized } _uiState.value = DownloadsUiState(normalized) notifyLiveStatusPlatform() + if (shouldPersistNormalized) { + persist() + } } private fun startDownload(item: DownloadItem) { @@ -359,6 +399,26 @@ object DownloadsRepository { 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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 79274569..2db78246 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -868,7 +868,7 @@ fun PlayerScreen( } fun switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) { - val localFileUri = downloadItem.localFileUri ?: return + val localFileUri = DownloadsRepository.playableLocalFileUri(downloadItem) ?: return showNextEpisodeCard = false showSourcesPanel = false showEpisodesPanel = false diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt index a5062582..8002195d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt @@ -4,7 +4,7 @@ import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.lang_english import nuvio.composeapp.generated.resources.lang_french 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_italian import nuvio.composeapp.generated.resources.lang_greek diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 98d6e7e3..674e3352 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -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() { activeJob?.cancel() + activeJob = null activeRequestKey = null _uiState.value = StreamsUiState() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt new file mode 100644 index 00000000..50fa7baf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt @@ -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 S1E1–S1E25, S2E1–S2E12, etc. + * Trakt may list it as S1E1–S1E87 (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() + private val reverseMappingCache = mutableMapOf() + private val addonEpisodesCache = mutableMapOf>() + private val traktEpisodesCache = mutableMapOf>() + // In-flight dedup: prevents multiple concurrent coroutines from fetching + // the same show's addon episodes simultaneously. + private val addonEpisodesInFlight = mutableMapOf>>() + + // ── 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, + traktEpisodes: List, + ): 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, + traktEpisodes: List, + ): 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, + traktEpisodes: List, + ): 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 { + 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>() + 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 { + 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 { + 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 { + val seasons = runCatching { + json.decodeFromString>(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.toEpisodeMappingEntries(): List { + 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? = null, +) + +@Serializable +private data class TraktSeasonEpisodeDto( + @SerialName("number") val number: Int? = null, + @SerialName("season") val season: Int? = null, + @SerialName("title") val title: String? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt index b036b984..d7b005d2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt @@ -7,6 +7,7 @@ internal data class TraktExternalIds( val trakt: Int? = null, val imdb: String? = null, val tmdb: Int? = null, + val slug: String? = null, ) internal fun parseTraktContentIds(contentId: String?): TraktExternalIds { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktImageUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktImageUtils.kt new file mode 100644 index 00000000..b6acf748 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktImageUtils.kt @@ -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? = null, + val poster: List? = null, + val logo: List? = null, + val clearart: List? = null, + val banner: List? = null, + val thumb: List? = null, +) + +internal fun List?.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() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt index 4e2468e8..0dc06966 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt @@ -1,20 +1,15 @@ package com.nuvio.app.features.trakt 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.httpPostJsonWithHeaders -import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.tmdb.TmdbService import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -28,7 +23,6 @@ import org.jetbrains.compose.resources.getString import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.selects.select import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable 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 WATCHLIST_KEY = "trakt:watchlist" 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 SNAPSHOT_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 val refreshMutex = Mutex() - private var hydrationJob: Job? = null private var lastRefreshAtMs: Long = 0L private var lastListTabsRefreshAtMs: Long = 0L @@ -91,8 +82,6 @@ object TraktLibraryRepository { } fun onProfileChanged() { - hydrationJob?.cancel() - hydrationJob = null hasLoaded = false lastRefreshAtMs = 0L lastListTabsRefreshAtMs = 0L @@ -101,8 +90,6 @@ object TraktLibraryRepository { } fun clearLocalState() { - hydrationJob?.cancel() - hydrationJob = null hasLoaded = false lastRefreshAtMs = 0L lastListTabsRefreshAtMs = 0L @@ -154,8 +141,6 @@ object TraktLibraryRepository { return } - AddonRepository.initialize() - val headers = TraktAuthRepository.authorizedHeaders() if (headers == null) { _uiState.value = TraktLibraryUiState() @@ -173,7 +158,6 @@ object TraktLibraryRepository { hasLoaded = true, errorMessage = null, ) - hydrateMissingMetadataAsync(_uiState.value) } }.onFailure { error -> if (error is CancellationException) throw error @@ -195,7 +179,6 @@ object TraktLibraryRepository { errorMessage = null, ) persistSnapshot(_uiState.value) - hydrateMissingMetadataAsync(_uiState.value) lastRefreshAtMs = now } } @@ -421,7 +404,6 @@ object TraktLibraryRepository { entriesByList = cached.entriesByList, ) _uiState.value = state.copy(isLoading = false, errorMessage = null, hasLoaded = true) - hydrateMissingMetadataAsync(_uiState.value) } private fun persistSnapshot(state: TraktLibraryUiState) { @@ -432,59 +414,6 @@ object TraktLibraryRepository { 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>, - hydratedEntriesByList: Map>, - ): Map> { - 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): List { val watchlistTabs = listOf( TraktListTab( @@ -544,83 +473,6 @@ object TraktLibraryRepository { entriesByList.toMap() } - private suspend fun hydrateEntriesFromAddonMeta( - entriesByList: Map>, - ): Map> = 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): List { val payload = httpGetTextWithHeaders( url = "$BASE_URL/users/me/lists", @@ -786,10 +638,9 @@ object TraktLibraryRepository { ?: ids?.trakt?.let { "trakt:$it" } ?: return null - val poster = media.images?.poster.firstNonBlankImageUrl() - ?: media.images?.fanart.firstNonBlankImageUrl() - val banner = media.images?.banner.firstNonBlankImageUrl() - val logo = media.images?.logo.firstNonBlankImageUrl() + val poster = media.images.traktBestPosterUrl() + val banner = media.images.traktBestBackdropUrl() + val logo = media.images.traktBestLogoUrl() val savedAt = item.listedAt ?.takeIf { it.isNotBlank() } @@ -829,34 +680,6 @@ object TraktLibraryRepository { 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?.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+") } @@ -866,11 +689,6 @@ private data class StoredTraktLibraryPayload( val entriesByList: Map> = 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 private data class TraktListSummaryDto( val name: String? = null, @@ -902,14 +720,6 @@ private data class TraktMediaDto( val images: TraktImagesDto? = null, ) -@Serializable -private data class TraktImagesDto( - val fanart: List? = null, - val poster: List? = null, - val logo: List? = null, - val banner: List? = null, -) - @Serializable private data class TraktIdsDto( val trakt: Int? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index 6d10a78c..6ca02f0b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -434,9 +434,31 @@ object TraktProgressRepository { entries.map { entry -> val meta = metadataByContent[entry.parentMetaType to entry.parentMetaId] ?: return@map entry - val episode = if (entry.seasonNumber != null && entry.episodeNumber != null) { - meta.videos.firstOrNull { video -> - video.season == entry.seasonNumber && video.episode == entry.episodeNumber + var resolvedSeason = entry.seasonNumber + var resolvedEpisode = 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 { null @@ -447,6 +469,8 @@ object TraktProgressRepository { logo = entry.logo ?: meta.logo, poster = entry.poster ?: meta.poster, background = entry.background ?: meta.background, + seasonNumber = resolvedSeason ?: entry.seasonNumber, + episodeNumber = resolvedEpisode ?: entry.episodeNumber, episodeTitle = entry.episodeTitle ?: episode?.title, episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail, pauseDescription = entry.pauseDescription diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt new file mode 100644 index 00000000..e1468245 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt @@ -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>(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( + 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 = withContext(Dispatchers.Default) { + val trimmed = query.trim() + if (trimmed.isBlank()) return@withContext emptyList() + requestJson>( + 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 = + loadProminentLists("lists/trending") + + suspend fun popularPublicLists(): List = + loadProminentLists("lists/popular") + + fun parseTraktListId(input: String): Long? = + parseTraktListPath(input)?.toLongOrNull() + + private suspend fun loadProminentLists(endpoint: String): List = + withContext(Dispatchers.Default) { + requestJson>( + 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 requestJson( + endpoint: String, + query: Map = 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(response.body) } + .onFailure { error -> log.w(error) { "Failed to parse Trakt response for $endpoint" } } + .getOrThrow() + } + + private suspend fun requestRaw( + endpoint: String, + query: Map = 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 { + 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? = 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? = 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? = null, + val images: TraktImagesDto? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt index 359eec29..59c074ee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt @@ -46,12 +46,30 @@ fun nextReleasedEpisodeAfter( compareBy({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }), ) 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 - .dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId } - .drop(1) + .drop(watchedIndex + 1) .filter { episode -> shouldSurfaceNextEpisode( - watchedSeasonNumber = seasonNumber, + watchedSeasonNumber = watchedEpisodeSeason, candidateSeasonNumber = episode.seasonNumber, todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt index 237f9dcf..27c6fcd1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt @@ -166,7 +166,11 @@ fun latestCompletedSeriesEpisode( { it.markedAtEpochMs }, ) } else { - compareBy { it.markedAtEpochMs } + compareBy( + { it.markedAtEpochMs }, + { normalizeSeasonNumber(it.seasonNumber) }, + { it.episodeNumber }, + ) } val allMarkers = buildList { progressRecords diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt index 714dbcf7..5a63de88 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpPostJsonWithHeaders import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktEpisodeMappingService import com.nuvio.app.features.watched.WatchedItem import kotlinx.coroutines.CancellationException 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() + 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) ─────────────────────────────────────────── @@ -107,6 +131,8 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { val shows = mutableListOf() items.forEach { item -> + if (!item.shouldSyncToTraktHistory()) return@forEach + val ids = parseIds(item.id) ?: return@forEach 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( TraktHistoryAddRequestDto( movies = movies.takeIf { it.isNotEmpty() }, @@ -178,7 +199,7 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { ), ) - runCatching { + val responseText = runCatching { httpPostJsonWithHeaders( url = "$BASE_URL/sync/history", body = body, @@ -187,6 +208,101 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { }.onFailure { e -> if (e is CancellationException) throw e 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, + items: Collection, + ) { + val remappedShows = mutableListOf() + + 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() items.forEach { item -> + if (!item.shouldSyncToTraktHistory()) return@forEach + val ids = parseIds(item.id) ?: return@forEach 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( TraktHistoryRemoveRequestDto( movies = movies.takeIf { it.isNotEmpty() }, @@ -251,6 +365,70 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { if (e is CancellationException) throw e 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, + items: Collection, + ) { + val remappedShowDtos = mutableListOf() + + 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 ───────────────────────────────────────────────────────── @@ -348,6 +526,13 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { 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) ─────────────────────────────────── @Serializable diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt new file mode 100644 index 00000000..66f227dd --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt @@ -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>(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>(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>(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""")) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktImageUtilsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktImageUtilsTest.kt new file mode 100644 index 00000000..c432735f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktImageUtilsTest.kt @@ -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().firstTraktImageUrl()) + assertNull(TraktImagesDto().traktBestPosterUrl()) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepositoryTest.kt deleted file mode 100644 index a6b053a4..00000000 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepositoryTest.kt +++ /dev/null @@ -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)) - } -} \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolverTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolverTest.kt new file mode 100644 index 00000000..2174b5dc --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolverTest.kt @@ -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(), + ) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt index 2ce2b26a..733bec21 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt @@ -1,9 +1,8 @@ package com.nuvio.app.features.downloads import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.addressOf +import kotlinx.cinterop.CPointer import kotlinx.cinterop.convert -import kotlinx.cinterop.usePinned import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -13,6 +12,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import platform.Foundation.NSError import platform.Foundation.NSDate +import platform.Foundation.NSData import platform.Foundation.NSFileManager import platform.Foundation.NSHTTPURLResponse import platform.Foundation.NSHomeDirectory @@ -23,16 +23,17 @@ import platform.Foundation.NSURLRequestReloadIgnoringLocalCacheData import platform.Foundation.NSURLResponse import platform.Foundation.NSURLSession import platform.Foundation.NSURLSessionConfiguration -import platform.Foundation.NSURLSessionDownloadDelegateProtocol -import platform.Foundation.NSURLSessionDownloadTask +import platform.Foundation.NSURLSessionDataDelegateProtocol +import platform.Foundation.NSURLSessionDataTask import platform.Foundation.NSURLSessionTask import platform.Foundation.setHTTPMethod import platform.Foundation.setValue import platform.Foundation.timeIntervalSince1970 import platform.darwin.NSObject -import platform.posix.fopen +import platform.posix.FILE import platform.posix.fclose -import platform.posix.fread +import platform.posix.fflush +import platform.posix.fopen import platform.posix.fwrite private const val DOWNLOAD_REQUEST_TIMEOUT_SECONDS = 60.0 @@ -49,6 +50,10 @@ fun handleDownloadsBackgroundEvents( backgroundSessionCompletionHandlers[identifier] = completionHandler } +fun pauseDownloadsForAppBackground() { + DownloadsRepository.pauseActiveDownloads() +} + @OptIn(ExperimentalForeignApi::class) internal actual object DownloadsPlatformDownloader { actual fun start( @@ -132,22 +137,45 @@ internal actual object DownloadsPlatformDownloader { actual fun removeFile(localFileUri: String?): Boolean { if (localFileUri.isNullOrBlank()) 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 { val tempPath = "${downloadsDirectoryPath()}/$destinationFileName.part" 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 val job: Job, ) : DownloadsTaskHandle { - private var task: NSURLSessionDownloadTask? = null + private var task: NSURLSessionTask? = null private var session: NSURLSession? = null - fun attach(task: NSURLSessionDownloadTask, session: NSURLSession) { + fun attach(task: NSURLSessionTask, session: NSURLSession) { this.task = task this.session = session } @@ -177,10 +205,14 @@ private class IosDownloadDelegate( private val resumeFromBytes: Long, private val tempPath: String, private val onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, -) : NSObject(), NSURLSessionDownloadDelegateProtocol { +) : NSObject(), NSURLSessionDataDelegateProtocol { private val completion = CompletableDeferred() private var result: IosDownloadResult? = null private var fileError: Throwable? = null + private var outputFile: CPointer? = null + private var startingBytesForResponse = 0L + private var bytesWrittenForResponse = 0L + private var totalBytesForResponse: Long? = null private var lastProgressBytes = -1L private var lastProgressTimestampSeconds = 0.0 @@ -188,12 +220,13 @@ private class IosDownloadDelegate( override fun URLSession( session: NSURLSession, - downloadTask: NSURLSessionDownloadTask, - didFinishDownloadingToURL: NSURL, + dataTask: NSURLSessionDataTask, + didReceiveResponse: NSURLResponse, + completionHandler: (Long) -> Unit, ) { - val httpResponse = downloadTask.response as? NSHTTPURLResponse + val httpResponse = didReceiveResponse as? NSHTTPURLResponse val statusCode = httpResponse?.statusCode?.toInt() ?: 200 - result = IosDownloadResult( + val nextResult = IosDownloadResult( statusCode = statusCode, contentRange = httpResponse?.valueForHTTPHeaderField("Content-Range"), contentLength = httpResponse @@ -201,51 +234,59 @@ private class IosDownloadDelegate( ?.toLongOrNull() ?.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 - if (sourcePath.isNullOrBlank()) { - fileError = IllegalStateException("Downloaded file was not available") - return + outputFile = fopen(tempPath, if (isPartialResume) "ab" else "wb") ?: run { + fileError = IllegalStateException("Failed to open partial download file") + null + } + + reportProgress(startingBytesForResponse, totalBytesForResponse) } - val isPartialResume = attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L - 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") - } + completionHandler(1L) } override fun URLSession( session: NSURLSession, - downloadTask: NSURLSessionDownloadTask, - didWriteData: Long, - totalBytesWritten: Long, - totalBytesExpectedToWrite: Long, + dataTask: NSURLSessionDataTask, + didReceiveData: NSData, ) { - val statusCode = (downloadTask.response as? NSHTTPURLResponse)?.statusCode?.toInt() - val startingBytes = if (attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L) { - resumeFromBytes - } else { - 0L + if (fileError != null) return + + val file = outputFile ?: run { + fileError = IllegalStateException("Partial download file is not open") + return } - val expectedTotal = totalBytesExpectedToWrite - .takeIf { it > 0L } - ?.let { startingBytes + it } + + val bytesToWrite = didReceiveData.length.toLong() + 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( - downloadedBytes = startingBytes + totalBytesWritten.coerceAtLeast(0L), - totalBytes = expectedTotal, + downloadedBytes = startingBytesForResponse + bytesWrittenForResponse, + totalBytes = totalBytesForResponse, ) } @@ -254,6 +295,8 @@ private class IosDownloadDelegate( task: NSURLSessionTask, didCompleteWithError: NSError?, ) { + closeOutputFile() + if (didCompleteWithError != null) { completion.completeExceptionally( IllegalStateException(didCompleteWithError.localizedDescription), @@ -275,6 +318,14 @@ private class IosDownloadDelegate( backgroundSessionCompletionHandlers.remove(identifier)?.invoke() } + private fun closeOutputFile() { + outputFile?.let { file -> + fflush(file) + fclose(file) + } + outputFile = null + } + private fun reportProgress( downloadedBytes: Long, totalBytes: Long?, @@ -374,9 +425,11 @@ private suspend fun performDownloadRequest( val session = NSURLSession.sessionWithConfiguration( configuration = configuration, delegate = delegate, - delegateQueue = NSOperationQueue(), + delegateQueue = NSOperationQueue().apply { + maxConcurrentOperationCount = 1 + }, ) - val task = session.downloadTaskWithRequest(nativeRequest) + val task = session.dataTaskWithRequest(nativeRequest) handle.attach(task, session) 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) private fun fileSizeOrNull(path: String): Long? { val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, error = null) @@ -439,10 +454,11 @@ private fun fileSizeOrNull(path: String): Long? { } private fun String.toLocalPath(): String? { - if (startsWith("file://")) { - return removePrefix("file://") + val value = trim() + if (value.startsWith("file:")) { + return NSURL(string = value).path ?: value.removePrefix("file://") } - return takeIf { it.isNotBlank() } + return value.takeIf { it.isNotBlank() } } private fun resolveTotalBytes( diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index e702a219..d7b9fb66 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=48 +CURRENT_PROJECT_VERSION=49 MARKETING_VERSION=0.1.0 diff --git a/iosApp/iosApp/OrientationLockCoordinator.swift b/iosApp/iosApp/OrientationLockCoordinator.swift index cf78e051..26d80c43 100644 --- a/iosApp/iosApp/OrientationLockCoordinator.swift +++ b/iosApp/iosApp/OrientationLockCoordinator.swift @@ -34,6 +34,10 @@ final class OrientationLockAppDelegate: NSObject, UIApplicationDelegate, UNUserN ) } + func applicationDidEnterBackground(_ application: UIApplication) { + DownloadsPlatformDownloader_iosKt.pauseDownloadsForAppBackground() + } + func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index ae08f457..9839d1f0 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -137,12 +137,22 @@ struct TrackInfo { let selected: Bool } +private struct PendingLoadRequest { + let urlString: String + let audioUrl: String? + let requestHeaders: [String: String] + let queuedAtUptime: TimeInterval +} + // MARK: - MPV Player View Controller final class MPVPlayerViewController: UIViewController { private let errorStateLock = NSLock() private var metalLayer = MetalLayer() + private var lastAppliedDrawableSize: CGSize = .zero + private var pendingLoadRequest: PendingLoadRequest? + private var pendingLoadRetryWorkItem: DispatchWorkItem? private var mpv: OpaquePointer? private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated) private var recentPlaybackLogs: [String] = [] @@ -188,12 +198,14 @@ final class MPVPlayerViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black + view.layer.masksToBounds = true - metalLayer.frame = view.bounds - metalLayer.contentsScale = UIScreen.main.nativeScale + metalLayer.contentsGravity = .resize + metalLayer.contentsScale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale metalLayer.framebufferOnly = true metalLayer.backgroundColor = UIColor.black.cgColor view.layer.addSublayer(metalLayer) + layoutMetalLayer() setupMpv() setupNotifications() @@ -207,17 +219,42 @@ final class MPVPlayerViewController: UIViewController { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - metalLayer.frame = view.bounds + layoutMetalLayer() + attemptStartPendingLoad() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) refreshImmersiveSystemUI() + attemptStartPendingLoad() } override func viewSafeAreaInsetsDidChange() { super.viewSafeAreaInsetsDidChange() + layoutMetalLayer() 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 @@ -287,21 +324,80 @@ final class MPVPlayerViewController: UIViewController { // MARK: - Playback API 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 } + 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() - let sanitizedHeaders = sanitizeRequestHeaders(requestHeaders) + let sanitizedHeaders = sanitizeRequestHeaders(request.requestHeaders) activeRequestHeaders = sanitizedHeaders applyRequestHeaders(sanitizedHeaders) isPlayerLoading = true isPlayerEnded = false - command("loadfile", args: [urlString, "replace"]) - if let audioUrl, !audioUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + command("loadfile", args: [request.urlString, "replace"]) + if let audioUrl = request.audioUrl, !audioUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in 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() { guard mpv != nil else { return } 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, "video-unscaled", "no")) case 2: // Zoom - checkError(mpv_set_option_string(mpv, "panscan", "0.0")) - checkError(mpv_set_option_string(mpv, "video-unscaled", "downscale-big")) + checkError(mpv_set_option_string(mpv, "panscan", "1.0")) + checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) default: // Fit checkError(mpv_set_option_string(mpv, "panscan", "0.0")) checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) @@ -432,6 +528,9 @@ final class MPVPlayerViewController: UIViewController { func destroyPlayer() { NotificationCenter.default.removeObserver(self) + pendingLoadRetryWorkItem?.cancel() + pendingLoadRetryWorkItem = nil + pendingLoadRequest = nil clearPlaybackError() guard let ctx = mpv else { return } mpv = nil // nil first so event loop stops reading