from types import SimpleNamespace from unittest.mock import patch import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from app import schemas from app.chain import mediaserver as MEDIA_SERVER_CHAIN_MODULE from app.chain.mediaserver import MediaServerChain from app.db.adapters.mediaserver import TransactionalMediaServerRepository from app.db.base import Base from app.db.models.mediaserver import MediaServerItem from app.db.oper.mediaserver import MediaServerOper from app.runtime.config import global_vars @pytest.fixture def database(tmp_path): """创建隔离的媒体服务器测试数据库。""" engine = create_engine(f"sqlite:///{tmp_path / 'mediaserver.db'}") session_factory = sessionmaker(bind=engine) Base.metadata.create_all(bind=engine) yield session_factory engine.dispose() def test_add_allows_same_item_id_across_servers(database): """不同媒体服务器允许复用相同 item_id。""" with database() as db: oper = MediaServerOper(db) assert oper.add( server="plex", library="movies", item_id="same-item-id", item_type="电影", title="Movie A", ) assert oper.add( server="jellyfin", library="movies", item_id="same-item-id", item_type="电影", title="Movie B", ) items = ( db.query(MediaServerItem) .order_by(MediaServerItem.server.asc()) .all() ) assert len(items) == 2 assert [item.server for item in items] == ["jellyfin", "plex"] def test_media_count_reuses_existing_server_statistics(): """整服同步应复用现有媒体统计并排除剧集集数。""" chain = object.__new__(MediaServerChain) chain.run_module = lambda *_args, **_kwargs: [ schemas.Statistic(movie_count=12, tv_count=8, music_count=3, episode_count=200) ] assert chain.media_count("plex") == 23 @pytest.mark.parametrize( ("raw_type", "expected"), [ ("Series", "电视剧"), ("show", "电视剧"), ("音乐", "音乐"), ("MusicAlbum", "音乐"), ("Audio", "音乐"), ("Movie", "电影"), ], ) def test_sync_normalizes_movie_tv_and_music_item_types(raw_type, expected): """同步缓存应保留音乐类型,并兼容不同媒体服务器的原始类型名称。""" assert MediaServerChain._normalize_item_type(raw_type) == expected def test_sync_persists_music_without_querying_tv_episodes(database): """Navidrome 专辑同步应写成音乐条目,且不能触发电视剧分集查询。""" chain = object.__new__(MediaServerChain) chain.librarys = lambda _server: [SimpleNamespace(id="music", name="音乐")] chain.media_count = lambda _server: 1 chain.items_count = lambda **_kwargs: pytest.fail("整服统计存在时不应逐库计数") chain.items = lambda **_kwargs: iter( [ schemas.MediaServerItem( server="navidrome", library="music", item_id="album-1", item_type="音乐", title="叶惠美", year="2003", ) ] ) chain.episodes = lambda *_args, **_kwargs: pytest.fail("音乐条目不应查询电视剧分集") with patch.object( MEDIA_SERVER_CHAIN_MODULE, "get_chain_media_server_port", lambda: TransactionalMediaServerRepository(database), ), patch.object( MEDIA_SERVER_CHAIN_MODULE, "get_mediaserver_configs", return_value=[SimpleNamespace(name="navidrome", enabled=True, sync_libraries=["all"])], ): chain.sync() with database() as db: item = db.query(MediaServerItem).one() assert item.item_type == "音乐" assert item.title == "叶惠美" assert item.seasoninfo == {} def test_sync_normalizes_incomplete_tv_episode_rows(database): """TV 同步应丢弃无季号记录,并把缺失集列表规范为空列表。""" chain = object.__new__(MediaServerChain) chain.librarys = lambda _server: [SimpleNamespace(id="shows", name="剧集库")] chain.media_count = lambda _server: 1 chain.items_count = lambda **_kwargs: 1 chain.items = lambda **_kwargs: iter([ schemas.MediaServerItem( server="plex", library="shows", item_id="show-1", item_type="Series", title="测试剧集", ) ]) chain.episodes = lambda *_args, **_kwargs: [ schemas.MediaServerSeasonInfo(season=None, episodes=[99]), schemas.MediaServerSeasonInfo(season=1, episodes=None), ] with patch.object( MEDIA_SERVER_CHAIN_MODULE, "get_chain_media_server_port", lambda: TransactionalMediaServerRepository(database), ), patch.object( MEDIA_SERVER_CHAIN_MODULE, "get_mediaserver_configs", return_value=[SimpleNamespace(name="plex", enabled=True, sync_libraries=["all"])], ): chain.sync() with database() as session: item = session.query(MediaServerItem).one() assert item.seasoninfo == {"1": []} def test_sync_updates_rows_and_removes_stale_entries(database): """同步应更新已存在条目,并清理未再出现或已移除服务的数据。""" old_sync_time = "2026-05-01 00:00:00" with database() as db: db.add_all( [ MediaServerItem( server="plex", library="movies", item_id="/library/metadata/1", item_type="电影", title="Old Title", year="2024", path="/media/old.mkv", lst_mod_date=old_sync_time, ), MediaServerItem( server="plex", library="movies", item_id="/library/metadata/2", item_type="电影", title="Stale Title", year="2020", path="/media/stale.mkv", lst_mod_date=old_sync_time, ), MediaServerItem( server="jellyfin", library="movies", item_id="/library/metadata/1", item_type="电影", title="Removed Server Title", year="2024", path="/media/removed.mkv", lst_mod_date=old_sync_time, ), ] ) db.commit() existing_id = ( db.query(MediaServerItem.id) .filter( MediaServerItem.server == "plex", MediaServerItem.item_id == "/library/metadata/1", ) .scalar() ) chain = object.__new__(MediaServerChain) chain.librarys = lambda _server: [ SimpleNamespace(id="movies", name="电影库"), SimpleNamespace(id="shows", name="剧集库"), ] chain.media_count = lambda _server: pytest.fail("部分媒体库同步不应使用整服统计") chain.items_count = lambda **_kwargs: 1 chain.items = lambda **_kwargs: iter( [ schemas.MediaServerItem( server="plex", library="movies", item_id="/library/metadata/1", item_type="Movie", title="New Title", year="2024", tmdbid=100, path="/media/new.mkv", ) ] ) chain.episodes = lambda *_args, **_kwargs: [] with patch.object( MEDIA_SERVER_CHAIN_MODULE, "get_chain_media_server_port", lambda: TransactionalMediaServerRepository(database), ), patch.object( MEDIA_SERVER_CHAIN_MODULE, "get_mediaserver_configs", return_value=[SimpleNamespace(name="plex", enabled=True, sync_libraries=["movies"])], ): chain.sync() with database() as db: items = ( db.query(MediaServerItem) .order_by(MediaServerItem.server.asc(), MediaServerItem.item_id.asc()) .all() ) assert len(items) == 1 assert items[0].id == existing_id assert items[0].server == "plex" assert items[0].item_id == "/library/metadata/1" assert items[0].item_type == "电影" assert items[0].title == "New Title" assert items[0].path == "/media/new.mkv" assert items[0].lst_mod_date != old_sync_time def test_sync_queries_counts_before_items_and_reports_media_progress(database): """同步前应查询全部目标媒体库总数,并按媒体条目更新进度。""" chain = object.__new__(MediaServerChain) events = [] progress_snapshots = [] server_libraries = { "plex-a": [SimpleNamespace(id="movies", name="电影库")], "plex-b": [SimpleNamespace(id="shows", name="剧集库")], } library_items = { ("plex-a", "movies"): [ schemas.MediaServerItem( server="plex-a", library="movies", item_id=f"movie-{index}", item_type="Movie", title=f"电影 {index}", ) for index in range(2) ], ("plex-b", "shows"): [ schemas.MediaServerItem( server="plex-b", library="shows", item_id="show-1", item_type="Movie", title="剧集 1", ) ], } chain.librarys = lambda server: server_libraries[server] def media_count(server): """记录整服统计顺序并返回待同步媒体总数。""" events.append(f"count:{server}") return sum( len(items) for (item_server, _library_id), items in library_items.items() if item_server == server ) def items(**kwargs): """记录同步顺序并返回媒体库条目。""" server = kwargs["server"] library_id = kwargs["library_id"] events.append(f"items:{server}:{library_id}") return iter(library_items[(server, library_id)]) chain.media_count = media_count chain.items_count = lambda **_kwargs: pytest.fail("整服同步不应逐库重复计数") chain.items = items chain.episodes = lambda *_args, **_kwargs: [] with patch.object( MEDIA_SERVER_CHAIN_MODULE, "get_chain_media_server_port", lambda: TransactionalMediaServerRepository(database), ), patch.object( MEDIA_SERVER_CHAIN_MODULE, "get_mediaserver_configs", return_value=[ SimpleNamespace(name="plex-a", enabled=True, sync_libraries=["all"]), SimpleNamespace(name="plex-b", enabled=True, sync_libraries=["all"]), ], ): chain.sync( progress_callback=lambda **kwargs: progress_snapshots.append(kwargs) ) assert events == [ "count:plex-a", "count:plex-b", "items:plex-a:movies", "items:plex-b:shows", ] media_progress = [ snapshot for snapshot in progress_snapshots if snapshot["text"].startswith(("正在同步 plex-a 媒体库", "正在同步 plex-b 媒体库")) ] assert [round(snapshot["value"], 2) for snapshot in media_progress] == [ 33.33, 66.67, 100.0, ] assert media_progress[0]["data"]["media_total"] == 3 assert media_progress[1]["data"]["library_media_finished"] == 2 assert media_progress[2]["data"]["current_library"] == "剧集库" assert media_progress[2]["data"]["media_total"] == 3 assert media_progress[2]["data"]["media_finished"] == 3 progress_values = [snapshot["value"] for snapshot in progress_snapshots] assert progress_values == sorted(progress_values) def test_sync_targets_one_server_without_excluding_other_enabled_servers(monkeypatch): """定向同步只访问目标服务器,缓存清理仍保留其他已启用服务器。""" chain = object.__new__(MediaServerChain) library_calls = [] excluded_server_calls = [] class FakeMediaServerOper: """记录媒体服务器缓存清理参数的测试替身。""" def delete_excluded_servers(self, servers): """记录应保留的全部已启用服务器名称。""" excluded_server_calls.append(servers) chain.librarys = lambda server: library_calls.append(server) or [] monkeypatch.setattr( MEDIA_SERVER_CHAIN_MODULE, "get_chain_media_server_port", FakeMediaServerOper, ) monkeypatch.setattr( MEDIA_SERVER_CHAIN_MODULE, "get_mediaserver_configs", lambda **_kwargs: [ SimpleNamespace(name="plex-a", enabled=True, sync_libraries=["all"]), SimpleNamespace(name="plex-b", enabled=True, sync_libraries=["all"]), ], ) chain.sync(server="plex-a") assert library_calls == ["plex-a"] assert excluded_server_calls == [["plex-a", "plex-b"]] def test_sync_partial_commit_preserves_stale_rows_until_next_run( database, monkeypatch, ): """前序条目提交后发生失败时保留 stale,并由下一轮成功同步收敛。""" old_sync_time = "2026-05-01 00:00:00" with database() as session: session.add(MediaServerItem( server="plex", library="movies", item_id="stale", item_type="电影", title="陈旧条目", lst_mod_date=old_sync_time, )) session.commit() chain = object.__new__(MediaServerChain) stale_calls = [] items = [ schemas.MediaServerItem( server="plex", library="movies", item_id=f"movie-{index}", item_type="Movie", title=f"测试电影 {index}", ) for index in (1, 2) ] transactional = TransactionalMediaServerRepository(database) class FailingMediaServerRepository: """提交首个条目后让第二个条目失败,并记录不应发生的清理。""" def __init__(self): """初始化本轮已尝试写入计数。""" self.upsert_count = 0 def delete_excluded_servers(self, servers): """委托真实短事务清理已移除服务器。""" return transactional.delete_excluded_servers(servers) def upsert(self, item): """提交首条记录,在第二条写入前模拟数据库不可用。""" self.upsert_count += 1 if self.upsert_count == 2: raise RuntimeError("database unavailable") return transactional.upsert(item) def delete_stale(self, **kwargs): """记录不应发生的 stale 清理。""" stale_calls.append(kwargs) return transactional.delete_stale(**kwargs) chain.librarys = lambda _server: [SimpleNamespace(id="movies", name="电影库")] chain.media_count = lambda _server: len(items) chain.items_count = lambda **_kwargs: len(items) chain.items = lambda **_kwargs: iter(items) chain.episodes = lambda *_args, **_kwargs: [] repository = FailingMediaServerRepository() monkeypatch.setattr( MEDIA_SERVER_CHAIN_MODULE, "get_chain_media_server_port", lambda: repository, ) monkeypatch.setattr( MEDIA_SERVER_CHAIN_MODULE, "get_mediaserver_configs", lambda **_kwargs: [ SimpleNamespace(name="plex", enabled=True, sync_libraries=["all"]) ], ) with pytest.raises(RuntimeError, match="database unavailable"): chain.sync() assert stale_calls == [] with database() as session: assert { item.item_id for item in session.query(MediaServerItem).all() } == {"movie-1", "stale"} monkeypatch.setattr( MEDIA_SERVER_CHAIN_MODULE, "get_chain_media_server_port", lambda: transactional, ) chain.sync() with database() as session: assert { item.item_id for item in session.query(MediaServerItem).all() } == {"movie-1", "movie-2"} def test_sync_stops_without_emitting_completion_after_stop_signal(monkeypatch): """系统停止发生在逐库同步期间时,不应再发送服务器或全局完成进度。""" chain = object.__new__(MediaServerChain) server = SimpleNamespace(name="plex", enabled=True) progress = [] class FakeMediaServerOper: """提供同步阶段所需的最小数据库端口。""" def delete_excluded_servers(self, _servers): """忽略测试中的媒体服务器清理。""" def stop_during_sync(**_kwargs): """模拟读取媒体条目时收到系统停止信号。""" global_vars.stop_system() return 0, 0 monkeypatch.setattr( MEDIA_SERVER_CHAIN_MODULE, "get_chain_media_server_port", FakeMediaServerOper, ) monkeypatch.setattr( chain, "_prepare_sync_contexts", lambda _servers, _server, _repository: ( [server], 1, {"plex": ([], {})}, 0, ), ) monkeypatch.setattr(chain, "_sync_server_libraries", stop_during_sync) monkeypatch.setattr( MEDIA_SERVER_CHAIN_MODULE, "get_mediaserver_configs", lambda **_kwargs: [server], ) global_vars.STOP_EVENT.clear() try: chain.sync(progress_callback=lambda **kwargs: progress.append(kwargs)) finally: global_vars.STOP_EVENT.clear() texts = [item.get("text") for item in progress] assert not any(text and "同步完成" in text for text in texts)