From 541ab6409e1766b5b35b1a0bff3ea5f7fda4f5c5 Mon Sep 17 00:00:00 2001 From: wimby Date: Tue, 29 Sep 2026 09:57:02 +0200 Subject: [PATCH] fix: honor HD minimum when falling back formats --- tubesync/sync/matching.py | 10 +++++-- tubesync/sync/tests/test_format_matching.py | 31 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/tubesync/sync/matching.py b/tubesync/sync/matching.py index a53fc8d7..64583a0c 100644 --- a/tubesync/sync/matching.py +++ b/tubesync/sync/matching.py @@ -202,7 +202,13 @@ def get_best_video_format(media): if not media.source.can_fallback: # Can't fallback return False, False - # Find the next-best format matches by height + # Find the next-best format matches by height. If the source requires + # an HD fallback, exclude sub-HD candidates before preference ranking; + # otherwise a preferred-codec 360p stream can be selected and rejected + # later even when a valid 720p stream is available. + fallback_min_height = min_height + if media.source.fallback == Val(Fallback.REQUIRE_HD): + fallback_min_height = max(fallback_min_height, fallback_hd_cutoff) for fmt in media.iter_formats(): # If the format has an audio stream, skip it if fmt['acodec'] is not None: @@ -212,7 +218,7 @@ def get_best_video_format(media): if '-sr' in fmt['id']: continue accept_height = ( - fmt['height'] >= min_height and + fmt['height'] >= fallback_min_height and fmt['height'] <= source_resolution_height ) if accept_height: diff --git a/tubesync/sync/tests/test_format_matching.py b/tubesync/sync/tests/test_format_matching.py index 43b4391b..e1ff6935 100644 --- a/tubesync/sync/tests/test_format_matching.py +++ b/tubesync/sync/tests/test_format_matching.py @@ -1,3 +1,4 @@ +import json import logging from django.test import TestCase from sync.models import Source, Media @@ -384,6 +385,36 @@ class FormatMatchingTestCase(TestCase): self.source.audio_track = Val(AudioTrack.DEFAULT) self.assertEqual(self.media.get_format_str(), '248+251-es') + def test_require_hd_fallback_ignores_sub_hd_preferred_codec(self): + self.source.fallback = Val(Fallback.REQUIRE_HD) + self.source.source_resolution = Val(SourceResolution.VIDEO_1080P) + self.source.source_vcodec = Val(YouTube_VideoCodec.VP9) + self.source.source_acodec = Val(YouTube_AudioCodec.OPUS) + self.source.prefer_60fps = True + self.source.prefer_hdr = False + self.media.metadata = json.dumps({ + 'formats': [ + { + 'format_id': '136', 'format_note': '720p', 'height': 720, + 'fps': 24, 'vcodec': 'avc1.4d401f', 'acodec': 'none', + 'tbr': 1000, + }, + { + 'format_id': '243', 'format_note': '360p', 'height': 360, + 'fps': 24, 'vcodec': 'vp9', 'acodec': 'none', + 'tbr': 500, + }, + { + 'format_id': '251', 'format_note': 'medium', + 'vcodec': 'none', 'acodec': 'opus', 'tbr': 120, + }, + ], + }) + self.media.save() + + self.assertEqual(self.media.get_best_video_format(), (False, '136')) + self.assertEqual(self.media.get_format_str(), '136+251') + def test_audio_track_preference_no_marker_is_noop(self): # 'boring' metadata carries no (original)/(default) markers, so the # preference must not change what gets matched