diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index d5695562f..06af6ba11 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from app import schemas from app.chain.subscribe import SubscribeChain 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.metainfo import MetaInfo from app.core.security import verify_token, verify_apitoken @@ -105,16 +105,38 @@ def select_accessible_subscribe( 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( - 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]: - """按统一媒体键查询订阅,并兼容迁移前的专用 ID 字段。""" + """按统一媒体键及音乐实体查询订阅,并兼容迁移前的专用 ID 字段。""" source, media_id = parse_media_key(media_key) 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( - 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(): 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(): 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: return [ subscribe for subscribe in unique_subscribes.values() @@ -218,6 +244,12 @@ async def update_subscribe( old_subscribe_dict = subscribe.to_dict() subscribe_dict = subscribe_in.to_public_write_payload() 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): # 扩大目标范围时,新增加的集数尚无下载事实,应同步计入缺失集数。 subscribe_dict["lack_episode"] = (subscribe.lack_episode or 0) + ( @@ -281,13 +313,14 @@ async def subscribe_mediaid( mediaid: str, season: Optional[int] = None, title: Optional[str] = None, + music_type: Optional[str] = None, db: AsyncSession = Depends(get_async_db), current_user: User = Depends(get_current_active_user_async), ) -> Any: """ 根据 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) source, _ = parse_media_key(mediaid) 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( 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) return result if result else Subscribe() @@ -429,13 +467,14 @@ async def search_subscribe( async def delete_subscribe_by_mediaid( mediaid: str, season: Optional[int] = None, + music_type: Optional[str] = None, db: AsyncSession = Depends(get_async_db), current_user: User = Depends(get_current_active_user_async), ) -> Any: """ 根据任意媒体数据源 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 = [] for subscribe in [ subscribe diff --git a/app/chain/music.py b/app/chain/music.py index 9d75f5f91..10d26340f 100644 --- a/app/chain/music.py +++ b/app/chain/music.py @@ -39,18 +39,21 @@ class MusicChain(ChainBase): @classmethod def build_site_keywords(cls, music: MetaMusic | MusicInfo) -> list[str]: - """根据音乐元数据生成按精确度递减的站点搜索关键词。""" + """按单曲或专辑实体生成站点关键词,避免单曲订阅优先搜到所属整专。""" artists = music.artists or [] artist = artists[0] if artists else music.album_artist keywords = [] - if artist and music.album: - keywords.append(f"{artist} {music.album}") - if artist and music.title: - keywords.append(f"{artist} {music.title}") - if music.album: - keywords.append(music.album) - if music.title: - keywords.append(music.title) + if getattr(music, "music_type", None) == MUSIC_ENTITY_ALBUM: + album = music.album or music.title + if artist and album: + keywords.append(f"{artist} {album}") + if album: + keywords.append(album) + else: + if artist and music.title: + keywords.append(f"{artist} {music.title}") + if music.title: + keywords.append(music.title) return cls._unique_texts(keywords) @classmethod diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index c7ec19ea7..a5fa54cde 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -19,6 +19,7 @@ from app.chain.torrents import TorrentsChain from app.core.config import settings, global_vars from app.core.context import ( MUSIC_ENTITY_ALBUM, + MUSIC_ENTITY_RECORDING, Context, MediaInfo, MusicInfo, @@ -71,7 +72,7 @@ def build_subscribe_meta(subscribe: Subscribe) -> MetaBase: title=subscribe.name, album=subscribe.name if is_album else None, 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_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}') 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 season is None: @@ -1103,7 +1113,8 @@ class SubscribeChain(ChainBase): "media_source": media_source, "media_id": media_id, "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, "poster": mediainfo.get_poster_image(), "backdrop": mediainfo.get_backdrop_image(), @@ -1201,6 +1212,15 @@ class SubscribeChain(ChainBase): logger.warn(f'未识别到媒体信息,标题:{title},tmdbid:{tmdbid},doubanid:{doubanid}') 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 season is None: @@ -1321,7 +1341,8 @@ class SubscribeChain(ChainBase): "media_source": media_source, "media_id": media_id, "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, "poster": mediainfo.get_poster_image(), "backdrop": mediainfo.get_backdrop_image(), @@ -1344,12 +1365,59 @@ class SubscribeChain(ChainBase): anilistid=mediainfo.anilist_id, media_source=media_source, 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, episode_group=mediainfo.episode_group, ): return True 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 def _recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]: """按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。""" @@ -1361,8 +1429,8 @@ class SubscribeChain(ChainBase): mtype=MediaType.MUSIC, ) if mediainfo: - return mediainfo - if getattr(subscribe, "music_type", None): + return SubscribeChain._ensure_music_subscribe_entity(subscribe, mediainfo) + if getattr(subscribe, "music_type", None) in {MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM}: return SubscribeChain._music_info_from_subscribe(subscribe) # 旧订阅没有保存实体类型时不能猜测为单曲,否则可能误把专辑按单曲完成。 return None @@ -1387,8 +1455,8 @@ class SubscribeChain(ChainBase): mtype=MediaType.MUSIC, ) if mediainfo: - return mediainfo - if getattr(subscribe, "music_type", None): + return SubscribeChain._ensure_music_subscribe_entity(subscribe, mediainfo) + if getattr(subscribe, "music_type", None) in {MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM}: return SubscribeChain._music_info_from_subscribe(subscribe) return None if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM: @@ -1411,7 +1479,8 @@ class SubscribeChain(ChainBase): title=subscribe.name, album=subscribe.name if music_type == MUSIC_ENTITY_ALBUM 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), ) @@ -1421,8 +1490,9 @@ class SubscribeChain(ChainBase): update_data = {} if mediainfo.music_type and getattr(subscribe, "music_type", None) != mediainfo.music_type: update_data["music_type"] = mediainfo.music_type - if mediainfo.total_tracks and getattr(subscribe, "total_tracks", None) != mediainfo.total_tracks: - update_data["total_tracks"] = mediainfo.total_tracks + total_tracks = mediainfo.total_tracks if mediainfo.music_type == MUSIC_ENTITY_ALBUM else None + if getattr(subscribe, "total_tracks", None) != total_tracks: + update_data["total_tracks"] = total_tracks if not update_data: return SubscribeOper().update(subscribe.id, update_data) @@ -2448,9 +2518,11 @@ class SubscribeChain(ChainBase): "total_episode": total_episode, } if meta.type == MediaType.MUSIC: + music_type = getattr(mediainfo, "music_type", None) update_data.update({ - "music_type": getattr(mediainfo, "music_type", None), - "total_tracks": getattr(mediainfo, "total_tracks", None), + "music_type": music_type, + "total_tracks": getattr(mediainfo, "total_tracks", None) + if music_type == MUSIC_ENTITY_ALBUM else None, }) update_data.update(progress_update) for key, value in progress_update.items(): @@ -2479,6 +2551,7 @@ class SubscribeChain(ChainBase): if k in [ "type", "season", "tmdbid", "doubanid", "bangumiid", "anilistid", "media_source", "media_id", + "music_type", ] } # 暂时不考虑订阅历史, 若有必要再添加 @@ -2525,6 +2598,7 @@ class SubscribeChain(ChainBase): anilistid=share_sub.get("anilistid"), media_source=share_sub.get("media_source"), media_id=share_sub.get("media_id"), + music_type=share_sub.get("music_type"), season=share_sub.get("season"), episode_group=share_sub.get("episode_group")): continue @@ -2535,6 +2609,7 @@ class SubscribeChain(ChainBase): anilistid=share_sub.get("anilistid"), media_source=share_sub.get("media_source"), media_id=share_sub.get("media_id"), + music_type=share_sub.get("music_type"), season=share_sub.get("season"), episode_group=share_sub.get("episode_group")): continue @@ -2546,7 +2621,7 @@ class SubscribeChain(ChainBase): subscribe_in = schemas.Subscribe(**share_sub) mtype = MediaType(subscribe_in.type) # 豆瓣标题处理 - if ( + if mtype != MediaType.MUSIC and ( subscribe_in.doubanid or subscribe_in.bangumiid or subscribe_in.anilistid @@ -2573,6 +2648,8 @@ class SubscribeChain(ChainBase): anilistid=subscribe_in.anilistid, media_source=subscribe_in.media_source, media_id=subscribe_in.media_id, + music_type=subscribe_in.music_type, + total_tracks=subscribe_in.total_tracks, username="订阅分享", best_version=subscribe_in.best_version, save_path=subscribe_in.save_path, @@ -2711,8 +2788,10 @@ class SubscribeChain(ChainBase): # 电影只有一个条目,设置为 [1] items = [1] 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: continue # 合并已下载的集数或电影项(去重) @@ -4627,6 +4706,7 @@ class SubscribeChain(ChainBase): 'anilistid': subscribe.anilistid, 'media_source': subscribe.media_source, 'media_id': subscribe.media_id, + 'music_type': getattr(subscribe, 'music_type', None), } return f"Subscribe|{json.dumps(source_keyword, ensure_ascii=False)}" diff --git a/app/db/models/subscribe.py b/app/db/models/subscribe.py index 60757534d..3b876af51 100644 --- a/app/db/models/subscribe.py +++ b/app/db/models/subscribe.py @@ -1,7 +1,7 @@ import time 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.orm import Session @@ -113,10 +113,17 @@ class Subscribe(Base): doubanid: Optional[str] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None, + music_type: Optional[str] = None, ): """按统一媒体身份优先级构造订阅查询条件。""" 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: return cls.tmdbid == tmdbid if doubanid: @@ -135,10 +142,11 @@ class Subscribe(Base): anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, season: Optional[int] = None, episode_group: Optional[str] = None, + music_type: Optional[str] = None, ): """按媒体身份、季号与剧集组查询已有订阅。""" 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: return None @@ -156,10 +164,11 @@ class Subscribe(Base): anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, season: Optional[int] = None, episode_group: Optional[str] = None, + music_type: Optional[str] = None, ): """异步按媒体身份、季号与剧集组查询已有订阅。""" 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: return None @@ -178,6 +187,7 @@ class Subscribe(Base): anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, season: Optional[int] = None, episode_group: Optional[str] = None, + music_type: Optional[str] = None, ): """ 按订阅 owner、媒体身份、季号与剧集组查询订阅行。 @@ -185,7 +195,7 @@ class Subscribe(Base): if not username: return None 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: return None @@ -203,6 +213,7 @@ class Subscribe(Base): anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, season: Optional[int] = None, episode_group: Optional[str] = None, + music_type: Optional[str] = None, ): """ 异步按订阅 owner、媒体身份、季号与剧集组查询订阅行。 @@ -210,7 +221,7 @@ class Subscribe(Base): if not username: return None 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: return None @@ -364,14 +375,15 @@ class Subscribe(Base): @async_db_query async def async_list_by_media_identity( cls, db: AsyncSession, media_source: str, media_id: str, + music_type: Optional[str] = None, ): """异步按统一媒体身份查询候选订阅列表。""" - result = await db.execute( - select(cls).filter( - cls.media_source == media_source, - cls.media_id == str(media_id), - ) + condition = cls._identity_condition( + media_source=media_source, + media_id=media_id, + music_type=music_type, ) + result = await db.execute(select(cls).filter(condition)) return result.scalars().all() @classmethod @@ -405,12 +417,13 @@ class Subscribe(Base): tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, + music_type: Optional[str] = None, ): """ 根据条件查询订阅 """ 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: return None @@ -426,12 +439,13 @@ class Subscribe(Base): tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, + music_type: Optional[str] = None, ): """ 根据条件查询订阅 """ 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: return None diff --git a/app/db/models/subscribehistory.py b/app/db/models/subscribehistory.py index 3178f5d3a..29ee96fd9 100644 --- a/app/db/models/subscribehistory.py +++ b/app/db/models/subscribehistory.py @@ -1,6 +1,6 @@ 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.orm import Session @@ -144,10 +144,17 @@ class SubscribeHistory(Base): doubanid: Optional[str] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None, + music_type: Optional[str] = None, ): """按统一媒体身份优先级构造订阅历史查询条件。""" 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: return cls.tmdbid == tmdbid if doubanid: @@ -166,10 +173,11 @@ class SubscribeHistory(Base): anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, season: Optional[int] = None, episode_group: Optional[str] = None, + music_type: Optional[str] = None, ): """按媒体身份、季号及可选剧集组查询订阅历史。""" 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: return None @@ -187,10 +195,11 @@ class SubscribeHistory(Base): anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, season: Optional[int] = None, episode_group: Optional[str] = None, + music_type: Optional[str] = None, ): """异步按媒体身份、季号及可选剧集组查询订阅历史。""" 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: return None diff --git a/app/db/subscribe_oper.py b/app/db/subscribe_oper.py index 984a34873..82df7cf22 100644 --- a/app/db/subscribe_oper.py +++ b/app/db/subscribe_oper.py @@ -1,7 +1,7 @@ import time 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.models.subscribe import Subscribe from app.db.models.subscribehistory import SubscribeHistory @@ -36,9 +36,11 @@ def _music_subscription_fields(mediainfo: MediaInfo | MusicInfo) -> dict: """从标准媒体信息提取音乐订阅需要持久化的专辑级字段。""" if mediainfo.type != MediaType.MUSIC: return {"music_type": None, "total_tracks": None} + music_type = getattr(mediainfo, "music_type", None) return { - "music_type": getattr(mediainfo, "music_type", None), - "total_tracks": getattr(mediainfo, "total_tracks", None), + "music_type": music_type, + "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, "media_source": media_source, "media_id": media_id, + "music_type": getattr(mediainfo, "music_type", None) + if mediainfo.type == MediaType.MUSIC else None, "season": kwargs.get("season"), "episode_group": mediainfo.episode_group, } @@ -128,6 +132,8 @@ class SubscribeOper(DbOper): "anilistid": mediainfo.anilist_id, "media_source": media_source, "media_id": media_id, + "music_type": getattr(mediainfo, "music_type", None) + if mediainfo.type == MediaType.MUSIC else None, "season": kwargs.get("season"), "episode_group": mediainfo.episode_group, } @@ -178,6 +184,7 @@ class SubscribeOper(DbOper): bangumiid: Optional[int] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, season: Optional[int] = None, episode_group: Optional[str] = None, + music_type: Optional[str] = None, ) -> bool: """ 按媒体身份、季号及可选剧集组判断订阅是否存在。 @@ -189,6 +196,7 @@ class SubscribeOper(DbOper): "anilistid": anilistid, "media_source": media_source, "media_id": media_id, + "music_type": music_type, "season": season, "episode_group": episode_group, } @@ -211,13 +219,14 @@ class SubscribeOper(DbOper): tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, + music_type: Optional[str] = None, ) -> Optional[Subscribe]: """ 根据条件查询订阅 """ return Subscribe.get_by( self._db, type, season, tmdbid, doubanid, bangumiid, anilistid, - media_source, media_id, + media_source, media_id, music_type, ) async def async_get_by( @@ -225,13 +234,14 @@ class SubscribeOper(DbOper): tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, + music_type: Optional[str] = None, ) -> Optional[Subscribe]: """ 根据条件查询订阅 """ return await Subscribe.async_get_by( 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]: @@ -327,6 +337,7 @@ class SubscribeOper(DbOper): bangumiid: Optional[int] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, season: Optional[int] = None, episode_group: Optional[str] = None, + music_type: Optional[str] = None, ) -> bool: """ 按媒体身份、季号及可选剧集组判断订阅历史是否存在。 @@ -338,6 +349,7 @@ class SubscribeOper(DbOper): "anilistid": anilistid, "media_source": media_source, "media_id": media_id, + "music_type": music_type, "season": season, "episode_group": episode_group, } diff --git a/tests/test_music_chain.py b/tests/test_music_chain.py index 86f01988e..f0c1f9b18 100644 --- a/tests/test_music_chain.py +++ b/tests/test_music_chain.py @@ -25,8 +25,9 @@ def test_parse_query_keeps_plain_title(): def test_build_site_keywords_prefers_artist_album(): - """站点关键词应优先使用艺术家和专辑组合。""" + """专辑订阅只按艺术家与专辑名搜索,不混入其中某首单曲。""" info = MusicInfo( + music_type="album", title="Get Lucky", artists=["Daft Punk"], album="Random Access Memories", @@ -34,8 +35,21 @@ def test_build_site_keywords_prefers_artist_album(): assert MusicChain.build_site_keywords(info) == [ "Daft Punk Random Access Memories", - "Daft Punk Get Lucky", "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", ] diff --git a/tests/test_music_subscribe.py b/tests/test_music_subscribe.py index 244b7e2e3..56413c915 100644 --- a/tests/test_music_subscribe.py +++ b/tests/test_music_subscribe.py @@ -2,7 +2,7 @@ from types import SimpleNamespace from unittest.mock import Mock, patch 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.context import MusicInfo 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) search_chain.search_by_title.assert_called_once_with( - title="周杰伦 叶惠美", + title="周杰伦 晴天", sites=[], mtype=MediaType.MUSIC, rule_groups=[], @@ -253,6 +253,47 @@ def test_album_subscription_finishes_only_after_confirmed_full_pack(): ) 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(): """音乐订阅新增应走统一 recognize_by_meta,并把媒体身份落到 MetaMusic 上。""" 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" +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(): """统一识别返回离线兜底(无远端 source)时订阅应直接失败,不写入数据库。""" offline = MusicInfo(title="未知曲目", artists=["未知艺术家"]) @@ -304,3 +368,39 @@ def test_subscribe_add_music_fails_fast_on_offline_fallback(): assert sid is None assert err_msg == "未识别到媒体信息" 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 diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index 72ef1c4c5..7824510e9 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -79,6 +79,7 @@ def _load_subscribe_chain_class(): context_module.MediaInfo = SimpleNamespace context_module.MusicInfo = SimpleNamespace context_module.MUSIC_ENTITY_ALBUM = "album" + context_module.MUSIC_ENTITY_RECORDING = "recording" event_module = ensure_module("app.core.event", types.ModuleType("app.core.event")) diff --git a/tests/test_subscribe_endpoint.py b/tests/test_subscribe_endpoint.py index 6d4992754..9d3f4aa47 100644 --- a/tests/test_subscribe_endpoint.py +++ b/tests/test_subscribe_endpoint.py @@ -220,6 +220,53 @@ class SubscribeEndpointTest(TestCase): self.assertNotIn("username", payload["fields"]) 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): """ 超级用户可以管理他人和 legacy 订阅。 @@ -311,6 +358,43 @@ class SubscribeEndpointTest(TestCase): 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): """ 按媒体删除订阅时,应在候选集合中删除当前用户自己的订阅。 @@ -347,6 +431,32 @@ class SubscribeEndpointTest(TestCase): self.assertEqual(db.deleted, [own]) 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): """ 普通用户批量搜索只按自己的订阅 ID 入队。 diff --git a/tests/test_subscribe_oper.py b/tests/test_subscribe_oper.py index 6eddce822..beae501ce 100644 --- a/tests/test_subscribe_oper.py +++ b/tests/test_subscribe_oper.py @@ -168,6 +168,35 @@ def test_music_album_subscription_persists_entity_and_track_count(): 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"]) 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) +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(): """订阅前置存在性检查必须查询当前剧集组,不能退回主季范围。""" from app.chain.subscribe import SubscribeChain @@ -312,6 +390,7 @@ def test_subscribe_chain_exists_forwards_episode_group(): anilistid=media.anilist_id, media_source="themoviedb", media_id=str(media.tmdb_id), + music_type=None, season=1, episode_group="eg-1", ) diff --git a/tests/test_subscribe_source_keyword.py b/tests/test_subscribe_source_keyword.py index b1ab57f59..94b5c932f 100644 --- a/tests/test_subscribe_source_keyword.py +++ b/tests/test_subscribe_source_keyword.py @@ -21,6 +21,7 @@ def test_subscribe_source_keyword_includes_episode_group(): anilistid=None, media_source="themoviedb", media_id="12345", + music_type=None, ) source = SubscribeChain.get_subscribe_source_keyword(subscribe)