fix(music): separate album and recording subscriptions

This commit is contained in:
jxxghp
2026-08-09 23:30:31 +08:00
parent aa4023e0a5
commit 5572b2dc64
12 changed files with 520 additions and 58 deletions
+47 -8
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app import schemas from app import schemas
from app.chain.subscribe import SubscribeChain from app.chain.subscribe import SubscribeChain
from app.core.config import settings from app.core.config import settings
from app.core.context import MediaInfo from app.core.context import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaInfo
from app.core.event import eventmanager from app.core.event import eventmanager
from app.core.metainfo import MetaInfo from app.core.metainfo import MetaInfo
from app.core.security import verify_token, verify_apitoken from app.core.security import verify_token, verify_apitoken
@@ -105,16 +105,38 @@ def select_accessible_subscribe(
return None return None
def matches_subscribe_music_type(
subscribe: Subscribe,
music_type: Optional[str],
) -> bool:
"""匹配订阅音乐实体,并把迁移前未标注类型的历史记录兼容为单曲。"""
if not music_type:
return True
subscribe_music_type = getattr(subscribe, "music_type", None)
return subscribe_music_type == music_type \
or (music_type == MUSIC_ENTITY_RECORDING and subscribe_music_type is None)
async def list_subscribes_by_media_key( async def list_subscribes_by_media_key(
db: AsyncSession, media_key: str, season: Optional[int] = None, db: AsyncSession,
media_key: str,
season: Optional[int] = None,
music_type: Optional[str] = None,
) -> List[Subscribe]: ) -> List[Subscribe]:
"""按统一媒体键查询订阅,并兼容迁移前的专用 ID 字段。""" """按统一媒体键及音乐实体查询订阅,并兼容迁移前的专用 ID 字段。"""
source, media_id = parse_media_key(media_key) source, media_id = parse_media_key(media_key)
if not source or not media_id: if not source or not media_id:
return await Subscribe.async_list_by_mediaid(db, media_key) subscribes = list(await Subscribe.async_list_by_mediaid(db, media_key))
return [
subscribe for subscribe in subscribes
if matches_subscribe_music_type(subscribe, music_type)
]
subscribes = list(await Subscribe.async_list_by_media_identity( subscribes = list(await Subscribe.async_list_by_media_identity(
db, media_source=source, media_id=media_id db,
media_source=source,
media_id=media_id,
music_type=music_type,
)) ))
if source == "themoviedb" and media_id.isdigit(): if source == "themoviedb" and media_id.isdigit():
subscribes.extend(await Subscribe.async_get_by_tmdbid(db, int(media_id), season)) subscribes.extend(await Subscribe.async_get_by_tmdbid(db, int(media_id), season))
@@ -125,7 +147,11 @@ async def list_subscribes_by_media_key(
elif source == "anilist" and media_id.isdigit(): elif source == "anilist" and media_id.isdigit():
subscribes.extend(await Subscribe.async_list_by_anilistid(db, int(media_id))) subscribes.extend(await Subscribe.async_list_by_anilistid(db, int(media_id)))
unique_subscribes = {subscribe.id: subscribe for subscribe in subscribes} unique_subscribes = {
subscribe.id: subscribe
for subscribe in subscribes
if matches_subscribe_music_type(subscribe, music_type)
}
if season is not None: if season is not None:
return [ return [
subscribe for subscribe in unique_subscribes.values() subscribe for subscribe in unique_subscribes.values()
@@ -218,6 +244,12 @@ async def update_subscribe(
old_subscribe_dict = subscribe.to_dict() old_subscribe_dict = subscribe.to_dict()
subscribe_dict = subscribe_in.to_public_write_payload() subscribe_dict = subscribe_in.to_public_write_payload()
subscribe_dict["username"] = subscribe.username subscribe_dict["username"] = subscribe.username
if getattr(subscribe, "type", None) == MediaType.MUSIC.value:
# 音乐实体与曲目总数来自识别链,编辑接口不得把专辑改成单曲而提前完成订阅。
subscribe_dict["type"] = subscribe.type
subscribe_dict["music_type"] = subscribe.music_type
subscribe_dict["total_tracks"] = subscribe.total_tracks \
if subscribe.music_type == MUSIC_ENTITY_ALBUM else None
if subscribe_in.total_episode and subscribe_in.total_episode > (subscribe.total_episode or 0): if subscribe_in.total_episode and subscribe_in.total_episode > (subscribe.total_episode or 0):
# 扩大目标范围时,新增加的集数尚无下载事实,应同步计入缺失集数。 # 扩大目标范围时,新增加的集数尚无下载事实,应同步计入缺失集数。
subscribe_dict["lack_episode"] = (subscribe.lack_episode or 0) + ( subscribe_dict["lack_episode"] = (subscribe.lack_episode or 0) + (
@@ -281,13 +313,14 @@ async def subscribe_mediaid(
mediaid: str, mediaid: str,
season: Optional[int] = None, season: Optional[int] = None,
title: Optional[str] = None, title: Optional[str] = None,
music_type: Optional[str] = None,
db: AsyncSession = Depends(get_async_db), db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_user_async), current_user: User = Depends(get_current_active_user_async),
) -> Any: ) -> Any:
""" """
根据 TMDB、豆瓣、Bangumi、AniList 或插件媒体键查询订阅。 根据 TMDB、豆瓣、Bangumi、AniList 或插件媒体键查询订阅。
""" """
subscribes = await list_subscribes_by_media_key(db, mediaid, season) subscribes = await list_subscribes_by_media_key(db, mediaid, season, music_type)
result = select_accessible_subscribe(subscribes, current_user) result = select_accessible_subscribe(subscribes, current_user)
source, _ = parse_media_key(mediaid) source, _ = parse_media_key(mediaid)
title_check = not result and bool(title) and source != "themoviedb" title_check = not result and bool(title) and source != "themoviedb"
@@ -299,6 +332,11 @@ async def subscribe_mediaid(
subscribes = await Subscribe.async_list_by_title( subscribes = await Subscribe.async_list_by_title(
db, title=meta.name, season=meta.begin_season db, title=meta.name, season=meta.begin_season
) )
if music_type:
subscribes = [
subscribe for subscribe in subscribes
if matches_subscribe_music_type(subscribe, music_type)
]
result = select_accessible_subscribe(subscribes, current_user) result = select_accessible_subscribe(subscribes, current_user)
return result if result else Subscribe() return result if result else Subscribe()
@@ -429,13 +467,14 @@ async def search_subscribe(
async def delete_subscribe_by_mediaid( async def delete_subscribe_by_mediaid(
mediaid: str, mediaid: str,
season: Optional[int] = None, season: Optional[int] = None,
music_type: Optional[str] = None,
db: AsyncSession = Depends(get_async_db), db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_user_async), current_user: User = Depends(get_current_active_user_async),
) -> Any: ) -> Any:
""" """
根据任意媒体数据源 ID 删除订阅。 根据任意媒体数据源 ID 删除订阅。
""" """
delete_subscribes = await list_subscribes_by_media_key(db, mediaid, season) delete_subscribes = await list_subscribes_by_media_key(db, mediaid, season, music_type)
delete_events = [] delete_events = []
for subscribe in [ for subscribe in [
subscribe subscribe
+12 -9
View File
@@ -39,18 +39,21 @@ class MusicChain(ChainBase):
@classmethod @classmethod
def build_site_keywords(cls, music: MetaMusic | MusicInfo) -> list[str]: def build_site_keywords(cls, music: MetaMusic | MusicInfo) -> list[str]:
"""根据音乐元数据生成按精确度递减的站点搜索关键词""" """按单曲或专辑实体生成站点关键词,避免单曲订阅优先搜到所属整专"""
artists = music.artists or [] artists = music.artists or []
artist = artists[0] if artists else music.album_artist artist = artists[0] if artists else music.album_artist
keywords = [] keywords = []
if artist and music.album: if getattr(music, "music_type", None) == MUSIC_ENTITY_ALBUM:
keywords.append(f"{artist} {music.album}") album = music.album or music.title
if artist and music.title: if artist and album:
keywords.append(f"{artist} {music.title}") keywords.append(f"{artist} {album}")
if music.album: if album:
keywords.append(music.album) keywords.append(album)
if music.title: else:
keywords.append(music.title) if artist and music.title:
keywords.append(f"{artist} {music.title}")
if music.title:
keywords.append(music.title)
return cls._unique_texts(keywords) return cls._unique_texts(keywords)
@classmethod @classmethod
+95 -15
View File
@@ -19,6 +19,7 @@ from app.chain.torrents import TorrentsChain
from app.core.config import settings, global_vars from app.core.config import settings, global_vars
from app.core.context import ( from app.core.context import (
MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
Context, Context,
MediaInfo, MediaInfo,
MusicInfo, MusicInfo,
@@ -71,7 +72,7 @@ def build_subscribe_meta(subscribe: Subscribe) -> MetaBase:
title=subscribe.name, title=subscribe.name,
album=subscribe.name if is_album else None, album=subscribe.name if is_album else None,
year=subscribe.year, year=subscribe.year,
total_tracks=getattr(subscribe, "total_tracks", None), total_tracks=getattr(subscribe, "total_tracks", None) if is_album else None,
media_source=subscribe.media_source, media_source=subscribe.media_source,
media_id=str(subscribe.media_id) if subscribe.media_id is not None else None, media_id=str(subscribe.media_id) if subscribe.media_id is not None else None,
) )
@@ -983,6 +984,15 @@ class SubscribeChain(ChainBase):
logger.warn(f'未识别到媒体信息,标题:{title}tmdbid{tmdbid}doubanid{doubanid}') logger.warn(f'未识别到媒体信息,标题:{title}tmdbid{tmdbid}doubanid{doubanid}')
return None, "未识别到媒体信息" return None, "未识别到媒体信息"
if mtype == MediaType.MUSIC:
music_error = self._validate_music_subscribe_target(
mediainfo,
requested_music_type=kwargs.get("music_type"),
)
if music_error:
logger.warning(f"音乐订阅目标校验失败:{title} - {music_error}")
return None, music_error
# 总集数 # 总集数
if mediainfo.type == MediaType.TV: if mediainfo.type == MediaType.TV:
if season is None: if season is None:
@@ -1103,7 +1113,8 @@ class SubscribeChain(ChainBase):
"media_source": media_source, "media_source": media_source,
"media_id": media_id, "media_id": media_id,
"music_type": getattr(mediainfo, "music_type", None), "music_type": getattr(mediainfo, "music_type", None),
"total_tracks": getattr(mediainfo, "total_tracks", None), "total_tracks": getattr(mediainfo, "total_tracks", None)
if getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM else None,
"season": season, "season": season,
"poster": mediainfo.get_poster_image(), "poster": mediainfo.get_poster_image(),
"backdrop": mediainfo.get_backdrop_image(), "backdrop": mediainfo.get_backdrop_image(),
@@ -1201,6 +1212,15 @@ class SubscribeChain(ChainBase):
logger.warn(f'未识别到媒体信息,标题:{title}tmdbid{tmdbid}doubanid{doubanid}') logger.warn(f'未识别到媒体信息,标题:{title}tmdbid{tmdbid}doubanid{doubanid}')
return None, "未识别到媒体信息" return None, "未识别到媒体信息"
if mtype == MediaType.MUSIC:
music_error = self._validate_music_subscribe_target(
mediainfo,
requested_music_type=kwargs.get("music_type"),
)
if music_error:
logger.warning(f"音乐订阅目标校验失败:{title} - {music_error}")
return None, music_error
# 总集数 # 总集数
if mediainfo.type == MediaType.TV: if mediainfo.type == MediaType.TV:
if season is None: if season is None:
@@ -1321,7 +1341,8 @@ class SubscribeChain(ChainBase):
"media_source": media_source, "media_source": media_source,
"media_id": media_id, "media_id": media_id,
"music_type": getattr(mediainfo, "music_type", None), "music_type": getattr(mediainfo, "music_type", None),
"total_tracks": getattr(mediainfo, "total_tracks", None), "total_tracks": getattr(mediainfo, "total_tracks", None)
if getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM else None,
"season": season, "season": season,
"poster": mediainfo.get_poster_image(), "poster": mediainfo.get_poster_image(),
"backdrop": mediainfo.get_backdrop_image(), "backdrop": mediainfo.get_backdrop_image(),
@@ -1344,12 +1365,59 @@ class SubscribeChain(ChainBase):
anilistid=mediainfo.anilist_id, anilistid=mediainfo.anilist_id,
media_source=media_source, media_source=media_source,
media_id=media_id, media_id=media_id,
music_type=getattr(mediainfo, "music_type", None)
if mediainfo.type == MediaType.MUSIC else None,
season=meta.begin_season if meta else None, season=meta.begin_season if meta else None,
episode_group=mediainfo.episode_group, episode_group=mediainfo.episode_group,
): ):
return True return True
return False return False
@staticmethod
def _validate_music_subscribe_target(
mediainfo: MediaInfo,
requested_music_type: Optional[str] = None,
) -> Optional[str]:
"""校验音乐订阅实体一致性,并确保专辑具备可验证的曲目总数。"""
if mediainfo.type != MediaType.MUSIC:
return "识别结果不是音乐"
music_type = getattr(mediainfo, "music_type", None)
supported_types = {MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM}
if requested_music_type and requested_music_type not in supported_types:
return "音乐订阅仅支持单曲或专辑"
if music_type not in supported_types:
return "音乐订阅仅支持单曲或专辑"
if requested_music_type and requested_music_type != music_type:
return f"音乐订阅类型不匹配:请求 {requested_music_type},识别为 {music_type}"
if music_type == MUSIC_ENTITY_ALBUM:
try:
total_tracks = int(getattr(mediainfo, "total_tracks", None) or 0)
except (TypeError, ValueError):
total_tracks = 0
if total_tracks <= 0:
return "专辑总曲目数未知,无法校验整张专辑资源"
return None
@staticmethod
def _ensure_music_subscribe_entity(
subscribe: Subscribe,
mediainfo: Optional[MusicInfo],
) -> Optional[MusicInfo]:
"""保持已持久化的单曲/专辑实体边界,拒绝远端详情把订阅类型改写。"""
if not mediainfo:
return None
expected_type = getattr(subscribe, "music_type", None)
if expected_type and expected_type not in {MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM}:
logger.warning(f"音乐订阅 {subscribe.name} 的实体类型无效:{expected_type}")
return None
if expected_type and mediainfo.music_type != expected_type:
logger.warning(
f"音乐订阅 {subscribe.name} 实体不匹配:"
f"订阅为 {expected_type},远端识别为 {mediainfo.music_type},使用订阅快照"
)
return SubscribeChain._music_info_from_subscribe(subscribe)
return mediainfo
@staticmethod @staticmethod
def _recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]: def _recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]:
"""按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。""" """按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。"""
@@ -1361,8 +1429,8 @@ class SubscribeChain(ChainBase):
mtype=MediaType.MUSIC, mtype=MediaType.MUSIC,
) )
if mediainfo: if mediainfo:
return mediainfo return SubscribeChain._ensure_music_subscribe_entity(subscribe, mediainfo)
if getattr(subscribe, "music_type", None): if getattr(subscribe, "music_type", None) in {MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM}:
return SubscribeChain._music_info_from_subscribe(subscribe) return SubscribeChain._music_info_from_subscribe(subscribe)
# 旧订阅没有保存实体类型时不能猜测为单曲,否则可能误把专辑按单曲完成。 # 旧订阅没有保存实体类型时不能猜测为单曲,否则可能误把专辑按单曲完成。
return None return None
@@ -1387,8 +1455,8 @@ class SubscribeChain(ChainBase):
mtype=MediaType.MUSIC, mtype=MediaType.MUSIC,
) )
if mediainfo: if mediainfo:
return mediainfo return SubscribeChain._ensure_music_subscribe_entity(subscribe, mediainfo)
if getattr(subscribe, "music_type", None): if getattr(subscribe, "music_type", None) in {MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM}:
return SubscribeChain._music_info_from_subscribe(subscribe) return SubscribeChain._music_info_from_subscribe(subscribe)
return None return None
if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM: if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM:
@@ -1411,7 +1479,8 @@ class SubscribeChain(ChainBase):
title=subscribe.name, title=subscribe.name,
album=subscribe.name if music_type == MUSIC_ENTITY_ALBUM else None, album=subscribe.name if music_type == MUSIC_ENTITY_ALBUM else None,
year=int(year_text) if year_text.isdigit() else None, year=int(year_text) if year_text.isdigit() else None,
total_tracks=getattr(subscribe, "total_tracks", None), total_tracks=getattr(subscribe, "total_tracks", None)
if music_type == MUSIC_ENTITY_ALBUM else None,
cover_url=getattr(subscribe, "poster", None) or getattr(subscribe, "backdrop", None), cover_url=getattr(subscribe, "poster", None) or getattr(subscribe, "backdrop", None),
) )
@@ -1421,8 +1490,9 @@ class SubscribeChain(ChainBase):
update_data = {} update_data = {}
if mediainfo.music_type and getattr(subscribe, "music_type", None) != mediainfo.music_type: if mediainfo.music_type and getattr(subscribe, "music_type", None) != mediainfo.music_type:
update_data["music_type"] = mediainfo.music_type update_data["music_type"] = mediainfo.music_type
if mediainfo.total_tracks and getattr(subscribe, "total_tracks", None) != mediainfo.total_tracks: total_tracks = mediainfo.total_tracks if mediainfo.music_type == MUSIC_ENTITY_ALBUM else None
update_data["total_tracks"] = mediainfo.total_tracks if getattr(subscribe, "total_tracks", None) != total_tracks:
update_data["total_tracks"] = total_tracks
if not update_data: if not update_data:
return return
SubscribeOper().update(subscribe.id, update_data) SubscribeOper().update(subscribe.id, update_data)
@@ -2448,9 +2518,11 @@ class SubscribeChain(ChainBase):
"total_episode": total_episode, "total_episode": total_episode,
} }
if meta.type == MediaType.MUSIC: if meta.type == MediaType.MUSIC:
music_type = getattr(mediainfo, "music_type", None)
update_data.update({ update_data.update({
"music_type": getattr(mediainfo, "music_type", None), "music_type": music_type,
"total_tracks": getattr(mediainfo, "total_tracks", None), "total_tracks": getattr(mediainfo, "total_tracks", None)
if music_type == MUSIC_ENTITY_ALBUM else None,
}) })
update_data.update(progress_update) update_data.update(progress_update)
for key, value in progress_update.items(): for key, value in progress_update.items():
@@ -2479,6 +2551,7 @@ class SubscribeChain(ChainBase):
if k in [ if k in [
"type", "season", "tmdbid", "doubanid", "bangumiid", "type", "season", "tmdbid", "doubanid", "bangumiid",
"anilistid", "media_source", "media_id", "anilistid", "media_source", "media_id",
"music_type",
] ]
} }
# 暂时不考虑订阅历史, 若有必要再添加 # 暂时不考虑订阅历史, 若有必要再添加
@@ -2525,6 +2598,7 @@ class SubscribeChain(ChainBase):
anilistid=share_sub.get("anilistid"), anilistid=share_sub.get("anilistid"),
media_source=share_sub.get("media_source"), media_source=share_sub.get("media_source"),
media_id=share_sub.get("media_id"), media_id=share_sub.get("media_id"),
music_type=share_sub.get("music_type"),
season=share_sub.get("season"), season=share_sub.get("season"),
episode_group=share_sub.get("episode_group")): episode_group=share_sub.get("episode_group")):
continue continue
@@ -2535,6 +2609,7 @@ class SubscribeChain(ChainBase):
anilistid=share_sub.get("anilistid"), anilistid=share_sub.get("anilistid"),
media_source=share_sub.get("media_source"), media_source=share_sub.get("media_source"),
media_id=share_sub.get("media_id"), media_id=share_sub.get("media_id"),
music_type=share_sub.get("music_type"),
season=share_sub.get("season"), season=share_sub.get("season"),
episode_group=share_sub.get("episode_group")): episode_group=share_sub.get("episode_group")):
continue continue
@@ -2546,7 +2621,7 @@ class SubscribeChain(ChainBase):
subscribe_in = schemas.Subscribe(**share_sub) subscribe_in = schemas.Subscribe(**share_sub)
mtype = MediaType(subscribe_in.type) mtype = MediaType(subscribe_in.type)
# 豆瓣标题处理 # 豆瓣标题处理
if ( if mtype != MediaType.MUSIC and (
subscribe_in.doubanid subscribe_in.doubanid
or subscribe_in.bangumiid or subscribe_in.bangumiid
or subscribe_in.anilistid or subscribe_in.anilistid
@@ -2573,6 +2648,8 @@ class SubscribeChain(ChainBase):
anilistid=subscribe_in.anilistid, anilistid=subscribe_in.anilistid,
media_source=subscribe_in.media_source, media_source=subscribe_in.media_source,
media_id=subscribe_in.media_id, media_id=subscribe_in.media_id,
music_type=subscribe_in.music_type,
total_tracks=subscribe_in.total_tracks,
username="订阅分享", username="订阅分享",
best_version=subscribe_in.best_version, best_version=subscribe_in.best_version,
save_path=subscribe_in.save_path, save_path=subscribe_in.save_path,
@@ -2711,8 +2788,10 @@ class SubscribeChain(ChainBase):
# 电影只有一个条目,设置为 [1] # 电影只有一个条目,设置为 [1]
items = [1] items = [1]
elif mediainfo.type == MediaType.MUSIC: elif mediainfo.type == MediaType.MUSIC:
# 音乐订阅和电影一样,一次成功下载即记录单项完成事实 # 专辑只能记录已由下载层确认的整专资源;单曲任一成功任务即可完成
items = [1] if getattr(subscribe, "music_type", None) != MUSIC_ENTITY_ALBUM \
or context.confirmed_full_coverage:
items = [1]
if not items: if not items:
continue continue
# 合并已下载的集数或电影项(去重) # 合并已下载的集数或电影项(去重)
@@ -4627,6 +4706,7 @@ class SubscribeChain(ChainBase):
'anilistid': subscribe.anilistid, 'anilistid': subscribe.anilistid,
'media_source': subscribe.media_source, 'media_source': subscribe.media_source,
'media_id': subscribe.media_id, 'media_id': subscribe.media_id,
'music_type': getattr(subscribe, 'music_type', None),
} }
return f"Subscribe|{json.dumps(source_keyword, ensure_ascii=False)}" return f"Subscribe|{json.dumps(source_keyword, ensure_ascii=False)}"
+27 -13
View File
@@ -1,7 +1,7 @@
import time import time
from typing import Optional from typing import Optional
from sqlalchemy import Column, Integer, String, Float, JSON, Index, select from sqlalchemy import Column, Integer, String, Float, JSON, Index, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -113,10 +113,17 @@ class Subscribe(Base):
doubanid: Optional[str] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, anilistid: Optional[int] = None,
music_type: Optional[str] = None,
): ):
"""按统一媒体身份优先级构造订阅查询条件。""" """按统一媒体身份优先级构造订阅查询条件。"""
if media_source and media_id: if media_source and media_id:
return (cls.media_source == media_source) & (cls.media_id == str(media_id)) condition = (cls.media_source == media_source) & (cls.media_id == str(media_id))
if music_type == "recording":
# 旧音乐订阅没有实体字段,历史语义等同单曲,查询时保持向后兼容。
return condition & or_(cls.music_type == music_type, cls.music_type.is_(None))
if music_type:
return condition & (cls.music_type == music_type)
return condition
if tmdbid is not None: if tmdbid is not None:
return cls.tmdbid == tmdbid return cls.tmdbid == tmdbid
if doubanid: if doubanid:
@@ -135,10 +142,11 @@ class Subscribe(Base):
anilistid: Optional[int] = None, media_source: Optional[str] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None, media_id: Optional[str] = None, season: Optional[int] = None,
episode_group: Optional[str] = None, episode_group: Optional[str] = None,
music_type: Optional[str] = None,
): ):
"""按媒体身份、季号与剧集组查询已有订阅。""" """按媒体身份、季号与剧集组查询已有订阅。"""
condition = cls._identity_condition( condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid media_source, media_id, tmdbid, doubanid, bangumiid, anilistid, music_type
) )
if condition is None: if condition is None:
return None return None
@@ -156,10 +164,11 @@ class Subscribe(Base):
anilistid: Optional[int] = None, media_source: Optional[str] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None, media_id: Optional[str] = None, season: Optional[int] = None,
episode_group: Optional[str] = None, episode_group: Optional[str] = None,
music_type: Optional[str] = None,
): ):
"""异步按媒体身份、季号与剧集组查询已有订阅。""" """异步按媒体身份、季号与剧集组查询已有订阅。"""
condition = cls._identity_condition( condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid media_source, media_id, tmdbid, doubanid, bangumiid, anilistid, music_type
) )
if condition is None: if condition is None:
return None return None
@@ -178,6 +187,7 @@ class Subscribe(Base):
anilistid: Optional[int] = None, media_source: Optional[str] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None, media_id: Optional[str] = None, season: Optional[int] = None,
episode_group: Optional[str] = None, episode_group: Optional[str] = None,
music_type: Optional[str] = None,
): ):
""" """
按订阅 owner、媒体身份、季号与剧集组查询订阅行。 按订阅 owner、媒体身份、季号与剧集组查询订阅行。
@@ -185,7 +195,7 @@ class Subscribe(Base):
if not username: if not username:
return None return None
condition = cls._identity_condition( condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid media_source, media_id, tmdbid, doubanid, bangumiid, anilistid, music_type
) )
if condition is None: if condition is None:
return None return None
@@ -203,6 +213,7 @@ class Subscribe(Base):
anilistid: Optional[int] = None, media_source: Optional[str] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None, media_id: Optional[str] = None, season: Optional[int] = None,
episode_group: Optional[str] = None, episode_group: Optional[str] = None,
music_type: Optional[str] = None,
): ):
""" """
异步按订阅 owner、媒体身份、季号与剧集组查询订阅行。 异步按订阅 owner、媒体身份、季号与剧集组查询订阅行。
@@ -210,7 +221,7 @@ class Subscribe(Base):
if not username: if not username:
return None return None
condition = cls._identity_condition( condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid media_source, media_id, tmdbid, doubanid, bangumiid, anilistid, music_type
) )
if condition is None: if condition is None:
return None return None
@@ -364,14 +375,15 @@ class Subscribe(Base):
@async_db_query @async_db_query
async def async_list_by_media_identity( async def async_list_by_media_identity(
cls, db: AsyncSession, media_source: str, media_id: str, cls, db: AsyncSession, media_source: str, media_id: str,
music_type: Optional[str] = None,
): ):
"""异步按统一媒体身份查询候选订阅列表。""" """异步按统一媒体身份查询候选订阅列表。"""
result = await db.execute( condition = cls._identity_condition(
select(cls).filter( media_source=media_source,
cls.media_source == media_source, media_id=media_id,
cls.media_id == str(media_id), music_type=music_type,
)
) )
result = await db.execute(select(cls).filter(condition))
return result.scalars().all() return result.scalars().all()
@classmethod @classmethod
@@ -405,12 +417,13 @@ class Subscribe(Base):
tmdbid: Optional[int] = None, doubanid: Optional[str] = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None, media_source: Optional[str] = None, media_id: Optional[str] = None,
music_type: Optional[str] = None,
): ):
""" """
根据条件查询订阅 根据条件查询订阅
""" """
condition = cls._identity_condition( condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid media_source, media_id, tmdbid, doubanid, bangumiid, anilistid, music_type
) )
if condition is None: if condition is None:
return None return None
@@ -426,12 +439,13 @@ class Subscribe(Base):
tmdbid: Optional[int] = None, doubanid: Optional[str] = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None, media_source: Optional[str] = None, media_id: Optional[str] = None,
music_type: Optional[str] = None,
): ):
""" """
根据条件查询订阅 根据条件查询订阅
""" """
condition = cls._identity_condition( condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid media_source, media_id, tmdbid, doubanid, bangumiid, anilistid, music_type
) )
if condition is None: if condition is None:
return None return None
+13 -4
View File
@@ -1,6 +1,6 @@
from typing import Optional from typing import Optional
from sqlalchemy import Column, Integer, String, Float, JSON, Index, select from sqlalchemy import Column, Integer, String, Float, JSON, Index, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -144,10 +144,17 @@ class SubscribeHistory(Base):
doubanid: Optional[str] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, anilistid: Optional[int] = None,
music_type: Optional[str] = None,
): ):
"""按统一媒体身份优先级构造订阅历史查询条件。""" """按统一媒体身份优先级构造订阅历史查询条件。"""
if media_source and media_id: if media_source and media_id:
return (cls.media_source == media_source) & (cls.media_id == str(media_id)) condition = (cls.media_source == media_source) & (cls.media_id == str(media_id))
if music_type == "recording":
# 旧历史记录没有实体字段,历史语义等同单曲。
return condition & or_(cls.music_type == music_type, cls.music_type.is_(None))
if music_type:
return condition & (cls.music_type == music_type)
return condition
if tmdbid is not None: if tmdbid is not None:
return cls.tmdbid == tmdbid return cls.tmdbid == tmdbid
if doubanid: if doubanid:
@@ -166,10 +173,11 @@ class SubscribeHistory(Base):
anilistid: Optional[int] = None, media_source: Optional[str] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None, media_id: Optional[str] = None, season: Optional[int] = None,
episode_group: Optional[str] = None, episode_group: Optional[str] = None,
music_type: Optional[str] = None,
): ):
"""按媒体身份、季号及可选剧集组查询订阅历史。""" """按媒体身份、季号及可选剧集组查询订阅历史。"""
condition = cls._identity_condition( condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid media_source, media_id, tmdbid, doubanid, bangumiid, anilistid, music_type
) )
if condition is None: if condition is None:
return None return None
@@ -187,10 +195,11 @@ class SubscribeHistory(Base):
anilistid: Optional[int] = None, media_source: Optional[str] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None, media_id: Optional[str] = None, season: Optional[int] = None,
episode_group: Optional[str] = None, episode_group: Optional[str] = None,
music_type: Optional[str] = None,
): ):
"""异步按媒体身份、季号及可选剧集组查询订阅历史。""" """异步按媒体身份、季号及可选剧集组查询订阅历史。"""
condition = cls._identity_condition( condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid media_source, media_id, tmdbid, doubanid, bangumiid, anilistid, music_type
) )
if condition is None: if condition is None:
return None return None
+17 -5
View File
@@ -1,7 +1,7 @@
import time import time
from typing import Tuple, List, Optional from typing import Tuple, List, Optional
from app.core.context import MediaInfo, MusicInfo from app.core.context import MUSIC_ENTITY_ALBUM, MediaInfo, MusicInfo
from app.db import DbOper from app.db import DbOper
from app.db.models.subscribe import Subscribe from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory from app.db.models.subscribehistory import SubscribeHistory
@@ -36,9 +36,11 @@ def _music_subscription_fields(mediainfo: MediaInfo | MusicInfo) -> dict:
"""从标准媒体信息提取音乐订阅需要持久化的专辑级字段。""" """从标准媒体信息提取音乐订阅需要持久化的专辑级字段。"""
if mediainfo.type != MediaType.MUSIC: if mediainfo.type != MediaType.MUSIC:
return {"music_type": None, "total_tracks": None} return {"music_type": None, "total_tracks": None}
music_type = getattr(mediainfo, "music_type", None)
return { return {
"music_type": getattr(mediainfo, "music_type", None), "music_type": music_type,
"total_tracks": getattr(mediainfo, "total_tracks", None), "total_tracks": getattr(mediainfo, "total_tracks", None)
if music_type == MUSIC_ENTITY_ALBUM else None,
} }
@@ -65,6 +67,8 @@ class SubscribeOper(DbOper):
"anilistid": mediainfo.anilist_id, "anilistid": mediainfo.anilist_id,
"media_source": media_source, "media_source": media_source,
"media_id": media_id, "media_id": media_id,
"music_type": getattr(mediainfo, "music_type", None)
if mediainfo.type == MediaType.MUSIC else None,
"season": kwargs.get("season"), "season": kwargs.get("season"),
"episode_group": mediainfo.episode_group, "episode_group": mediainfo.episode_group,
} }
@@ -128,6 +132,8 @@ class SubscribeOper(DbOper):
"anilistid": mediainfo.anilist_id, "anilistid": mediainfo.anilist_id,
"media_source": media_source, "media_source": media_source,
"media_id": media_id, "media_id": media_id,
"music_type": getattr(mediainfo, "music_type", None)
if mediainfo.type == MediaType.MUSIC else None,
"season": kwargs.get("season"), "season": kwargs.get("season"),
"episode_group": mediainfo.episode_group, "episode_group": mediainfo.episode_group,
} }
@@ -178,6 +184,7 @@ class SubscribeOper(DbOper):
bangumiid: Optional[int] = None, anilistid: Optional[int] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None, media_source: Optional[str] = None, media_id: Optional[str] = None,
season: Optional[int] = None, episode_group: Optional[str] = None, season: Optional[int] = None, episode_group: Optional[str] = None,
music_type: Optional[str] = None,
) -> bool: ) -> bool:
""" """
按媒体身份季号及可选剧集组判断订阅是否存在 按媒体身份季号及可选剧集组判断订阅是否存在
@@ -189,6 +196,7 @@ class SubscribeOper(DbOper):
"anilistid": anilistid, "anilistid": anilistid,
"media_source": media_source, "media_source": media_source,
"media_id": media_id, "media_id": media_id,
"music_type": music_type,
"season": season, "season": season,
"episode_group": episode_group, "episode_group": episode_group,
} }
@@ -211,13 +219,14 @@ class SubscribeOper(DbOper):
tmdbid: Optional[int] = None, doubanid: Optional[str] = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None, media_source: Optional[str] = None, media_id: Optional[str] = None,
music_type: Optional[str] = None,
) -> Optional[Subscribe]: ) -> Optional[Subscribe]:
""" """
根据条件查询订阅 根据条件查询订阅
""" """
return Subscribe.get_by( return Subscribe.get_by(
self._db, type, season, tmdbid, doubanid, bangumiid, anilistid, self._db, type, season, tmdbid, doubanid, bangumiid, anilistid,
media_source, media_id, media_source, media_id, music_type,
) )
async def async_get_by( async def async_get_by(
@@ -225,13 +234,14 @@ class SubscribeOper(DbOper):
tmdbid: Optional[int] = None, doubanid: Optional[str] = None, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None, media_source: Optional[str] = None, media_id: Optional[str] = None,
music_type: Optional[str] = None,
) -> Optional[Subscribe]: ) -> Optional[Subscribe]:
""" """
根据条件查询订阅 根据条件查询订阅
""" """
return await Subscribe.async_get_by( return await Subscribe.async_get_by(
self._db, type, season, tmdbid, doubanid, bangumiid, anilistid, self._db, type, season, tmdbid, doubanid, bangumiid, anilistid,
media_source, media_id, media_source, media_id, music_type,
) )
def list(self, state: Optional[str] = None) -> List[Subscribe]: def list(self, state: Optional[str] = None) -> List[Subscribe]:
@@ -327,6 +337,7 @@ class SubscribeOper(DbOper):
bangumiid: Optional[int] = None, anilistid: Optional[int] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None, media_source: Optional[str] = None, media_id: Optional[str] = None,
season: Optional[int] = None, episode_group: Optional[str] = None, season: Optional[int] = None, episode_group: Optional[str] = None,
music_type: Optional[str] = None,
) -> bool: ) -> bool:
""" """
按媒体身份季号及可选剧集组判断订阅历史是否存在 按媒体身份季号及可选剧集组判断订阅历史是否存在
@@ -338,6 +349,7 @@ class SubscribeOper(DbOper):
"anilistid": anilistid, "anilistid": anilistid,
"media_source": media_source, "media_source": media_source,
"media_id": media_id, "media_id": media_id,
"music_type": music_type,
"season": season, "season": season,
"episode_group": episode_group, "episode_group": episode_group,
} }
+16 -2
View File
@@ -25,8 +25,9 @@ def test_parse_query_keeps_plain_title():
def test_build_site_keywords_prefers_artist_album(): def test_build_site_keywords_prefers_artist_album():
"""站点关键词应优先使用艺术家专辑组合""" """专辑订阅只按艺术家专辑名搜索,不混入其中某首单曲"""
info = MusicInfo( info = MusicInfo(
music_type="album",
title="Get Lucky", title="Get Lucky",
artists=["Daft Punk"], artists=["Daft Punk"],
album="Random Access Memories", album="Random Access Memories",
@@ -34,8 +35,21 @@ def test_build_site_keywords_prefers_artist_album():
assert MusicChain.build_site_keywords(info) == [ assert MusicChain.build_site_keywords(info) == [
"Daft Punk Random Access Memories", "Daft Punk Random Access Memories",
"Daft Punk Get Lucky",
"Random Access Memories", "Random Access Memories",
]
def test_build_site_keywords_keeps_recording_out_of_album_search():
"""单曲订阅只按艺术家与曲名搜索,不能优先命中所属整张专辑。"""
info = MusicInfo(
music_type="recording",
title="Get Lucky",
artists=["Daft Punk"],
album="Random Access Memories",
)
assert MusicChain.build_site_keywords(info) == [
"Daft Punk Get Lucky",
"Get Lucky", "Get Lucky",
] ]
+102 -2
View File
@@ -2,7 +2,7 @@ from types import SimpleNamespace
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from app.chain.subscribe import SubscribeChain, build_subscribe_meta from app.chain.subscribe import SubscribeChain, build_subscribe_meta
from app.core.context import MUSIC_ENTITY_ALBUM, Context, TorrentInfo from app.core.context import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, Context, TorrentInfo
from app.core.meta import MetaMusic from app.core.meta import MetaMusic
from app.core.context import MusicInfo from app.core.context import MusicInfo
from app.schemas.types import MediaType from app.schemas.types import MediaType
@@ -97,7 +97,7 @@ def test_music_subscribe_reuses_search_download_and_finish_flow():
chain._search_music_subscribe(subscribe) chain._search_music_subscribe(subscribe)
search_chain.search_by_title.assert_called_once_with( search_chain.search_by_title.assert_called_once_with(
title="周杰伦 叶惠美", title="周杰伦 晴天",
sites=[], sites=[],
mtype=MediaType.MUSIC, mtype=MediaType.MUSIC,
rule_groups=[], rule_groups=[],
@@ -253,6 +253,47 @@ def test_album_subscription_finishes_only_after_confirmed_full_pack():
) is True ) is True
def test_music_subscribe_target_validation_enforces_entity_semantics():
"""单曲无需专辑曲目数,专辑必须有总曲目数,艺术家和实体错配均不可订阅。"""
recording = _music_info()
recording.total_tracks = 11
album = MusicInfo(
source="musicbrainz",
media_id="release-group-1",
music_type=MUSIC_ENTITY_ALBUM,
title="叶惠美",
album="叶惠美",
total_tracks=11,
)
assert SubscribeChain._validate_music_subscribe_target(
recording, MUSIC_ENTITY_RECORDING
) is None
assert SubscribeChain._validate_music_subscribe_target(album, MUSIC_ENTITY_ALBUM) is None
assert "类型不匹配" in (
SubscribeChain._validate_music_subscribe_target(album, MUSIC_ENTITY_RECORDING) or ""
)
album.total_tracks = None
assert "总曲目数未知" in (
SubscribeChain._validate_music_subscribe_target(album, MUSIC_ENTITY_ALBUM) or ""
)
assert "仅支持单曲或专辑" in (
SubscribeChain._validate_music_subscribe_target(recording, "artist") or ""
)
def test_recording_target_sync_clears_stale_album_track_count():
"""旧单曲订阅若误存所属专辑曲目数,刷新元数据时应主动清空。"""
subscribe = _subscribe(total_tracks=11)
subscribe_oper = Mock()
with patch("app.chain.subscribe.SubscribeOper", return_value=subscribe_oper):
SubscribeChain._sync_music_subscribe_target(subscribe, _music_info())
subscribe_oper.update.assert_called_once_with(subscribe.id, {"total_tracks": None})
assert subscribe.total_tracks is None
def test_subscribe_add_music_uses_unified_recognize_by_meta(): def test_subscribe_add_music_uses_unified_recognize_by_meta():
"""音乐订阅新增应走统一 recognize_by_meta,并把媒体身份落到 MetaMusic 上。""" """音乐订阅新增应走统一 recognize_by_meta,并把媒体身份落到 MetaMusic 上。"""
target = _music_info() target = _music_info()
@@ -284,6 +325,29 @@ def test_subscribe_add_music_uses_unified_recognize_by_meta():
assert media_chain.recognize_by_meta.call_args.kwargs["source"] == "musicbrainz" assert media_chain.recognize_by_meta.call_args.kwargs["source"] == "musicbrainz"
def test_subscribe_add_rejects_music_entity_mismatch_before_database_write():
"""请求专辑却识别为单曲时必须中止,不能创建完成语义错误的订阅。"""
media_chain = Mock()
media_chain.recognize_by_meta.return_value = _music_info()
subscribe_oper = Mock()
with patch("app.chain.subscribe.MediaChain", return_value=media_chain), \
patch("app.chain.subscribe.SubscribeOper", return_value=subscribe_oper):
sid, err_msg = SubscribeChain().add(
title="叶惠美",
year="2003",
mtype=MediaType.MUSIC,
media_source="musicbrainz",
media_id="recording-1",
music_type=MUSIC_ENTITY_ALBUM,
message=False,
)
assert sid is None
assert "类型不匹配" in err_msg
subscribe_oper.add.assert_not_called()
def test_subscribe_add_music_fails_fast_on_offline_fallback(): def test_subscribe_add_music_fails_fast_on_offline_fallback():
"""统一识别返回离线兜底(无远端 source)时订阅应直接失败,不写入数据库。""" """统一识别返回离线兜底(无远端 source)时订阅应直接失败,不写入数据库。"""
offline = MusicInfo(title="未知曲目", artists=["未知艺术家"]) offline = MusicInfo(title="未知曲目", artists=["未知艺术家"])
@@ -304,3 +368,39 @@ def test_subscribe_add_music_fails_fast_on_offline_fallback():
assert sid is None assert sid is None
assert err_msg == "未识别到媒体信息" assert err_msg == "未识别到媒体信息"
subscribe_oper.add.assert_not_called() subscribe_oper.add.assert_not_called()
def test_follow_preserves_album_entity_and_track_count():
"""Follow 专辑分享不得走影视标题解析,并须保留整专完成判定字段。"""
share = {
"share_uid": "follow-user",
"name": "叶惠美",
"type": MediaType.MUSIC.value,
"year": "2003",
"media_source": "musicbrainz",
"media_id": "release-group-1",
"music_type": MUSIC_ENTITY_ALBUM,
"total_tracks": 11,
"filter_groups": [],
}
subscribe_oper = Mock()
subscribe_oper.exists.return_value = False
subscribe_oper.exist_history.return_value = False
system_config = Mock()
system_config.get.return_value = ["follow-user"]
with patch("app.chain.subscribe.SubscribeOper", return_value=subscribe_oper), \
patch("app.chain.subscribe.SystemConfigOper", return_value=system_config), \
patch(
"app.chain.subscribe.MoviePilotServerHelper.get_subscribe_shares",
return_value=[share],
), \
patch("app.chain.subscribe.MetaInfo") as video_meta, \
patch.object(SubscribeChain, "add", return_value=(1, "")) as add:
SubscribeChain.follow()
video_meta.assert_not_called()
assert subscribe_oper.exists.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM
assert subscribe_oper.exist_history.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM
assert add.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM
assert add.call_args.kwargs["total_tracks"] == 11
+1
View File
@@ -79,6 +79,7 @@ def _load_subscribe_chain_class():
context_module.MediaInfo = SimpleNamespace context_module.MediaInfo = SimpleNamespace
context_module.MusicInfo = SimpleNamespace context_module.MusicInfo = SimpleNamespace
context_module.MUSIC_ENTITY_ALBUM = "album" context_module.MUSIC_ENTITY_ALBUM = "album"
context_module.MUSIC_ENTITY_RECORDING = "recording"
event_module = ensure_module("app.core.event", types.ModuleType("app.core.event")) event_module = ensure_module("app.core.event", types.ModuleType("app.core.event"))
+110
View File
@@ -220,6 +220,53 @@ class SubscribeEndpointTest(TestCase):
self.assertNotIn("username", payload["fields"]) self.assertNotIn("username", payload["fields"])
self.assertEqual(payload["subscribe_info"]["username"], "alice") self.assertEqual(payload["subscribe_info"]["username"], "alice")
def test_update_subscribe_preserves_recognized_music_entity(self):
"""普通编辑不得把专辑改为单曲或覆盖整专完成判定所需的曲目总数。"""
from app.api.endpoints.subscribe import update_subscribe
subscribe = _EndpointSubscribe(
id=23,
username="alice",
name="叶惠美",
type=MediaType.MUSIC.value,
music_type="album",
total_tracks=11,
total_episode=0,
lack_episode=0,
vote=0.0,
sites=[],
search_imdbid=0,
filter_groups=[],
start_episode=0,
)
subscribe_in = Subscribe(
id=23,
name="叶惠美",
type=MediaType.MUSIC.value,
music_type="recording",
total_tracks=1,
)
with patch(
"app.api.endpoints.subscribe.Subscribe.async_get",
new=AsyncMock(side_effect=[subscribe, subscribe]),
), patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
new=AsyncMock(),
):
response = asyncio.run(
update_subscribe(
subscribe_in=subscribe_in,
db=object(),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertTrue(response.success)
self.assertEqual(subscribe.type, MediaType.MUSIC.value)
self.assertEqual(subscribe.music_type, "album")
self.assertEqual(subscribe.total_tracks, 11)
def test_superuser_can_update_other_and_legacy_subscribe(self): def test_superuser_can_update_other_and_legacy_subscribe(self):
""" """
超级用户可以管理他人和 legacy 订阅 超级用户可以管理他人和 legacy 订阅
@@ -311,6 +358,43 @@ class SubscribeEndpointTest(TestCase):
self.assertEqual(result.id, 14) self.assertEqual(result.id, 14)
def test_subscribe_mediaid_distinguishes_recording_and_album_entities(self):
"""同一来源身份下查询专辑时不能返回单曲订阅。"""
from app.api.endpoints.subscribe import subscribe_mediaid
recording = _EndpointSubscribe(
id=21,
username="alice",
type=MediaType.MUSIC.value,
music_type="recording",
media_source="musicbrainz",
media_id="shared-id",
)
album = _EndpointSubscribe(
id=22,
username="alice",
type=MediaType.MUSIC.value,
music_type="album",
media_source="musicbrainz",
media_id="shared-id",
)
with patch(
"app.api.endpoints.subscribe.Subscribe.async_list_by_media_identity",
new=AsyncMock(return_value=[recording, album]),
) as list_by_identity:
result = asyncio.run(
subscribe_mediaid(
mediaid="musicbrainz:shared-id",
music_type="album",
db=object(),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertEqual(result.id, 22)
self.assertEqual(list_by_identity.await_args.kwargs["music_type"], "album")
def test_delete_subscribe_by_mediaid_deletes_owner_when_other_douban_match_first(self): def test_delete_subscribe_by_mediaid_deletes_owner_when_other_douban_match_first(self):
""" """
按媒体删除订阅时应在候选集合中删除当前用户自己的订阅 按媒体删除订阅时应在候选集合中删除当前用户自己的订阅
@@ -347,6 +431,32 @@ class SubscribeEndpointTest(TestCase):
self.assertEqual(db.deleted, [own]) self.assertEqual(db.deleted, [own])
send_event.assert_awaited_once() send_event.assert_awaited_once()
def test_delete_subscribe_by_mediaid_forwards_music_entity(self):
"""取消专辑订阅时必须把实体类型传给统一身份查询。"""
from app.api.endpoints.subscribe import delete_subscribe_by_mediaid
db = _EndpointAsyncDb()
with patch(
"app.api.endpoints.subscribe.list_subscribes_by_media_key",
new=AsyncMock(return_value=[]),
) as list_by_key:
response = asyncio.run(
delete_subscribe_by_mediaid(
mediaid="musicbrainz:release-group-1",
music_type="album",
db=db,
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertTrue(response.success)
list_by_key.assert_awaited_once_with(
db,
"musicbrainz:release-group-1",
None,
"album",
)
def test_search_subscribes_regular_user_schedules_only_owned_rows(self): def test_search_subscribes_regular_user_schedules_only_owned_rows(self):
""" """
普通用户批量搜索只按自己的订阅 ID 入队 普通用户批量搜索只按自己的订阅 ID 入队
+79
View File
@@ -168,6 +168,35 @@ def test_music_album_subscription_persists_entity_and_track_count():
assert payload["total_tracks"] == 11 assert payload["total_tracks"] == 11
def test_music_recording_subscription_drops_album_track_count_and_scopes_identity():
"""单曲只持久化实体类型,重复查询也必须携带实体,不能与专辑身份串用。"""
persisted = SimpleNamespace(id=95)
created = SimpleNamespace(create=MagicMock())
media = MusicInfo(
source="musicbrainz",
media_id="recording-1",
music_type="recording",
title="晴天",
album="叶惠美",
total_tracks=11,
)
with patch("app.db.subscribe_oper.Subscribe") as subscribe_model:
subscribe_model.exists.side_effect = [None, persisted]
subscribe_model.return_value = created
sid, _ = SubscribeOper(db=object()).add(mediainfo=media, season=None)
assert sid == 95
payload = subscribe_model.call_args.kwargs
assert payload["music_type"] == "recording"
assert payload["total_tracks"] is None
assert all(
call.kwargs["music_type"] == "recording"
for call in subscribe_model.exists.call_args_list
)
@pytest.mark.parametrize("episode_group", [None, "eg-1"]) @pytest.mark.parametrize("episode_group", [None, "eg-1"])
def test_async_add_scopes_duplicate_lookup_by_episode_group(episode_group): def test_async_add_scopes_duplicate_lookup_by_episode_group(episode_group):
"""异步新增与同步路径使用相同的剧集组身份契约。""" """异步新增与同步路径使用相同的剧集组身份契约。"""
@@ -294,6 +323,55 @@ def test_subscribe_exists_distinguishes_same_season_episode_groups():
Subscribe.delete(oper._db, rid=subscribe_id) Subscribe.delete(oper._db, rid=subscribe_id)
def test_subscribe_exists_distinguishes_music_entities_with_same_source_id():
"""统一来源 ID 相同时,单曲与专辑仍是两条独立订阅身份。"""
oper = SubscribeOper()
media_id = f"music-shared-{os.getpid()}"
created_ids = []
rows = [
Subscribe(
name="同名单曲",
type=MediaType.MUSIC.value,
state="N",
media_source="musicbrainz",
media_id=media_id,
music_type="recording",
),
Subscribe(
name="同名专辑",
type=MediaType.MUSIC.value,
state="N",
media_source="musicbrainz",
media_id=media_id,
music_type="album",
total_tracks=10,
),
]
try:
for row in rows:
row.create(oper._db)
recording = Subscribe.exists(
oper._db,
media_source="musicbrainz",
media_id=media_id,
music_type="recording",
)
created_ids.append(recording.id)
album = Subscribe.exists(
oper._db,
media_source="musicbrainz",
media_id=media_id,
music_type="album",
)
created_ids.append(album.id)
assert recording.name == "同名单曲"
assert album.name == "同名专辑"
finally:
for subscribe_id in created_ids:
Subscribe.delete(oper._db, rid=subscribe_id)
def test_subscribe_chain_exists_forwards_episode_group(): def test_subscribe_chain_exists_forwards_episode_group():
"""订阅前置存在性检查必须查询当前剧集组,不能退回主季范围。""" """订阅前置存在性检查必须查询当前剧集组,不能退回主季范围。"""
from app.chain.subscribe import SubscribeChain from app.chain.subscribe import SubscribeChain
@@ -312,6 +390,7 @@ def test_subscribe_chain_exists_forwards_episode_group():
anilistid=media.anilist_id, anilistid=media.anilist_id,
media_source="themoviedb", media_source="themoviedb",
media_id=str(media.tmdb_id), media_id=str(media.tmdb_id),
music_type=None,
season=1, season=1,
episode_group="eg-1", episode_group="eg-1",
) )
+1
View File
@@ -21,6 +21,7 @@ def test_subscribe_source_keyword_includes_episode_group():
anilistid=None, anilistid=None,
media_source="themoviedb", media_source="themoviedb",
media_id="12345", media_id="12345",
music_type=None,
) )
source = SubscribeChain.get_subscribe_source_keyword(subscribe) source = SubscribeChain.get_subscribe_source_keyword(subscribe)