mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-15 19:14:01 +08:00
fix(music): separate album and recording subscriptions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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 入队。
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user