refactor: unify emby family item formatting

This commit is contained in:
jxxghp
2026-08-24 06:08:04 +08:00
parent 073ff7ff81
commit 1047bfa821
8 changed files with 115 additions and 157 deletions
+58
View File
@@ -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]):
"""管理媒体服务器配置,并按类型发现已启用的服务实例。"""
+7 -47
View File
@@ -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]:
"""
+3 -46
View File
@@ -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]:
"""
+3 -46
View File
@@ -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]:
"""
@@ -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 计划治理的真实债务。
+4 -2
View File
@@ -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",
+1 -5
View File
@@ -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:
+26 -9
View File
@@ -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": ["周杰伦"],