diff --git a/app/application/mediaserver.py b/app/application/mediaserver.py index 26a1afded..f245bf1d4 100644 --- a/app/application/mediaserver.py +++ b/app/application/mediaserver.py @@ -1,9 +1,12 @@ import re from collections.abc import Iterable, Mapping +from datetime import datetime from typing import Any, Optional, Protocol from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem +from app.schemas.mediaserver import MediaServerItemUserState as _SchemaMediaServerItemUserState from app.domain.context import MusicInfo +from app.runtime.log import logger from app.schemas.media import normalize_media_source, resolve_media_identity from app.application.service import ServiceBaseHelper from app.schemas.system import MediaServerConf @@ -284,6 +287,61 @@ class MusicMediaServerHelper: ) +def format_emby_family_item( + item: Mapping[str, Any], + *, + server: str, + include_server_id: bool = False, +) -> Optional[_SchemaMediaServerItem]: + """把 Emby 系服务条目转换为统一媒体服务器模型,并保留各服务既有字段差异。""" + try: + user_data = item.get("UserData") or {} + if not user_data: + user_state = None + else: + resume = ( + user_data.get("PlaybackPositionTicks") + and user_data.get("PlaybackPositionTicks") > 0 + ) + last_played_date = user_data.get("LastPlayedDate") + if last_played_date is not None and "." in last_played_date: + last_played_date = last_played_date.split(".")[0] + user_state = _SchemaMediaServerItemUserState( + played=user_data.get("Played"), + resume=resume, + last_played_date=datetime.strptime( + last_played_date, + "%Y-%m-%dT%H:%M:%S", + ).strftime("%Y-%m-%d %H:%M:%S") if last_played_date else None, + play_count=user_data.get("PlayCount"), + percentage=user_data.get("PlayedPercentage"), + ) + media_source, media_id = MediaServerIdentityHelper.from_provider_ids( + item.get("ProviderIds") + ) + fields = { + "server": server, + "library": item.get("ParentId"), + "item_id": item.get("Id"), + "item_type": item.get("Type"), + "title": item.get("Name"), + "original_title": item.get("OriginalTitle"), + "year": item.get("ProductionYear"), + "media_source": media_source, + "media_id": media_id, + "path": item.get("Path"), + "note": MusicMediaServerHelper.build_note(item) + if item.get("Type") in {"MusicAlbum", "Audio"} else None, + "user_state": user_state, + } + if include_server_id: + fields["server_id"] = item.get("ServerId") + return _SchemaMediaServerItem(**fields) + except Exception as error: + logger.error(error) + return None + + class MediaServerHelper(ServiceBaseHelper[MediaServerConf]): """管理媒体服务器配置,并按类型发现已启用的服务实例。""" diff --git a/app/modules/emby/emby.py b/app/modules/emby/emby.py index a055ad0ca..2e7ff7bcc 100644 --- a/app/modules/emby/emby.py +++ b/app/modules/emby/emby.py @@ -1,7 +1,6 @@ import json import re import traceback -from datetime import datetime from pathlib import Path from typing import List, Optional, Union, Dict, Generator, Tuple, Any @@ -9,7 +8,6 @@ from requests import Response from app.schemas.dashboard import Statistic as _SchemaStatistic from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem -from app.schemas.mediaserver import MediaServerItemUserState as _SchemaMediaServerItemUserState from app.schemas.mediaserver import MediaServerLibrary as _SchemaMediaServerLibrary from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem from app.schemas.mediaserver import RefreshMediaItem as _SchemaRefreshMediaItem @@ -17,7 +15,7 @@ from app.schemas.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo from app.runtime.settings import RuntimeSettingsCompat settings = RuntimeSettingsCompat() -from app.application.mediaserver import MediaServerIdentityHelper, MusicMediaServerHelper +from app.application.mediaserver import MediaServerIdentityHelper, format_emby_family_item from app.runtime.log import logger from app.schemas.mediaserver import MediaServerItem from app.schemas.types import MediaSource, MediaType @@ -731,50 +729,12 @@ class Emby: @staticmethod def __format_item_info(item) -> Optional[_SchemaMediaServerItem]: - """ - 格式化item - """ - try: - user_data = item.get("UserData", {}) - if not user_data: - user_state = None - else: - resume = item.get("UserData", {}).get("PlaybackPositionTicks") and item.get("UserData", {}).get( - "PlaybackPositionTicks") > 0 - last_played_date = item.get("UserData", {}).get("LastPlayedDate") - if last_played_date is not None and "." in last_played_date: - last_played_date = last_played_date.split(".")[0] - user_state = _SchemaMediaServerItemUserState( - played=item.get("UserData", {}).get("Played"), - resume=resume, - last_played_date=datetime.strptime(last_played_date, "%Y-%m-%dT%H:%M:%S").strftime( - "%Y-%m-%d %H:%M:%S") if last_played_date else None, - play_count=item.get("UserData", {}).get("PlayCount"), - percentage=item.get("UserData", {}).get("PlayedPercentage"), - ) - media_source, media_id = MediaServerIdentityHelper.from_provider_ids( - item.get("ProviderIds") - ) - return _SchemaMediaServerItem( - server="emby", - library=item.get("ParentId"), - server_id=item.get("ServerId"), - item_id=item.get("Id"), - item_type=item.get("Type"), - title=item.get("Name"), - original_title=item.get("OriginalTitle"), - year=item.get("ProductionYear"), - media_source=media_source, - media_id=media_id, - path=item.get("Path"), - note=MusicMediaServerHelper.build_note(item) - if item.get("Type") in {"MusicAlbum", "Audio"} else None, - user_state=user_state - - ) - except Exception as e: - logger.error(e) - return None + """通过统一 Emby 系转换契约生成条目,并保留 Emby 的服务器 ID。""" + return format_emby_family_item( + item, + server="emby", + include_server_id=True, + ) def get_iteminfo(self, itemid: str) -> Optional[_SchemaMediaServerItem]: """ diff --git a/app/modules/jellyfin/jellyfin.py b/app/modules/jellyfin/jellyfin.py index 037feae72..f3e2645c0 100644 --- a/app/modules/jellyfin/jellyfin.py +++ b/app/modules/jellyfin/jellyfin.py @@ -1,20 +1,18 @@ import json import posixpath -from datetime import datetime from typing import List, Union, Optional, Dict, Generator, Tuple, Any from requests import Response from app.schemas.dashboard import Statistic as _SchemaStatistic from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem -from app.schemas.mediaserver import MediaServerItemUserState as _SchemaMediaServerItemUserState from app.schemas.mediaserver import MediaServerLibrary as _SchemaMediaServerLibrary from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem from app.schemas.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo from app.runtime.settings import RuntimeSettingsCompat settings = RuntimeSettingsCompat() -from app.application.mediaserver import MediaServerIdentityHelper, MusicMediaServerHelper +from app.application.mediaserver import MediaServerIdentityHelper, format_emby_family_item from app.runtime.log import logger from app.schemas.types import MediaType from app.schemas.types import MediaSource @@ -889,49 +887,8 @@ class Jellyfin: @staticmethod def __format_item_info(item) -> Optional[_SchemaMediaServerItem]: - """ - 格式化item - """ - try: - user_data = item.get("UserData", {}) - if not user_data: - user_state = None - else: - resume = item.get("UserData", {}).get("PlaybackPositionTicks") and item.get("UserData", {}).get( - "PlaybackPositionTicks") > 0 - last_played_date = item.get("UserData", {}).get("LastPlayedDate") - if last_played_date is not None and "." in last_played_date: - last_played_date = last_played_date.split(".")[0] - user_state = _SchemaMediaServerItemUserState( - played=item.get("UserData", {}).get("Played"), - resume=resume, - last_played_date=datetime.strptime(last_played_date, "%Y-%m-%dT%H:%M:%S").strftime( - "%Y-%m-%d %H:%M:%S") if last_played_date else None, - play_count=item.get("UserData", {}).get("PlayCount"), - percentage=item.get("UserData", {}).get("PlayedPercentage"), - ) - media_source, media_id = MediaServerIdentityHelper.from_provider_ids( - item.get("ProviderIds") - ) - return _SchemaMediaServerItem( - server="jellyfin", - library=item.get("ParentId"), - item_id=item.get("Id"), - item_type=item.get("Type"), - title=item.get("Name"), - original_title=item.get("OriginalTitle"), - year=item.get("ProductionYear"), - media_source=media_source, - media_id=media_id, - path=item.get("Path"), - note=MusicMediaServerHelper.build_note(item) - if item.get("Type") in {"MusicAlbum", "Audio"} else None, - user_state=user_state - - ) - except Exception as e: - logger.error(e) - return None + """通过统一 Emby 系转换契约生成 Jellyfin 条目。""" + return format_emby_family_item(item, server="jellyfin") def get_iteminfo(self, itemid: str) -> Optional[_SchemaMediaServerItem]: """ diff --git a/app/modules/zspace/zspace.py b/app/modules/zspace/zspace.py index 96866230f..63e611e0d 100644 --- a/app/modules/zspace/zspace.py +++ b/app/modules/zspace/zspace.py @@ -1,7 +1,6 @@ import json import re import traceback -from datetime import datetime from pathlib import Path from typing import List, Optional, Union, Dict, Generator, Tuple, Any @@ -9,12 +8,11 @@ from requests import Response from app.schemas.dashboard import Statistic as _SchemaStatistic from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem -from app.schemas.mediaserver import MediaServerItemUserState as _SchemaMediaServerItemUserState from app.schemas.mediaserver import MediaServerLibrary as _SchemaMediaServerLibrary from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem from app.schemas.mediaserver import RefreshMediaItem as _SchemaRefreshMediaItem from app.schemas.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo -from app.application.mediaserver import MediaServerIdentityHelper, MusicMediaServerHelper +from app.application.mediaserver import MediaServerIdentityHelper, format_emby_family_item from app.runtime.log import logger from app.schemas.mediaserver import MediaServerItem from app.schemas.types import MediaSource, MediaType @@ -813,49 +811,8 @@ class ZSpace: @staticmethod def __format_item_info(item) -> Optional[_SchemaMediaServerItem]: - """ - 格式化item - """ - try: - user_data = item.get("UserData", {}) - if not user_data: - user_state = None - else: - resume = item.get("UserData", {}).get("PlaybackPositionTicks") and item.get("UserData", {}).get( - "PlaybackPositionTicks") > 0 - last_played_date = item.get("UserData", {}).get("LastPlayedDate") - if last_played_date is not None and "." in last_played_date: - last_played_date = last_played_date.split(".")[0] - user_state = _SchemaMediaServerItemUserState( - played=item.get("UserData", {}).get("Played"), - resume=resume, - last_played_date=datetime.strptime(last_played_date, "%Y-%m-%dT%H:%M:%S").strftime( - "%Y-%m-%d %H:%M:%S") if last_played_date else None, - play_count=item.get("UserData", {}).get("PlayCount"), - percentage=item.get("UserData", {}).get("PlayedPercentage"), - ) - media_source, media_id = MediaServerIdentityHelper.from_provider_ids( - item.get("ProviderIds") - ) - return _SchemaMediaServerItem( - server="zspace", - library=item.get("ParentId"), - item_id=item.get("Id"), - item_type=item.get("Type"), - title=item.get("Name"), - original_title=item.get("OriginalTitle"), - year=item.get("ProductionYear"), - media_source=media_source, - media_id=media_id, - path=item.get("Path"), - note=MusicMediaServerHelper.build_note(item) - if item.get("Type") in {"MusicAlbum", "Audio"} else None, - user_state=user_state - - ) - except Exception as e: - logger.error(e) - return None + """通过统一 Emby 系转换契约生成极影视条目。""" + return format_emby_family_item(item, server="zspace") def get_iteminfo(self, itemid: str) -> Optional[_SchemaMediaServerItem]: """ diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index edb4cdccc..2504d0a2d 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -6,7 +6,7 @@ > 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本 > 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文 > 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md` -> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界;阶段 5 已补齐事件窗口聚合任务的生命周期所有权;阶段 6 已统一插件文件操作的取消完成语义;阶段 7 已统一插件协程补偿的终态等待;阶段 8 已统一宿主同步函数的异步线程池入口;阶段 9 已统一工作流运行时的宿主获取路径;阶段 10 已统一模块、插件与调度运行时的显式 getter 调用;阶段 11 已清除系统配置 getter 的 Oper 形别名;阶段 12 已完成工作流域的显式 Chain 数据端口迁移;阶段 13 已收口用户、交互与消息链的数据端口;阶段 14 已收口音乐订阅数据端口;阶段 15 已收口站点数据端口;阶段 16 已收口媒体服务器数据端口;阶段 17 已收口下载数据端口;阶段 18 已收口主订阅数据端口;阶段 19 已收口整理数据端口;阶段 20 已收口 Agent 数据端口;阶段 21 已收口监控历史端口;阶段 22 已统一服务配置应用边界;阶段 23 已补齐媒体服务器 API 遗留的类形配置读取路径;阶段 24 已清除 Scheduler 内部无 owner 的协程提交双轨;阶段 25 已补齐 TaskRegistry 跨线程 owner 并迁移整理 AI 接管;阶段 26 已统一 Agent 会话清理提交;阶段 27 已统一历史 AI 进度 owner;阶段 28 已托管旧插件订阅统计线程。 +> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界;阶段 5 已补齐事件窗口聚合任务的生命周期所有权;阶段 6 已统一插件文件操作的取消完成语义;阶段 7 已统一插件协程补偿的终态等待;阶段 8 已统一宿主同步函数的异步线程池入口;阶段 9 已统一工作流运行时的宿主获取路径;阶段 10 已统一模块、插件与调度运行时的显式 getter 调用;阶段 11 已清除系统配置 getter 的 Oper 形别名;阶段 12 已完成工作流域的显式 Chain 数据端口迁移;阶段 13 已收口用户、交互与消息链的数据端口;阶段 14 已收口音乐订阅数据端口;阶段 15 已收口站点数据端口;阶段 16 已收口媒体服务器数据端口;阶段 17 已收口下载数据端口;阶段 18 已收口主订阅数据端口;阶段 19 已收口整理数据端口;阶段 20 已收口 Agent 数据端口;阶段 21 已收口监控历史端口;阶段 22 已统一服务配置应用边界;阶段 23 已补齐媒体服务器 API 遗留的类形配置读取路径;阶段 24 已清除 Scheduler 内部无 owner 的协程提交双轨;阶段 25 已补齐 TaskRegistry 跨线程 owner 并迁移整理 AI 接管;阶段 26 已统一 Agent 会话清理提交;阶段 27 已统一历史 AI 进度 owner;阶段 28 已托管旧插件订阅统计线程;阶段 29 已统一 Emby 系条目转换并清零重复代码白名单。 ## 当前复核结论(2026-08-24) @@ -299,13 +299,24 @@ `app.adapters.external.server -> app.runtime.config/tasks` 两条允许边,模块保持 `806`、内部边为 `6543`, 12 组禁止边与唯一隔离 TMDB SCC 均未变化。 +### 长期整改阶段 29:Emby 系条目转换协议统一(2026-08-24) + +- Emby、Jellyfin 与极影视原先各自维护一套用户播放状态、Provider ID、音乐备注和媒体服务器条目投影; + Jellyfin/极影视的同构函数还占用了重复代码门禁中最后一组“待后续 Phase 清理”白名单。 +- 转换规则现统一由 `app.application.mediaserver.format_emby_family_item()` 所有,三个服务类只保留带各自 + `server` 标识的薄适配器;Emby 独有的 `ServerId` 投影继续显式开启,Jellyfin/极影视仍保持不投影该字段。 +- 三个私有静态入口的名称、参数与返回类型均保留,服务查询、统一媒体身份和音乐匹配行为不变;插件仓、 + SDK/Compat 映射和 V1/V2/V3 插件公开合同均未修改。重复代码白名单因此清零,后续新增同构实现会直接失败。 +- 依赖基线只新增 `app.application.mediaserver -> app.runtime.log` 及父包边,用于保持原有转换异常日志;模块 + 保持 `806`、内部边为 `6545`,12 组禁止边与唯一隔离 TMDB SCC 均未变化。 + ### 总体判断 当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**: - 继续采用单进程控制面是正确选择,不建议现在拆成微服务;插件、调度器、工作流、事件和数据库共享进程内状态,拆分会放大部署、事务和兼容成本。 - `foundation/domain/runtime/adapters/application/chain/api/startup` 的职责方向基本成立;宿主架构基线、复杂度 ratchet、异步阻塞 ratchet 当前均通过。 -- 依赖图当前为 `806` 个 Python 模块、`6543` 条内部导入边;唯一非平凡 SCC 位于隔离的 TMDB 第三方移植包内部,不应为了指标归零重写。 +- 依赖图当前为 `806` 个 Python 模块、`6545` 条内部导入边;唯一非平凡 SCC 位于隔离的 TMDB 第三方移植包内部,不应为了指标归零重写。 - 当前主要风险已经从“目录和依赖失控”转移到运行时协议、后台副作用的可靠性和遗留兼容面。换言之,下一阶段重点应是**语义收口和可验证性**,而不是继续搬文件或机械拆大文件。 综合评价:架构方向可持续,生产可用性较高;可演进性仍处于中等水平。现阶段没有静态审计发现必须立即推倒重来的 P0 架构问题,但存在需要按 P1/P2 计划治理的真实债务。 diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index e740f3eb7..d83285045 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -13,8 +13,8 @@ "runtime_to_db": [], "workflow_to_db": [] }, - "edge_count": 6543, - "edge_sha256": "b9b6ba268382e7027f5557643defb09848c054cb68079345f2394d9b7faeb42c", + "edge_count": 6545, + "edge_sha256": "5a57ed0ff52fda67abf49f5ca956fea0adeb0ac595054e0f8f81374ad843a2bd", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -2595,6 +2595,8 @@ "app.application.mediaserver -> app.application.service", "app.application.mediaserver -> app.domain", "app.application.mediaserver -> app.domain.context", + "app.application.mediaserver -> app.runtime", + "app.application.mediaserver -> app.runtime.log", "app.application.mediaserver -> app.schemas", "app.application.mediaserver -> app.schemas.media", "app.application.mediaserver -> app.schemas.mediaserver", diff --git a/tests/test_duplicate_code.py b/tests/test_duplicate_code.py index b78d2f31c..d366c7a5a 100644 --- a/tests/test_duplicate_code.py +++ b/tests/test_duplicate_code.py @@ -17,11 +17,7 @@ MIN_FINGERPRINT_SIZE = 1000 # 参与告警的最小函数体节点数:过滤 setter/getter 等小函数。 MIN_FUNCTION_SIZE = 40 # 存量白名单:(模块名, 函数名) 集合,各 Phase 清理后同步移除。 -KNOWN_DUPLICATES = { - # 服务实现类(非模块类)的条目信息格式化样板(待后续 Phase 清理)。 - ("app.modules.jellyfin.jellyfin", "__format_item_info"), - ("app.modules.zspace.zspace", "__format_item_info"), -} +KNOWN_DUPLICATES: set[tuple[str, str]] = set() def _normalize(node: ast.AST) -> str: diff --git a/tests/test_music_mediaserver.py b/tests/test_music_mediaserver.py index c919c0f2a..b1ec362ef 100644 --- a/tests/test_music_mediaserver.py +++ b/tests/test_music_mediaserver.py @@ -14,8 +14,8 @@ from app.modules.jellyfin import JellyfinModule from app.modules.jellyfin.jellyfin import Jellyfin from app.modules.plex import PlexModule from app.modules.plex.plex import Plex -from app.modules.trimemedia import TrimeMediaModule -from app.modules.ugreen import UgreenModule +from app.modules.trimemedia import TrimeMediaModule # pylint: disable=no-name-in-module +from app.modules.ugreen import UgreenModule # pylint: disable=no-name-in-module from app.modules.zspace import ZSpaceModule from app.modules.zspace.zspace import ZSpace @@ -97,25 +97,42 @@ def test_music_media_server_helper_normalizes_emby_music_fields(): @pytest.mark.parametrize( - "formatter", + ("formatter", "server", "server_id"), [ - Emby._Emby__format_item_info, - Jellyfin._Jellyfin__format_item_info, - ZSpace._ZSpace__format_item_info, + (Emby._Emby__format_item_info, "emby", "server-1"), + (Jellyfin._Jellyfin__format_item_info, "jellyfin", None), + (ZSpace._ZSpace__format_item_info, "zspace", None), ], ) -def test_emby_family_clients_preserve_music_match_fields(formatter): - """Emby 系客户端格式化音乐结果时必须保留艺术家、专辑和曲目数。""" +def test_emby_family_clients_preserve_item_contract(formatter, server, server_id): + """Emby 系客户端复用统一转换时必须保留服务身份及音乐匹配字段。""" item = formatter({ "Id": "album-1", + "ServerId": "server-1", + "ParentId": "library-1", "Type": "MusicAlbum", "Name": "叶惠美", "AlbumArtists": [{"Name": "周杰伦"}], "ChildCount": 11, - "ProviderIds": {}, + "ProviderIds": {"Tmdb": "123"}, + "UserData": { + "Played": False, + "PlaybackPositionTicks": 1, + "LastPlayedDate": "2026-08-24T12:34:56.123456Z", + "PlayCount": 2, + "PlayedPercentage": 25.5, + }, }) assert item is not None + assert item.server == server + assert item.server_id == server_id + assert item.library == "library-1" + assert item.media_source == "themoviedb" + assert item.media_id == "123" + assert item.user_state is not None + assert item.user_state.resume is True + assert item.user_state.last_played_date == "2026-08-24 12:34:56" assert item.note == { "artist": "周杰伦", "artists": ["周杰伦"],