From 9eb0fc865860be84dc019c03e3dd7278937ad5fa Mon Sep 17 00:00:00 2001 From: huangjianwu Date: Tue, 25 Aug 2026 13:41:46 +0800 Subject: [PATCH 1/3] test(downloader): cover yt-dlp retry budget --- backend/tests/test_ydl_retry_behavior.py | 97 ++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 backend/tests/test_ydl_retry_behavior.py diff --git a/backend/tests/test_ydl_retry_behavior.py b/backend/tests/test_ydl_retry_behavior.py new file mode 100644 index 0000000..80b0d5b --- /dev/null +++ b/backend/tests/test_ydl_retry_behavior.py @@ -0,0 +1,97 @@ +"""Regression coverage for the retry contract at the yt-dlp boundary.""" +import importlib.util +import pathlib +import sys +import types + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "app" / "downloaders" / "youtube_downloader.py" + + +def _stub(monkeypatch, name, **attrs): + module = types.ModuleType(name) + for key, value in attrs.items(): + setattr(module, key, value) + monkeypatch.setitem(sys.modules, name, module) + return module + + +class _Downloader: + def __init__(self): + self.cache_data = "/tmp" + + +class _AudioDownloadResult: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +def _load_downloader_module(monkeypatch): + _stub(monkeypatch, "app") + _stub(monkeypatch, "app.downloaders") + _stub(monkeypatch, "app.models") + _stub(monkeypatch, "app.services") + _stub(monkeypatch, "app.utils") + _stub( + monkeypatch, + "app.downloaders.base", + Downloader=_Downloader, + DownloadQuality=str, + YDL_RETRY_OPTS={"retries": 3, "fragment_retries": 3, "socket_timeout": 30}, + ) + _stub(monkeypatch, "app.downloaders.youtube_subtitle", YouTubeSubtitleFetcher=object) + _stub(monkeypatch, "app.models.notes_model", AudioDownloadResult=_AudioDownloadResult) + _stub(monkeypatch, "app.models.transcriber_model", TranscriptResult=object) + _stub( + monkeypatch, + "app.services.proxy_config_manager", + ProxyConfigManager=type("ProxyConfigManager", (), {"get_proxy_url": lambda self: None}), + ) + _stub(monkeypatch, "app.utils.path_helper", get_data_dir=lambda: "/tmp") + _stub(monkeypatch, "app.utils.url_parser", extract_video_id=lambda url, platform: "video-id") + + spec = importlib.util.spec_from_file_location("youtube_downloader_under_test", MODULE_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +class _CapturingYoutubeDL: + options = None + + def __init__(self, options): + type(self).options = options + + def __enter__(self): + return self + + def __exit__(self, *exc_info): + return False + + def extract_info(self, _url, download=True): + return { + "id": "video-id", + "title": "Example", + "duration": 1, + "thumbnail": None, + "ext": "m4a", + "tags": [], + } + + +def test_audio_download_configures_a_nonzero_retry_budget(tmp_path, monkeypatch): + """Removing retry options must make transient download errors unprotected.""" + module = _load_downloader_module(monkeypatch) + original_youtube_dl = module.yt_dlp.YoutubeDL + module.yt_dlp.YoutubeDL = _CapturingYoutubeDL + try: + module.YoutubeDownloader().download("https://youtu.be/example", str(tmp_path)) + finally: + module.yt_dlp.YoutubeDL = original_youtube_dl + + options = _CapturingYoutubeDL.options + assert options["retries"] > 0 + assert options["fragment_retries"] > 0 + assert options["socket_timeout"] > 0 From a8ae70a239f425d6f931c1b5ec43600c2e9f2067 Mon Sep 17 00:00:00 2001 From: huangjianwu Date: Tue, 25 Aug 2026 13:50:01 +0800 Subject: [PATCH 2/3] test(transcriber): cover persisted Whisper model selection --- ...st_transcriber_provider_model_selection.py | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 backend/tests/test_transcriber_provider_model_selection.py diff --git a/backend/tests/test_transcriber_provider_model_selection.py b/backend/tests/test_transcriber_provider_model_selection.py new file mode 100644 index 0000000..04bb3d3 --- /dev/null +++ b/backend/tests/test_transcriber_provider_model_selection.py @@ -0,0 +1,76 @@ +"""Model-selection contract tests without loading the Whisper runtime.""" +import importlib.util +import pathlib +import sys +import types + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "app" / "transcriber" / "transcriber_provider.py" + + +class _Logger: + def info(self, *_args, **_kwargs): + pass + + def warning(self, *_args, **_kwargs): + pass + + def error(self, *_args, **_kwargs): + pass + + +class _Whisper: + def __init__(self, model_size, device): + self.model_size = model_size + self.device = device + + +def _stub(monkeypatch, name, **attrs): + module = types.ModuleType(name) + for key, value in attrs.items(): + setattr(module, key, value) + monkeypatch.setitem(sys.modules, name, module) + + +def _load_provider(monkeypatch): + _stub(monkeypatch, "app") + _stub(monkeypatch, "app.transcriber") + _stub(monkeypatch, "app.utils") + _stub(monkeypatch, "app.transcriber.groq", GroqTranscriber=object) + _stub(monkeypatch, "app.transcriber.whisper", WhisperTranscriber=_Whisper) + _stub(monkeypatch, "app.transcriber.bcut", BcutTranscriber=object) + _stub(monkeypatch, "app.transcriber.kuaishou", KuaishouTranscriber=object) + _stub(monkeypatch, "app.utils.logger", get_logger=lambda _name: _Logger()) + + spec = importlib.util.spec_from_file_location("transcriber_provider_under_test", MODULE_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_explicit_whisper_model_overrides_docker_default(monkeypatch): + """A saved UI choice must not be replaced by WHISPER_MODEL_SIZE.""" + provider = _load_provider(monkeypatch) + monkeypatch.setenv("WHISPER_MODEL_SIZE", "tiny") + + transcriber = provider.get_transcriber( + transcriber_type="fast-whisper", + model_size="large-v3-turbo", + device="cpu", + ) + + assert transcriber.model_size == "large-v3-turbo" + + +def test_switching_whisper_models_rebuilds_the_cached_instance(monkeypatch): + """Caching only by transcriber type would keep returning the first model.""" + provider = _load_provider(monkeypatch) + monkeypatch.delenv("WHISPER_MODEL_SIZE", raising=False) + + base = provider.get_transcriber("fast-whisper", model_size="base", device="cpu") + turbo = provider.get_transcriber("fast-whisper", model_size="large-v3-turbo", device="cpu") + + assert turbo is not base + assert turbo.model_size == "large-v3-turbo" From 8fbac5b2c5552ec8605e8f727bddc1ccb010dfa7 Mon Sep 17 00:00:00 2001 From: huangjianwu Date: Tue, 25 Aug 2026 13:59:27 +0800 Subject: [PATCH 3/3] chore(release): prepare v2.4.5 --- BillNote_frontend/src-tauri/tauri.conf.json | 4 ++-- backend/tests/test_youtube_metadata_only.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/BillNote_frontend/src-tauri/tauri.conf.json b/BillNote_frontend/src-tauri/tauri.conf.json index eb432d1..2993263 100644 --- a/BillNote_frontend/src-tauri/tauri.conf.json +++ b/BillNote_frontend/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "BiliNote", - "version": "2.4.4", + "version": "2.4.5", "identifier": "com.jefferyhuang.bilinote", "build": { "frontendDist": "../dist", @@ -43,4 +43,4 @@ "icons/icon.png" ] } -} \ No newline at end of file +} diff --git a/backend/tests/test_youtube_metadata_only.py b/backend/tests/test_youtube_metadata_only.py index c2e807e..34b5fa5 100644 --- a/backend/tests/test_youtube_metadata_only.py +++ b/backend/tests/test_youtube_metadata_only.py @@ -31,7 +31,11 @@ def _stub(name, **attrs): module = types.ModuleType(name) for key, value in attrs.items(): setattr(module, key, value) - sys.modules.setdefault(name, module) + # Tests in this repository load downloader modules with lightweight app + # stubs. Replace a previous test's stub instead of inheriting it; otherwise + # a prior import can leave AudioDownloadResult as ``object`` and turn this + # metadata-only test into a TypeError unrelated to the behavior under test. + sys.modules[name] = module return module