feat(v3): add music automation workflow

This commit is contained in:
jxxghp
2026-08-07 21:50:22 +08:00
parent c6bd396794
commit 05d15a04f5
65 changed files with 3505 additions and 257 deletions

View File

@@ -1,12 +1,14 @@
from typing import Any, List, Annotated, Literal, Optional
from typing import Any, List, Annotated, Literal, Optional, Union
from fastapi import APIRouter, Depends, Body
from app import schemas
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.core.context import MediaInfo, Context, SubtitleInfo, TorrentInfo
from app.core.metainfo import MetaInfo
from app.core.music import MusicInfo
from app.core.security import verify_token
from app.db.models.user import User
from app.db.site_oper import SiteOper
@@ -17,7 +19,7 @@ from app.schemas.types import SystemConfigKey
from app.utils.security import SecurityUtils
router = APIRouter()
MediaSource = Literal["themoviedb", "douban", "bangumi", "anilist"]
MediaSource = Literal["themoviedb", "douban", "bangumi", "anilist", "musicbrainz"]
def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]:
@@ -57,7 +59,7 @@ def current(
@router.post("/", summary="添加下载(含媒体信息)", response_model=schemas.Response)
def download(
media_in: schemas.MediaInfo,
media_in: Union[schemas.MusicInfo, schemas.MediaInfo],
torrent_in: schemas.TorrentInfo,
downloader: Annotated[str | None, Body()] = None,
save_path: Annotated[str | None, Body()] = None,
@@ -66,11 +68,14 @@ def download(
"""
添加下载任务(含媒体信息)
"""
# 元数据
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
# 媒体信息
mediainfo = MediaInfo()
mediainfo.from_dict(media_in.model_dump())
if isinstance(media_in, schemas.MusicInfo):
mediainfo = MusicInfo.from_dict(media_in.model_dump())
metainfo = MusicChain.to_meta(mediainfo)
metainfo.org_string = torrent_in.title
else:
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
mediainfo = MediaInfo()
mediainfo.from_dict(media_in.model_dump())
# 种子信息
torrentinfo = TorrentInfo()
torrentinfo.from_dict(torrent_in.model_dump())

View File

@@ -0,0 +1,70 @@
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from app import schemas
from app.chain.music import MusicChain
from app.core.music import MusicInfo
from app.core.security import verify_token
router = APIRouter()
CountParam = Annotated[int, Query(ge=1, le=100)]
def _serialize_music(info: MusicInfo) -> schemas.MusicInfo:
"""将内部音乐信息转换为 REST 响应模型。"""
return schemas.MusicInfo(**info.to_dict())
@router.get(
"/search",
summary="搜索音乐元数据",
response_model=list[schemas.MusicInfo],
)
async def search_music(
query: str = Query(min_length=1),
count: CountParam = 20,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MusicInfo]:
"""按歌曲、专辑或艺术家关键词搜索标准音乐候选。"""
results = await MusicChain().async_search(query=query, limit=count)
return [_serialize_music(info) for info in results]
@router.post(
"/recognize",
summary="识别音乐元数据详情",
response_model=schemas.MusicInfo,
)
async def recognize_music(
request: schemas.MusicRecognizeRequest,
_: schemas.TokenPayload = Depends(verify_token),
) -> schemas.MusicInfo:
"""根据音乐元数据来源和媒体 ID 获取标准详情。"""
info = await MusicChain().async_recognize(
source=request.source,
media_id=request.media_id,
)
if not info:
raise HTTPException(status_code=404, detail="未识别到音乐信息")
return _serialize_music(info)
@router.get(
"/explore",
summary="探索热门音乐",
response_model=list[schemas.MusicInfo],
)
async def explore_music(
page: Annotated[int, Query(ge=1)] = 1,
count: CountParam = 30,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MusicInfo]:
"""按月度全站收听榜单分页返回可搜索和订阅的音乐候选。"""
results = await MusicChain().async_chart(
range_name="this_month",
page=page,
count=count,
)
return [_serialize_music(info) for info in results]

View File

@@ -60,6 +60,20 @@ async def bangumi_calendar(
return await RecommendChain().async_bangumi_calendar(page=page, count=count)
@router.get(
"/music_weekly",
summary="ListenBrainz 本周热门音乐",
response_model=List[schemas.MusicInfo],
)
async def music_weekly(
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""浏览本周全站热门音乐。"""
return await RecommendChain().async_music_weekly(page=page, count=count)
@router.get(
"/douban_showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo]
)

View File

@@ -475,6 +475,7 @@ async def search_by_id(
async def search_by_title_stream(
request: Request,
keyword: Optional[str] = None,
mtype: Optional[str] = None,
page: Optional[int] = 0,
sites: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_resource_token),
@@ -484,7 +485,11 @@ async def search_by_title_stream(
"""
event_source = SearchChain().async_search_by_title_stream(
title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True
title=keyword,
page=page,
sites=_parse_site_list(sites),
cache_local=True,
mtype=_parse_media_type(mtype),
)
return StreamingResponse(
_stream_search_events(request, event_source),
@@ -496,6 +501,7 @@ async def search_by_title_stream(
@router.get("/title", summary="模糊搜索资源", response_model=schemas.Response)
async def search_by_title(
keyword: Optional[str] = None,
mtype: Optional[str] = None,
page: Optional[int] = 0,
sites: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
@@ -504,7 +510,11 @@ async def search_by_title(
根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源
"""
torrents = await SearchChain().async_search_by_title(
title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True
title=keyword,
page=page,
sites=_parse_site_list(sites),
cache_local=True,
mtype=_parse_media_type(mtype),
)
if not torrents:
return schemas.Response(success=False, message="未搜索到任何资源")

View File

@@ -30,7 +30,7 @@ from app.db.user_oper import (
from app.helper.sites import SitesHelper # noqa
from app.log import logger
from app.scheduler import Scheduler
from app.schemas.types import SystemConfigKey, EventType
from app.schemas.types import SystemConfigKey, EventType, MediaType
from app.utils.string import StringUtils
router = APIRouter()
@@ -394,6 +394,7 @@ async def site_category(
async def site_resource(
site_id: int,
keyword: Optional[str] = None,
mtype: Optional[str] = None,
cat: Optional[str] = None,
page: Optional[int] = 0,
db: AsyncSession = Depends(get_async_db),
@@ -409,7 +410,11 @@ async def site_resource(
detail=f"站点 {site_id} 不存在",
)
torrents = await TorrentsChain().async_browse(
domain=site.domain, keyword=keyword, cat=cat, page=page
domain=site.domain,
keyword=keyword,
cat=cat,
page=page,
mtype=MediaType.from_agent(mtype) or MediaType(mtype) if mtype else None,
)
if not torrents:
return []

View File

@@ -173,10 +173,13 @@ async def create_subscribe(
mtype = None
# 非 TMDB 来源的标题可能自带季标记,入库前统一拆分。
if (
subscribe_in.doubanid
or subscribe_in.bangumiid
or subscribe_in.anilistid
or normalize_media_source(subscribe_in.media_source) not in (None, "themoviedb")
mtype != MediaType.MUSIC
and (
subscribe_in.doubanid
or subscribe_in.bangumiid
or subscribe_in.anilistid
or normalize_media_source(subscribe_in.media_source) not in (None, "themoviedb")
)
):
meta = MetaInfo(subscribe_in.name)
subscribe_in.name = meta.name