From 0cf2367781d2800f8fc6772954d9eb6319b3dcd5 Mon Sep 17 00:00:00 2001 From: kenzuyaa Date: Tue, 2 Sep 2025 04:11:53 +0700 Subject: [PATCH] refactor(Netflix): improve audio and subtitle track hydration logic - Add primary audio track storage for subtitle-only hydration cases - Introduce helper methods for track validation, empty track tuple, and logging hydration attempts - Enhance hydration loop to reuse last successful or primary audio tracks for context - Log detailed hydration attempt information including track IDs and request types - Use None in API calls instead of 'N/A' for missing tracks to prevent errors - Comment out debug log for video profiles to reduce noise - Simplify handling of mismatched audio and subtitle hydration lengths with improved track fallback logic --- unshackle/services/Netflix/__init__.py | 93 ++++++++++++++++++++------ 1 file changed, 71 insertions(+), 22 deletions(-) diff --git a/unshackle/services/Netflix/__init__.py b/unshackle/services/Netflix/__init__.py index cf01dd2..d9b5b0c 100644 --- a/unshackle/services/Netflix/__init__.py +++ b/unshackle/services/Netflix/__init__.py @@ -179,6 +179,7 @@ class Netflix(Service): return titles + def get_tracks(self, title: Title_T) -> Tracks: @@ -571,7 +572,7 @@ class Netflix(Service): - self.log.debug("Profiles:\n\t" + "\n\t".join(video_profiles)) + # self.log.debug("Profiles:\n\t" + "\n\t".join(video_profiles)) if not self.msl: self.log.error(f"MSL Client is not initialized for title_id: {title_id}") @@ -777,6 +778,7 @@ class Netflix(Service): # Process audio tracks unavailable_audio_tracks: List[Tuple[str, str]] = [] + primary_audio_tracks: List[Tuple[str, str]] = [] # Store primary audio tracks with streams if "audio_tracks" in manifest: for audio_index, audio in enumerate(manifest["audio_tracks"]): try: @@ -789,6 +791,11 @@ class Netflix(Service): unavailable_audio_tracks.append((audio["new_track_id"], audio["id"])) # Assign to `unavailable_subtitle` for request missing audio tracks later self.log.debug(f"Audio track at index {audio_index}, audio_id: {audio_id}, language: {audio_lang} has no streams available") continue + + # Store primary audio track info (new_track_id, id) for potential use in hydration + if "new_track_id" in audio and "id" in audio: + primary_audio_tracks.append((audio["new_track_id"], audio["id"])) + # self.log.debug(f"Adding audio lang: {audio["language"]} with profile: {audio["content_profile"]}") is_original_lang = audio.get("language") == original_language.language # self.log.info(f"is audio {audio["languageDescription"]} original language: {is_original_lang}") @@ -901,15 +908,31 @@ class Netflix(Service): hydration_info = " and ".join(hydration_parts) self.log.info(f"Hydrating {hydration_info} tracks. Total: {audio_count + subtitle_count}") - # Handle mismatched lengths - use last successful subtitle track when needed - last_successful_subtitle = ("N/A", "N/A") if not unavailable_subtitle else unavailable_subtitle[-1] + # Handle mismatched lengths - use last successful tracks when needed + last_successful_subtitle = self._get_empty_track_tuple() if not unavailable_subtitle else unavailable_subtitle[-1] + last_successful_audio = self._get_empty_track_tuple() if not unavailable_audio_tracks else unavailable_audio_tracks[-1] + + # For subtitle-only hydration, use primary audio track if available + primary_audio_for_subtitle_hydration = primary_audio_tracks[0] if primary_audio_tracks and not unavailable_audio_tracks and unavailable_subtitle else self._get_empty_track_tuple() # Process audio tracks first, then handle subtitles separately if needed max_length = max(len(unavailable_audio_tracks), len(unavailable_subtitle)) for hydration_index in range(max_length): - # Get audio track info for this index - audio_hydration = unavailable_audio_tracks[hydration_index] if hydration_index < len(unavailable_audio_tracks) else ("N/A", "N/A") + # Get audio track info for this index, or use last successful one if available + if hydration_index < len(unavailable_audio_tracks): + audio_hydration = unavailable_audio_tracks[hydration_index] + is_real_audio_request = True # This is a real audio to be added to tracks + elif unavailable_audio_tracks: # Use last successful audio track for context only + audio_hydration = last_successful_audio + is_real_audio_request = False # This is just for context, don't add to tracks + elif primary_audio_for_subtitle_hydration[0] is not None: # Use primary audio for subtitle hydration + audio_hydration = primary_audio_for_subtitle_hydration + is_real_audio_request = False # This is just for context, don't add to tracks + self.log.debug(f"Using primary audio track for subtitle hydration: {audio_hydration[1]}") + else: + audio_hydration = self._get_empty_track_tuple() + is_real_audio_request = False # Get subtitle track info for this index, or use last successful one if available if hydration_index < len(unavailable_subtitle): @@ -919,33 +942,38 @@ class Netflix(Service): subtitle_hydration = last_successful_subtitle is_real_subtitle_request = False # This is just for context, don't add to tracks else: - subtitle_hydration = ("N/A", "N/A") + subtitle_hydration = self._get_empty_track_tuple() is_real_subtitle_request = False try: - # Log what we're trying to hydrate - self.log.debug(f"Hydrating tracks at index {hydration_index}, audio_track_id: {audio_hydration[1] if audio_hydration[1] != 'N/A' else 'N/A'}, subtitle_track_id: {subtitle_hydration[1] if subtitle_hydration[1] != 'N/A' else 'N/A'}, is_real_subtitle: {is_real_subtitle_request}") + + + # Prepare track IDs for API request - convert None to None for proper API handling + audio_track_id = audio_hydration[0] if audio_hydration[0] is not None else None + subtitle_track_id = subtitle_hydration[0] if subtitle_hydration[0] is not None else None - # Only call get_manifest if we have audio to hydrate - should_hydrate_audio = audio_hydration[0] != 'N/A' and audio_hydration[1] != 'N/A' + # Log what we're trying to hydrate + self._log_hydration_attempt(hydration_index, audio_hydration, subtitle_hydration, + is_real_audio_request, is_real_subtitle_request) + + # Only call get_manifest if we have valid tracks to hydrate + should_hydrate_audio = self._is_valid_track_for_hydration(audio_hydration) if not should_hydrate_audio: self.log.debug(f"Skipping hydration at index {hydration_index} - no audio tracks to hydrate") continue - # Always use a valid subtitle track ID for the manifest request to avoid API errors - # Use the subtitle track (real or reused) if available, otherwise use N/A - subtitle_track_for_request = subtitle_hydration[0] if subtitle_hydration[0] != 'N/A' else None - # If we still don't have a subtitle track ID, skip this hydration to avoid API error - if subtitle_track_for_request is None: + if subtitle_track_id is None: self.log.warning(f"Skipping hydration at index {hydration_index} - no subtitle track available for API request context") continue - hydrated_manifest = self.get_manifest(title, self.profiles, subtitle_track_for_request, audio_hydration[0]) - # Handle hydrated audio tracks - if should_hydrate_audio and "audio_tracks" in hydrated_manifest: + + hydrated_manifest = self.get_manifest(title, self.profiles, subtitle_track_id, audio_track_id) + + # Handle hydrated audio tracks (only if it's a real audio request, not reused) + if is_real_audio_request and should_hydrate_audio and "audio_tracks" in hydrated_manifest: try: audios = next((item for item in hydrated_manifest["audio_tracks"] if 'id' in item and item["id"] == audio_hydration[1]), None) if audios and "streams" in audios: @@ -976,9 +1004,11 @@ class Netflix(Service): self.log.warning(f"No audio streams found for hydrated audio_track_id: {audio_hydration[1]} at hydration_index {hydration_index}") except Exception as e: self.log.warning(f"Failed to find hydrated audio track at hydration_index {hydration_index}, audio_track_id: {audio_hydration[1]}, error: {e}") + elif not is_real_audio_request and audio_hydration[1] is not None: + self.log.debug(f"Used audio track context for API request at hydration_index {hydration_index}, audio_track_id: {audio_hydration[1]} (not adding to tracks)") # Handle hydrated subtitle tracks (only if it's a real subtitle request, not reused) - if is_real_subtitle_request and subtitle_hydration[0] != 'N/A' and subtitle_hydration[1] != 'N/A' and "timedtexttracks" in hydrated_manifest: + if is_real_subtitle_request and self._is_valid_track_for_hydration(subtitle_hydration) and "timedtexttracks" in hydrated_manifest: try: subtitles = next((item for item in hydrated_manifest["timedtexttracks"] if 'id' in item and item["id"] == subtitle_hydration[1]), None) if subtitles and "downloadableIds" in subtitles and "ttDownloadables" in subtitles: @@ -1009,11 +1039,11 @@ class Netflix(Service): self.log.warning(f"No subtitle data found for hydrated subtitle_track_id: {subtitle_hydration[1]} at hydration_index {hydration_index}") except Exception as e: self.log.warning(f"Failed to process hydrated subtitle track at hydration_index {hydration_index}, subtitle_track_id: {subtitle_hydration[1]}, error: {e}") - elif not is_real_subtitle_request and subtitle_hydration[1] != 'N/A': + elif not is_real_subtitle_request and subtitle_hydration[1] is not None: self.log.debug(f"Used subtitle track context for API request at hydration_index {hydration_index}, subtitle_track_id: {subtitle_hydration[1]} (not adding to tracks)") except Exception as e: - self.log.warning(f"Failed to hydrate tracks at hydration_index {hydration_index}, audio_track_id: {audio_hydration[1] if audio_hydration[1] != 'N/A' else 'N/A'}, subtitle_track_id: {subtitle_hydration[1] if subtitle_hydration[1] != 'N/A' else 'N/A'}, error: {e}") + self.log.warning(f"Failed to hydrate tracks at hydration_index {hydration_index}, audio_track_id: {audio_hydration[1] or 'None'}, subtitle_track_id: {subtitle_hydration[1] or 'None'}, error: {e}") continue else: self.log.info("No tracks need hydration") @@ -1063,4 +1093,23 @@ class Netflix(Service): return Video.Range.SDR # If profile not found, return SDR as default - return Video.Range.SDR \ No newline at end of file + return Video.Range.SDR + + def _is_valid_track_for_hydration(self, track_data: tuple) -> bool: + """Check if track data is valid for hydration (not None values).""" + return track_data[0] is not None and track_data[1] is not None + + def _get_empty_track_tuple(self) -> tuple: + """Return an empty track tuple with None values.""" + return (None, None) + + def _log_hydration_attempt(self, hydration_index: int, audio_data: tuple, subtitle_data: tuple, + is_real_audio: bool, is_real_subtitle: bool) -> None: + """Log hydration attempt details.""" + audio_id = audio_data[1] if audio_data[1] is not None else 'None' + subtitle_id = subtitle_data[1] if subtitle_data[1] is not None else 'None' + self.log.debug( + f"Hydrating tracks at index {hydration_index}, " + f"audio_track_id: {audio_id}, subtitle_track_id: {subtitle_id}, " + f"is_real_audio: {is_real_audio}, is_real_subtitle: {is_real_subtitle}" + ) \ No newline at end of file