fix(mediaserver): sync library data incrementally

This commit is contained in:
jxxghp
2026-05-09 21:18:20 +08:00
parent cd5e693302
commit bc6c61bc45
5 changed files with 411 additions and 10 deletions
+9 -3
View File
@@ -1,4 +1,5 @@
import threading import threading
from datetime import datetime
from typing import List, Union, Optional, Generator, Any from typing import List, Union, Optional, Generator, Any
from app.chain import ChainBase from app.chain import ChainBase
@@ -134,9 +135,10 @@ class MediaServerChain(ChainBase):
with lock: with lock:
# 汇总统计 # 汇总统计
total_count = 0 total_count = 0
# 清空登记薄
dboper = MediaServerOper() dboper = MediaServerOper()
dboper.empty() enabled_servers = [mediaserver.name for mediaserver in mediaservers
if mediaserver and mediaserver.enabled and mediaserver.name]
dboper.delete_excluded_servers(enabled_servers)
# 遍历媒体服务器 # 遍历媒体服务器
for mediaserver in mediaservers: for mediaserver in mediaservers:
if not mediaserver: if not mediaserver:
@@ -152,6 +154,7 @@ class MediaServerChain(ChainBase):
if not libraries: if not libraries:
logger.info(f"没有获取到媒体服务器 {server_name} 的媒体库,跳过") logger.info(f"没有获取到媒体服务器 {server_name} 的媒体库,跳过")
continue continue
sync_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
for library in libraries: for library in libraries:
if sync_libraries \ if sync_libraries \
and "all" not in sync_libraries \ and "all" not in sync_libraries \
@@ -180,8 +183,11 @@ class MediaServerChain(ChainBase):
item_dict = item.model_dump() item_dict = item.model_dump()
item_dict["seasoninfo"] = seasoninfo item_dict["seasoninfo"] = seasoninfo
item_dict["item_type"] = item_type item_dict["item_type"] = item_type
dboper.add(**item_dict) item_dict["lst_mod_date"] = sync_time
dboper.upsert(**item_dict)
logger.info(f"{server_name} 媒体库 {library.name} 同步完成,共同步数量:{library_count}") logger.info(f"{server_name} 媒体库 {library.name} 同步完成,共同步数量:{library_count}")
# 总数累加 # 总数累加
total_count += library_count total_count += library_count
stale_count = dboper.delete_stale(server=server_name, sync_time=sync_time)
logger.info(f"媒体服务器 {server_name} 清理陈旧数据完成,删除数量:{stale_count}")
logger.info(f"媒体服务器 {server_name} 数据同步完成,总同步数量:{total_count}") logger.info(f"媒体服务器 {server_name} 数据同步完成,总同步数量:{total_count}")
+46 -3
View File
@@ -14,24 +14,67 @@ class MediaServerOper(DbOper):
def __init__(self, db: Session = None): def __init__(self, db: Session = None):
super().__init__(db) super().__init__(db)
@staticmethod
def __prepare_payload(kwargs: dict) -> dict:
"""
过滤数据库模型不存在或不应由远端覆盖的字段
"""
return {
k: v for k, v in kwargs.items()
if hasattr(MediaServerItem, k) and k != "id"
}
def add(self, **kwargs) -> bool: def add(self, **kwargs) -> bool:
""" """
新增媒体服务器数据 新增媒体服务器数据
""" """
# MediaServerItem中没有的属性剔除 kwargs = self.__prepare_payload(kwargs)
kwargs = {k: v for k, v in kwargs.items() if hasattr(MediaServerItem, k)} server = kwargs.get("server")
item_id = kwargs.get("item_id")
if not server or not item_id:
return False
item = MediaServerItem(**kwargs) item = MediaServerItem(**kwargs)
if not item.get_by_itemid(self._db, kwargs.get("item_id")): if not item.get_by_server_itemid(self._db, server, item_id):
item.create(self._db) item.create(self._db)
return True return True
return False return False
def upsert(self, **kwargs) -> bool:
"""
按媒体服务器和条目ID新增或更新数据
"""
kwargs = self.__prepare_payload(kwargs)
server = kwargs.get("server")
item_id = kwargs.get("item_id")
if not server or not item_id:
return False
item = MediaServerItem.get_by_server_itemid(self._db, server, item_id)
if item:
item.update(self._db, kwargs)
return False
MediaServerItem(**kwargs).create(self._db)
return True
def empty(self, server: Optional[str] = None): def empty(self, server: Optional[str] = None):
""" """
清空媒体服务器数据 清空媒体服务器数据
""" """
MediaServerItem.empty(self._db, server) MediaServerItem.empty(self._db, server)
def delete_stale(self, server: str, sync_time: str) -> int:
"""
删除本轮同步未更新的旧数据
"""
return MediaServerItem.delete_stale(self._db, server, sync_time)
def delete_excluded_servers(self, servers: list[str]) -> int:
"""
删除未启用或已移除媒体服务器的数据
"""
return MediaServerItem.delete_excluded_servers(self._db, servers)
def exists(self, **kwargs) -> Optional[MediaServerItem]: def exists(self, **kwargs) -> Optional[MediaServerItem]:
""" """
判断媒体服务器数据是否存在 判断媒体服务器数据是否存在
+26 -4
View File
@@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional, List
from sqlalchemy import Column, Integer, String, JSON, Index from sqlalchemy import Column, Integer, String, JSON, Index, or_
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -44,6 +44,7 @@ class MediaServerItem(Base):
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S")) lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
__table_args__ = ( __table_args__ = (
Index('ux_mediaserveritem_server_item_id', 'server', 'item_id', unique=True),
Index('ix_mediaserveritem_tmdbid_item_type', 'tmdbid', 'item_type'), Index('ix_mediaserveritem_tmdbid_item_type', 'tmdbid', 'item_type'),
) )
@@ -52,13 +53,34 @@ class MediaServerItem(Base):
def get_by_itemid(cls, db: Session, item_id: str): def get_by_itemid(cls, db: Session, item_id: str):
return db.query(cls).filter(cls.item_id == item_id).first() return db.query(cls).filter(cls.item_id == item_id).first()
@classmethod
@db_query
def get_by_server_itemid(cls, db: Session, server: str, item_id: str):
return db.query(cls).filter(cls.server == server,
cls.item_id == item_id).first()
@classmethod @classmethod
@db_update @db_update
def empty(cls, db: Session, server: Optional[str] = None): def empty(cls, db: Session, server: Optional[str] = None):
if server is None: if server is None:
db.query(cls).delete() db.query(cls).delete(synchronize_session=False)
else: else:
db.query(cls).filter(cls.server == server).delete() db.query(cls).filter(cls.server == server).delete(synchronize_session=False)
@classmethod
@db_update
def delete_stale(cls, db: Session, server: str, sync_time: str):
return db.query(cls).filter(cls.server == server,
or_(cls.lst_mod_date.is_(None),
cls.lst_mod_date != sync_time)).delete(synchronize_session=False)
@classmethod
@db_update
def delete_excluded_servers(cls, db: Session, servers: List[str]):
if not servers:
return db.query(cls).delete(synchronize_session=False)
return db.query(cls).filter(or_(cls.server.is_(None),
~cls.server.in_(servers))).delete(synchronize_session=False)
@classmethod @classmethod
@db_query @db_query
+87
View File
@@ -0,0 +1,87 @@
"""2.2.5
mediaserveritem 改为按 server + item_id 唯一
Revision ID: b8f6e3a1c2d4
Revises: 93f8cb6a4d1e
Create Date: 2026-05-09
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "b8f6e3a1c2d4"
down_revision = "93f8cb6a4d1e"
branch_labels = None
depends_on = None
TABLE_NAME = "mediaserveritem"
INDEX_NAME = "ux_mediaserveritem_server_item_id"
INDEX_COLUMNS = ["server", "item_id"]
mediaserveritem = sa.table(
TABLE_NAME,
sa.column("id", sa.Integer),
sa.column("server", sa.String),
sa.column("item_id", sa.String),
)
def _table_exists(inspector: sa.Inspector) -> bool:
return TABLE_NAME in inspector.get_table_names()
def _has_index_signature(inspector: sa.Inspector, unique: bool) -> bool:
target_columns = tuple(INDEX_COLUMNS)
for index in inspector.get_indexes(TABLE_NAME):
if tuple(index.get("column_names") or []) == target_columns and bool(index.get("unique")) == unique:
return True
return False
def _drop_index_if_exists(inspector: sa.Inspector) -> None:
for index in inspector.get_indexes(TABLE_NAME):
if index.get("name") == INDEX_NAME:
op.drop_index(INDEX_NAME, table_name=TABLE_NAME)
return
def _deduplicate_rows() -> None:
bind = op.get_bind()
keep_ids = (
sa.select(sa.func.max(mediaserveritem.c.id))
.where(
mediaserveritem.c.server.is_not(None),
mediaserveritem.c.item_id.is_not(None),
)
.group_by(mediaserveritem.c.server, mediaserveritem.c.item_id)
)
bind.execute(
mediaserveritem.delete().where(
sa.and_(
mediaserveritem.c.server.is_not(None),
mediaserveritem.c.item_id.is_not(None),
mediaserveritem.c.id.not_in(keep_ids),
)
)
)
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not _table_exists(inspector):
return
_deduplicate_rows()
inspector = sa.inspect(op.get_bind())
if not _has_index_signature(inspector, unique=True):
op.create_index(INDEX_NAME, TABLE_NAME, INDEX_COLUMNS, unique=True)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not _table_exists(inspector):
return
_drop_index_if_exists(inspector)
+243
View File
@@ -0,0 +1,243 @@
import importlib.util
import sqlite3
import sys
import tempfile
import types
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
if "psutil" not in sys.modules:
sys.modules["psutil"] = types.ModuleType("psutil")
if "aiosqlite" not in sys.modules:
aiosqlite_module = types.ModuleType("aiosqlite")
for attr in (
"DatabaseError",
"Error",
"IntegrityError",
"InterfaceError",
"InternalError",
"NotSupportedError",
"OperationalError",
"ProgrammingError",
"sqlite_version",
"sqlite_version_info",
):
setattr(aiosqlite_module, attr, getattr(sqlite3, attr))
aiosqlite_module.connect = sqlite3.connect
aiosqlite_module.paramstyle = "qmark"
aiosqlite_module.threadsafety = sqlite3.threadsafety
sys.modules["aiosqlite"] = aiosqlite_module
if "app.log" not in sys.modules:
log_module = types.ModuleType("app.log")
class _Logger:
def info(self, *_args, **_kwargs):
return None
def debug(self, *_args, **_kwargs):
return None
def warning(self, *_args, **_kwargs):
return None
def error(self, *_args, **_kwargs):
return None
log_module.logger = _Logger()
log_module.log_settings = SimpleNamespace()
log_module.LogConfigModel = type("LogConfigModel", (), {})
sys.modules["app.log"] = log_module
from app import schemas
from app.db import Base
from app.db.mediaserver_oper import MediaServerOper
from app.db.models.mediaserver import MediaServerItem
def _load_mediaserver_chain_class():
"""隔离加载 MediaServerChain,避免测试依赖完整运行时环境。"""
module_name = "_test_mediaserver_chain"
if module_name in sys.modules:
module = sys.modules[module_name]
return module, module.MediaServerChain
if "app.chain" not in sys.modules:
chain_module = types.ModuleType("app.chain")
chain_module.ChainBase = type("ChainBase", (), {})
sys.modules["app.chain"] = chain_module
if "app.core.config" not in sys.modules:
config_module = types.ModuleType("app.core.config")
config_module.global_vars = SimpleNamespace(is_system_stopped=False)
sys.modules["app.core.config"] = config_module
if "app.helper.service" not in sys.modules:
service_module = types.ModuleType("app.helper.service")
class _ServiceConfigHelper:
@staticmethod
def get_mediaserver_configs():
return []
service_module.ServiceConfigHelper = _ServiceConfigHelper
sys.modules["app.helper.service"] = service_module
mediaserver_path = Path(__file__).resolve().parents[1] / "app" / "chain" / "mediaserver.py"
spec = importlib.util.spec_from_file_location(module_name, mediaserver_path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
assert spec and spec.loader
spec.loader.exec_module(module)
return module, module.MediaServerChain
MEDIA_SERVER_CHAIN_MODULE, MediaServerChain = _load_mediaserver_chain_class()
class MediaServerIncrementalSyncTest(unittest.TestCase):
"""验证媒体库同步改为按条目增量更新。"""
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
db_path = Path(self.temp_dir.name) / "mediaserver.db"
self.engine = create_engine(f"sqlite:///{db_path}")
self.SessionFactory = sessionmaker(bind=self.engine)
Base.metadata.create_all(bind=self.engine)
def tearDown(self):
self.engine.dispose()
self.temp_dir.cleanup()
def test_add_allows_same_item_id_across_servers(self):
"""不同媒体服务器允许复用相同 item_id。"""
with self.SessionFactory() as db:
oper = MediaServerOper(db)
self.assertTrue(
oper.add(
server="plex",
library="movies",
item_id="same-item-id",
item_type="电影",
title="Movie A",
)
)
self.assertTrue(
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()
)
self.assertEqual(len(items), 2)
self.assertEqual([item.server for item in items], ["jellyfin", "plex"])
def test_sync_updates_rows_and_removes_stale_entries(self):
"""同步应更新已存在条目,并清理未再出现或已移除服务的数据。"""
old_sync_time = "2026-05-01 00:00:00"
with self.SessionFactory() 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="电影库")]
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("app.db.ScopedSession", self.SessionFactory), patch.object(
MEDIA_SERVER_CHAIN_MODULE.ServiceConfigHelper,
"get_mediaserver_configs",
return_value=[SimpleNamespace(name="plex", enabled=True, sync_libraries=["all"])],
):
chain.sync()
with self.SessionFactory() as db:
items = (
db.query(MediaServerItem)
.order_by(MediaServerItem.server.asc(), MediaServerItem.item_id.asc())
.all()
)
self.assertEqual(len(items), 1)
self.assertEqual(items[0].id, existing_id)
self.assertEqual(items[0].server, "plex")
self.assertEqual(items[0].item_id, "/library/metadata/1")
self.assertEqual(items[0].item_type, "电影")
self.assertEqual(items[0].title, "New Title")
self.assertEqual(items[0].path, "/media/new.mkv")
self.assertNotEqual(items[0].lst_mod_date, old_sync_time)
if __name__ == "__main__":
unittest.main()