Files
MoviePilot/tests/test_mediaserver_sync_incremental.py
Aqr-K bf4ef4214e perf(mediaserver): add caller-controlled query projections (#7068)
* perf(mediaserver): add caller-controlled query projections

* test(mediaserver): cover query projection option contracts

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-19 08:33:45 +08:00

682 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
验证媒体服务器增量同步的身份隔离、统计和类型归一。
"""
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", "音乐"),
("artist", "音乐"),
("album", "音乐"),
("track", "音乐"),
("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, **_kwargs: [SimpleNamespace(id="music", name="音乐", item_count=1)]
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("音乐条目不应查询电视剧分集")
chain.media_server_repository = TransactionalMediaServerRepository(database)
with 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, **_kwargs: [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),
]
chain.media_server_repository = TransactionalMediaServerRepository(database)
with 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, **_kwargs: [
SimpleNamespace(id="movies", name="电影库", item_count=1),
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: []
chain.media_server_repository = TransactionalMediaServerRepository(database)
with 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_reuses_library_counts_before_items_and_reports_media_progress(database):
"""
同步前应查询全部目标媒体库总数,并按媒体条目更新进度。
"""
chain = object.__new__(MediaServerChain)
events = []
progress_snapshots = []
server_libraries = {
"plex-a": [SimpleNamespace(id="movies", name="电影库", item_count=1)],
"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",
)
],
}
def librarys(server, **_kwargs):
"""
返回已经带数量的目录,不允许同步链重复进行整服或逐库统计。
:param server: 合成媒体服务器名称
:param _kwargs: 本次库目录查询的投影与范围选项
:return: 包含精确条目数的库目录
"""
events.append(f"count:{server}")
for library in server_libraries[server]:
library.item_count = len(library_items[(server, library.id)])
return server_libraries[server]
chain.librarys = librarys
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 = lambda _server: pytest.fail("不应重复查询整服统计")
chain.items_count = lambda **_kwargs: pytest.fail("整服同步不应逐库重复计数")
chain.items = items
chain.episodes = lambda *_args, **_kwargs: []
chain.media_server_repository = TransactionalMediaServerRepository(database)
with 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, **_kwargs: library_calls.append(server) or []
chain.media_server_repository = 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"]]
@pytest.mark.parametrize("failure", [TimeoutError("read timeout"), ConnectionError("connection refused")])
def test_sync_preserves_cached_rows_when_plex_read_fails(database, failure):
"""
Plex 读取中断时,应保留上一轮缓存且不报告同步完成。
"""
with database() as session:
session.add(
MediaServerItem(
server="plex",
library="movies",
item_id="not-reached-before-timeout",
item_type="电影",
title="已有媒体",
lst_mod_date="2026-05-01 00:00:00",
)
)
session.commit()
chain = object.__new__(MediaServerChain)
chain.librarys = lambda _server, **_kwargs: [SimpleNamespace(id="movies", name="电影库", item_count=1)]
chain.media_count = lambda _server: 1
chain.items_count = lambda **_kwargs: pytest.fail("目录已有数量时不应重复计数")
def items_with_failure(**_kwargs):
"""
模拟 Plex 已返回部分条目后,下一次分页读取失败。
"""
yield schemas.MediaServerItem(
server="plex",
library="movies",
item_id="received-before-timeout",
item_type="Movie",
title="已读取媒体",
)
raise failure
chain.items = items_with_failure
chain.episodes = lambda *_args, **_kwargs: []
chain.media_server_repository = TransactionalMediaServerRepository(database)
progress = []
with (
patch.object(
MEDIA_SERVER_CHAIN_MODULE,
"get_mediaserver_configs",
return_value=[SimpleNamespace(name="plex", enabled=True, sync_libraries=["all"])],
),
pytest.raises(type(failure), match=str(failure)),
):
chain.sync(progress_callback=lambda **kwargs: progress.append(kwargs))
with database() as session:
item_ids = {
item.item_id for item in session.query(MediaServerItem).filter(MediaServerItem.server == "plex").all()
}
assert item_ids == {"not-reached-before-timeout", "received-before-timeout"}
assert not any("同步完成" in entry.get("text", "") for entry in progress)
# 前端新增媒体服务器的默认名称为「服务器N」,与模块条目携带的类型名不同
_CUSTOM_NAMED_JELLYFIN = [SimpleNamespace(name="服务器1", enabled=True, sync_libraries=["all"])]
def _custom_named_jellyfin_chain(database, items):
"""
构造配置名称与模块类型名不同的 Jellyfin 同步链。
"""
chain = object.__new__(MediaServerChain)
chain.librarys = lambda _server, **_kwargs: [SimpleNamespace(id="movies", name="电影库", item_count=1)]
chain.media_count = lambda _server: 1
chain.items_count = lambda **_kwargs: pytest.fail("目录已有数量时不应重复计数")
chain.items = items
chain.episodes = lambda *_args, **_kwargs: []
chain.media_server_repository = TransactionalMediaServerRepository(database)
return chain
def _jellyfin_movie_items(**_kwargs):
"""
模拟 Jellyfin 模块返回的条目,server 字段为类型名而非配置名称。
"""
yield schemas.MediaServerItem(
server="jellyfin",
library="movies",
item_id="movie-1",
item_type="Movie",
title="已入库电影",
year="2026",
)
def test_sync_stores_rows_under_config_name_for_existence_lookup(database):
"""
缓存必须按配置名称写入,存在性检查按配置名称查询才能命中。
"""
chain = _custom_named_jellyfin_chain(database, _jellyfin_movie_items)
with patch.object(MEDIA_SERVER_CHAIN_MODULE, "get_mediaserver_configs", return_value=_CUSTOM_NAMED_JELLYFIN):
chain.sync()
with database() as session:
servers = {item.server for item in session.query(MediaServerItem).all()}
assert servers == {"服务器1"}
assert chain.media_server_repository.get_item_id(
server="服务器1", title="已入库电影", year="2026", mtype="电影",
) == "movie-1"
def test_sync_keeps_custom_named_server_cache_when_read_fails(database):
"""
配置名称与类型名不同时,后续读取失败也不得清空上一轮缓存。
"""
chain = _custom_named_jellyfin_chain(database, _jellyfin_movie_items)
def items_with_timeout(**_kwargs):
"""
模拟条目列表请求超时。
"""
yield from ()
raise TimeoutError("read timeout")
with patch.object(MEDIA_SERVER_CHAIN_MODULE, "get_mediaserver_configs", return_value=_CUSTOM_NAMED_JELLYFIN):
chain.sync()
chain.items = items_with_timeout
with pytest.raises(TimeoutError, match="read timeout"):
chain.sync()
with database() as session:
rows = [(item.server, item.item_id) for item in session.query(MediaServerItem).all()]
assert rows == [("服务器1", "movie-1")]
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, **_kwargs: [SimpleNamespace(id="movies", name="电影库", item_count=1)]
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()
chain.media_server_repository = 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"}
chain.media_server_repository = 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
chain.media_server_repository = 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)