mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 10:14:36 +08:00
feat(agent): complete music workflow support
This commit is contained in:
@@ -30,7 +30,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
|
||||
|
||||
<moviepilot_domain_model>
|
||||
- Treat sites as a first-class system capability, not background detail. In MoviePilot, sites are the upstream source for search, account status, authentication, and many download or subscription decisions.
|
||||
- Understand the platform's core workflow as: site availability and configuration -> media search -> media recognition/metadata confirmation -> manual download or subscription -> transfer and library organization -> status/history confirmation.
|
||||
- Understand the platform's core workflow as: site availability and configuration -> media search -> media recognition/metadata confirmation -> manual download or subscription -> transfer and library organization -> metadata scraping (including configured music lyrics) -> status/history confirmation.
|
||||
- Treat manual download and subscription automation as two execution modes of the same acquisition pipeline. Manual download is user-triggered immediate acquisition; subscription is persistent site-driven monitoring and acquisition.
|
||||
- Keep the user anchored to the operational step that matters now: site, search, recognition, download, subscription, transfer, or status/history.
|
||||
- Users may attach images from supported channels; analyze them together with the text when relevant.
|
||||
@@ -48,7 +48,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
|
||||
|
||||
<core_workflow>
|
||||
1. Site and Context Check: Determine whether site status, site scope, library state, existing subscriptions, or prior download/transfer history can affect the task.
|
||||
2. Media Identity Resolution: Confirm exact media identity such as TMDB ID, title, year, type, season, or episode using `search_media`, `query_media_detail`, or `recognize_media` as needed.
|
||||
2. Media Identity Resolution: Confirm an exact source-native identity. Video normally uses TMDB/Douban/Bangumi/AniList IDs plus title, year, type, season, or episode. Music uses `media_type=music`, MusicBrainz `media_source` + `media_id`, and `music_type=recording|album|artist`, plus artist/title/album when available. Use `search_media`, `query_media_detail`, or `recognize_media` as needed.
|
||||
3. Resource Discovery: Use the appropriate search path for the task. For manual acquisition, search site resources and inspect result quality. For automation, prepare subscription conditions that will search sites continuously.
|
||||
4. Action Execution: Perform the requested task, typically one of: test/query site, search torrents, add download, add or modify subscription, or transfer and organize files.
|
||||
5. Final Confirmation: State the outcome briefly, including the key media facts, chosen site or resource scope when relevant, and the next blocker if the task could not be completed.
|
||||
@@ -81,6 +81,13 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
|
||||
6. Transfer Awareness: If the user asks about downloaded files landing in the library, include transfer or organization state in the reasoning, not just download completion.
|
||||
7. Error Handling: If a tool or site fails, briefly explain what went wrong and suggest an alternative or the next best operational step.
|
||||
8. TV Subscription Rule: When calling `add_subscribe` for a TV show, omitting `season` means subscribe to season 1 only. To subscribe multiple seasons or the full series, call `add_subscribe` separately for each season.
|
||||
9. Music Entity Rule: A recording is one track, an album is a collection of tracks, and an artist is browse-only. Never subscribe, search torrents for, download, transfer, or library-check an artist entity.
|
||||
10. Music Identity Rule: Reuse the exact `media_source`, `media_id`, and `music_type` returned by music search/detail. Do not substitute a same-name track, album, or artist, and do not send TV-only season/episode arguments for music.
|
||||
11. Recording Rule: A recording subscription or download targets one track. Use the recording ID and organize/scrape one audio file unless the user explicitly selected a different recording.
|
||||
12. Album Rule: An album subscription or download targets the complete album, similar to a TV season pack. Use the album ID, prefer one album resource/directory, require the resource file list to cover the expected `total_tracks`, and do not mark the subscription complete or report the album as present when only some tracks exist.
|
||||
13. Music Library Rule: Before a music download or subscription, use `query_library_exists` with the exact recording/album identity when duplicate risk matters. For albums, a negative result can mean either absent or incomplete and should remain eligible for acquisition.
|
||||
14. Music Organization Rule: Organize one recording as one audio file with `music_type=recording`. Organize a complete album by passing its album directory once with `media_type=music`, `music_type=album`, and the album source-native identity; do not repeatedly identify every track as an unrelated media item.
|
||||
15. Music Scraping Rule: `scrape_metadata(media_type="music")` applies configured audio-tag, cover, and lyrics policies. For an album directory, pass the album identity when known; without one, allow per-file tag recognition. Report the actual lyrics saved/existing/missing counts returned by the tool.
|
||||
</media_rules>
|
||||
</agent_core>
|
||||
|
||||
|
||||
@@ -40,13 +40,13 @@ task_types:
|
||||
- "Analyze the error message to determine the best retry strategy."
|
||||
- "If the source file no longer exists, skip this retry and report that the file is missing."
|
||||
- "Delete the failed history record using `delete_transfer_history` with history_id={history_id}."
|
||||
- "Re-identify the media using `recognize_media` with the source file path."
|
||||
- "If recognition fails, try `search_media` with keywords from the filename."
|
||||
- "Re-transfer using `transfer_file` with the source path and any identified media info such as tmdbid and media_type."
|
||||
- "Re-identify the media using `recognize_media` with the source file path. For audio files, set media_type='music' and preserve artist/title/album context."
|
||||
- "If recognition fails, try `search_media` with keywords from the filename. For music, distinguish recording, album, and browse-only artist results."
|
||||
- "Re-transfer using `transfer_file` with the source path and exact identity fields. Reuse media_source + media_id + media_type + music_type for music, or the applicable video IDs."
|
||||
- "Report the final result."
|
||||
batch_transfer_failed_retry:
|
||||
header: "[System Task - Batch Transfer Failed Retry]"
|
||||
objective: "Multiple file transfers from the same source have failed. These files likely belong to the same media. Please use the `transfer-failed-retry` skill to retry them efficiently."
|
||||
objective: "Multiple file transfers have failed. Group only records that share a trustworthy movie, series, recording, or album identity, then use the `transfer-failed-retry` skill to retry them efficiently."
|
||||
context_title: "Task context"
|
||||
context_lines:
|
||||
- "Failed transfer history record IDs: {history_ids_csv}"
|
||||
@@ -54,12 +54,12 @@ task_types:
|
||||
steps_title: "Follow these steps"
|
||||
steps:
|
||||
- "Use `query_transfer_history` with status='failed' to find all records with these IDs and understand the failure details."
|
||||
- "Analyze the first record to determine the shared media identity and the best retry strategy because the root cause is usually the same for all files."
|
||||
- "If the error is about media recognition, identify the media once using `recognize_media` or `search_media`, then reuse that result for all files."
|
||||
- "Group records by exact media identity and source directory before retrying. Do not assume all selected files belong to one media."
|
||||
- "If the error is about media recognition, identify each group once using `recognize_media` or `search_media`, then reuse that result inside the group. Album tracks should normally be retried from the shared album directory with the album identity."
|
||||
- "For each failed record, delete the old history entry with `delete_transfer_history` and re-transfer using `transfer_file`."
|
||||
- "Report how many retries succeeded and how many still failed."
|
||||
task_rules:
|
||||
- "These files share the same media identity. Do NOT call `recognize_media` or `search_media` repeatedly for each file."
|
||||
- "Within one verified group, do NOT call `recognize_media` or `search_media` repeatedly for each file. A music recording is one track; a music album is one multi-track directory; an artist is never a transfer target."
|
||||
manual_transfer_redo:
|
||||
header: "[System Task - Manual Transfer Re-Organize]"
|
||||
objective: "A user manually triggered an AI re-organize task from the transfer history page."
|
||||
@@ -83,6 +83,8 @@ task_types:
|
||||
- "- Current AniList ID: {anilistid}"
|
||||
- "- Current media source: {media_source}"
|
||||
- "- Current source-native ID: {media_id}"
|
||||
- "- Music entity type: {music_type}"
|
||||
- "- Expected album tracks: {total_tracks}"
|
||||
- "- Error message: {error_message}"
|
||||
steps_title: "Required workflow"
|
||||
steps:
|
||||
@@ -94,7 +96,7 @@ task_types:
|
||||
- "Only continue when you have high confidence in the target media."
|
||||
- "Before re-organizing, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
|
||||
- "Then use `transfer_file` to organize the source path directly."
|
||||
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, and media_type."
|
||||
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, media_type, and music_type. For an album, retry the album directory once with the album identity when the records share that directory."
|
||||
- "If this record is already correct and no re-organize is needed, do not perform destructive actions; simply report that no change is necessary."
|
||||
task_rules:
|
||||
- "Do NOT rely on previous chat context. Work only from the record above."
|
||||
@@ -116,11 +118,11 @@ task_types:
|
||||
- "Review the selected records below first and group them by likely shared media identity, source directory, or retry strategy when possible."
|
||||
- "Use the provided record context as the primary source of truth. Call `query_transfer_history` only when you need extra confirmation."
|
||||
- "For each group, decide whether the current recognition is trustworthy."
|
||||
- "If multiple records clearly belong to the same movie or series, identify the media once with `recognize_media` or `search_media`, then reuse that result for the related records."
|
||||
- "If multiple records clearly belong to the same movie, series, or music album, identify the media once with `recognize_media` or `search_media`, then reuse that result for the related records. A recording remains a single-track target, and an artist is browse-only."
|
||||
- "If a source file no longer exists or cannot be safely processed, skip that record and note the reason."
|
||||
- "Before re-organizing a record, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
|
||||
- "Then use `transfer_file` to organize the source path directly."
|
||||
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, and media_type."
|
||||
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, media_type, and music_type. Prefer one directory transfer for a verified complete album instead of treating each track as an unrelated media item."
|
||||
- "If a record is already correct and no re-organize is needed, do not perform destructive actions; simply mark it as skipped."
|
||||
- "Report only the aggregate outcome, including how many records succeeded, skipped, and failed."
|
||||
task_rules:
|
||||
|
||||
@@ -17,6 +17,7 @@ def build_manual_redo_template_context(history: Any) -> dict[str, int | str]:
|
||||
source_storage = history.dest_storage or "local"
|
||||
source_path = source_path or history.src or ""
|
||||
season_episode = f"{history.seasons or ''}{history.episodes or ''}".strip()
|
||||
is_music = str(history.type or "") in {"music", "音乐"}
|
||||
return {
|
||||
"history_id": history.id,
|
||||
"current_status": "success" if history.status else "failed",
|
||||
@@ -36,6 +37,12 @@ def build_manual_redo_template_context(history: Any) -> dict[str, int | str]:
|
||||
"anilistid": history.anilistid or "none",
|
||||
"media_source": history.media_source or "none",
|
||||
"media_id": history.media_id or "none",
|
||||
"music_type": getattr(history, "music_type", None) or (
|
||||
"unknown" if is_music else "not_applicable"
|
||||
),
|
||||
"total_tracks": getattr(history, "total_tracks", None) or (
|
||||
"unknown" if is_music else "not_applicable"
|
||||
),
|
||||
"error_message": history.errmsg or "none",
|
||||
}
|
||||
|
||||
@@ -63,6 +70,8 @@ def format_manual_redo_record_context(history: Any) -> str:
|
||||
f"- Current AniList ID: {context['anilistid']}",
|
||||
f"- Current media source: {context['media_source']}",
|
||||
f"- Current source-native ID: {context['media_id']}",
|
||||
f"- Music entity type: {context['music_type']}",
|
||||
f"- Expected album tracks: {context['total_tracks']}",
|
||||
f"- Error message: {context['error_message']}",
|
||||
]
|
||||
)
|
||||
|
||||
109
app/agent/tools/impl/_music_utils.py
Normal file
109
app/agent/tools/impl/_music_utils.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Agent 音乐工具共享的实体校验与结果精简函数。"""
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.core.context import (
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
MUSIC_ENTITY_ARTIST,
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
MusicAlbumInfo,
|
||||
MusicArtistInfo,
|
||||
MusicInfo,
|
||||
)
|
||||
from app.schemas.types import media_type_to_agent
|
||||
|
||||
MUSIC_ENTITY_TYPES = frozenset({
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
MUSIC_ENTITY_ARTIST,
|
||||
})
|
||||
MUSIC_SUBSCRIBABLE_TYPES = frozenset({
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
})
|
||||
MUSIC_TRACK_PREVIEW_LIMIT = 100
|
||||
MUSIC_RELEASE_PREVIEW_LIMIT = 20
|
||||
|
||||
|
||||
def normalize_music_type(
|
||||
value: Optional[str],
|
||||
*,
|
||||
allow_artist: bool = True,
|
||||
) -> Optional[str]:
|
||||
"""规范化音乐实体类型,非法值返回 None。"""
|
||||
normalized = str(value or "").strip().lower()
|
||||
allowed = MUSIC_ENTITY_TYPES if allow_artist else MUSIC_SUBSCRIBABLE_TYPES
|
||||
return normalized if normalized in allowed else None
|
||||
|
||||
|
||||
def simplify_music_info(info: MusicInfo) -> dict[str, Any]:
|
||||
"""精简音乐列表项,同时保留订阅和下载所需的稳定身份。"""
|
||||
payload = {
|
||||
"title": info.title,
|
||||
"year": info.year,
|
||||
"type": media_type_to_agent(info.type),
|
||||
"music_type": info.music_type,
|
||||
"artists": list(info.artists or []),
|
||||
"artist_ids": list(info.artist_ids or []),
|
||||
"artist": info.artist,
|
||||
"album": info.album,
|
||||
"album_id": info.album_id,
|
||||
"album_type": info.album_type,
|
||||
"release_date": info.release_date,
|
||||
"disc_number": info.disc_number,
|
||||
"track_number": info.track_number,
|
||||
"total_tracks": info.total_tracks,
|
||||
"duration": info.duration,
|
||||
"isrc": info.isrc,
|
||||
"version": info.version,
|
||||
"genres": list(info.genres or []),
|
||||
"category": info.category,
|
||||
"listen_count": info.listen_count,
|
||||
"media_source": info.source,
|
||||
"media_id": info.media_id,
|
||||
"poster_path": info.poster_path,
|
||||
"detail_link": info.detail_link,
|
||||
"overview": info.overview,
|
||||
}
|
||||
return {key: value for key, value in payload.items() if value not in (None, "", [])}
|
||||
|
||||
|
||||
def simplify_music_album(
|
||||
info: MusicAlbumInfo,
|
||||
*,
|
||||
track_limit: int = MUSIC_TRACK_PREVIEW_LIMIT,
|
||||
) -> dict[str, Any]:
|
||||
"""精简专辑详情并限制曲目和发行版本预览,避免撑大 Agent 上下文。"""
|
||||
normalized_track_limit = max(1, min(track_limit, MUSIC_TRACK_PREVIEW_LIMIT))
|
||||
tracks = list(info.tracks or [])
|
||||
releases = list(info.releases or [])
|
||||
payload = simplify_music_info(info.to_music_info())
|
||||
payload.update({
|
||||
"release_date": info.release_date,
|
||||
"secondary_types": list(info.secondary_types or []),
|
||||
"tags": list(info.tags or []),
|
||||
"rating": info.rating,
|
||||
"rating_votes": info.rating_votes,
|
||||
"tracks": [
|
||||
simplify_music_info(track)
|
||||
for track in tracks[:normalized_track_limit]
|
||||
],
|
||||
"tracks_total": len(tracks),
|
||||
"tracks_truncated": len(tracks) > normalized_track_limit,
|
||||
"releases": [release.to_dict() for release in releases[:MUSIC_RELEASE_PREVIEW_LIMIT]],
|
||||
"releases_total": len(releases),
|
||||
"releases_truncated": len(releases) > MUSIC_RELEASE_PREVIEW_LIMIT,
|
||||
})
|
||||
return payload
|
||||
|
||||
|
||||
def simplify_music_artist(info: MusicArtistInfo) -> dict[str, Any]:
|
||||
"""精简艺术家详情,明确其仅用于浏览而非订阅或下载。"""
|
||||
payload = info.to_dict()
|
||||
payload.pop("raw_data", None)
|
||||
payload.pop("mediaid_prefix", None)
|
||||
payload["type"] = media_type_to_agent(info.type)
|
||||
payload["media_source"] = info.source
|
||||
payload["media_id"] = info.media_id
|
||||
payload["subscribable"] = False
|
||||
return {key: value for key, value in payload.items() if value not in (None, "", [])}
|
||||
@@ -4,8 +4,10 @@ import re
|
||||
from typing import List, Optional
|
||||
|
||||
from app.core.context import Context
|
||||
from app.schemas.types import MediaType, media_type_to_agent
|
||||
from app.utils.crypto import HashUtils
|
||||
from app.utils.string import StringUtils
|
||||
from ._music_utils import simplify_music_info
|
||||
|
||||
SEARCH_RESULT_CACHE_FILE = "__search_result__"
|
||||
TORRENT_RESULT_LIMIT = 50
|
||||
@@ -162,28 +164,44 @@ def simplify_search_result(
|
||||
simplified["torrent_info"]["description"] = torrent_info.description
|
||||
|
||||
if media_info:
|
||||
simplified["media_info"] = {
|
||||
"title": media_info.title,
|
||||
"en_title": media_info.en_title,
|
||||
"year": media_info.year,
|
||||
"type": media_info.type.value if media_info.type else None,
|
||||
"season": media_info.season,
|
||||
"tmdb_id": media_info.tmdb_id,
|
||||
}
|
||||
if getattr(media_info, "type", None) == MediaType.MUSIC:
|
||||
simplified["media_info"] = simplify_music_info(media_info)
|
||||
else:
|
||||
simplified["media_info"] = {
|
||||
"title": getattr(media_info, "title", None),
|
||||
"en_title": getattr(media_info, "en_title", None),
|
||||
"year": getattr(media_info, "year", None),
|
||||
"type": media_type_to_agent(getattr(media_info, "type", None)),
|
||||
"season": getattr(media_info, "season", None),
|
||||
"tmdb_id": getattr(media_info, "tmdb_id", None),
|
||||
}
|
||||
|
||||
if meta_info:
|
||||
simplified["meta_info"] = {
|
||||
"name": meta_info.name,
|
||||
"cn_name": meta_info.cn_name,
|
||||
"en_name": meta_info.en_name,
|
||||
"year": meta_info.year,
|
||||
"type": meta_info.type.value if meta_info.type else None,
|
||||
"begin_season": meta_info.begin_season,
|
||||
"season_episode": meta_info.season_episode,
|
||||
"resource_team": meta_info.resource_team,
|
||||
"video_encode": meta_info.video_encode,
|
||||
"edition": meta_info.edition,
|
||||
"resource_pix": meta_info.resource_pix,
|
||||
}
|
||||
if getattr(meta_info, "type", None) == MediaType.MUSIC:
|
||||
simplified["meta_info"] = {
|
||||
key: getattr(meta_info, key, None)
|
||||
for key in (
|
||||
"title", "artists", "album", "album_artist", "year",
|
||||
"disc_number", "track_number", "total_tracks", "version",
|
||||
"audio_format", "bit_depth", "sample_rate", "bitrate",
|
||||
"duration", "isrc", "media_source", "media_id",
|
||||
)
|
||||
if getattr(meta_info, key, None) not in (None, "", [])
|
||||
}
|
||||
simplified["meta_info"]["type"] = "music"
|
||||
else:
|
||||
simplified["meta_info"] = {
|
||||
"name": getattr(meta_info, "name", None),
|
||||
"cn_name": getattr(meta_info, "cn_name", None),
|
||||
"en_name": getattr(meta_info, "en_name", None),
|
||||
"year": getattr(meta_info, "year", None),
|
||||
"type": media_type_to_agent(getattr(meta_info, "type", None)),
|
||||
"begin_season": getattr(meta_info, "begin_season", None),
|
||||
"season_episode": getattr(meta_info, "season_episode", None),
|
||||
"resource_team": getattr(meta_info, "resource_team", None),
|
||||
"video_encode": getattr(meta_info, "video_encode", None),
|
||||
"edition": getattr(meta_info, "edition", None),
|
||||
"resource_pix": getattr(meta_info, "resource_pix", None),
|
||||
}
|
||||
|
||||
return simplified
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""添加下载任务工具"""
|
||||
|
||||
import copy
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Type, Union
|
||||
@@ -17,7 +18,7 @@ from app.core.metainfo import MetaInfo
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.helper.directory import DirectoryHelper, validate_download_save_path
|
||||
from app.log import logger
|
||||
from app.schemas import FileURI, TorrentInfo
|
||||
from app.schemas import FileURI
|
||||
from app.utils.crypto import HashUtils
|
||||
|
||||
|
||||
@@ -279,18 +280,25 @@ class AddDownloadTasksTool(MoviePilotTool):
|
||||
failed_messages.append(f"{torrent_input} 未找到站点信息 {site_name}")
|
||||
continue
|
||||
|
||||
torrent_info = TorrentInfo(
|
||||
# 复制完整搜索上下文,只刷新可能变化的站点凭据。音乐上下文中的
|
||||
# MetaMusic、专辑总曲目数和完整覆盖状态必须保留到下载校验层。
|
||||
context = copy.copy(cached_context)
|
||||
torrent_info = copy.copy(cached_torrent)
|
||||
torrent_info.title = torrent_title
|
||||
torrent_info.description = torrent_description
|
||||
torrent_info.enclosure = enclosure
|
||||
torrent_info.site_name = site_name
|
||||
torrent_info.site_ua = siteinfo.ua
|
||||
torrent_info.site_cookie = siteinfo.cookie
|
||||
torrent_info.site_proxy = siteinfo.proxy
|
||||
torrent_info.site_order = siteinfo.pri
|
||||
torrent_info.site_downloader = siteinfo.downloader
|
||||
context.torrent_info = torrent_info
|
||||
|
||||
meta_info = cached_context.meta_info or MetaInfo(
|
||||
title=torrent_title,
|
||||
description=torrent_description,
|
||||
enclosure=enclosure,
|
||||
site_name=site_name,
|
||||
site_ua=siteinfo.ua,
|
||||
site_cookie=siteinfo.cookie,
|
||||
site_proxy=siteinfo.proxy,
|
||||
site_order=siteinfo.pri,
|
||||
site_downloader=siteinfo.downloader
|
||||
subtitle=torrent_description,
|
||||
)
|
||||
meta_info = MetaInfo(title=torrent_title, subtitle=torrent_description)
|
||||
media_info = cached_context.media_info if cached_context.media_info else None
|
||||
if not media_info:
|
||||
media_info = await MediaChain().async_recognize_by_meta(
|
||||
@@ -300,12 +308,8 @@ class AddDownloadTasksTool(MoviePilotTool):
|
||||
if not media_info:
|
||||
failed_messages.append(f"{torrent_input} 无法识别媒体信息")
|
||||
continue
|
||||
|
||||
context = Context(
|
||||
torrent_info=torrent_info,
|
||||
meta_info=meta_info,
|
||||
media_info=media_info
|
||||
)
|
||||
context.meta_info = meta_info
|
||||
context.media_info = media_info
|
||||
else:
|
||||
if not self._is_magnet_link_input(torrent_input):
|
||||
failed_messages.append(
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.chain.subscribe import SubscribeChain
|
||||
from app.db.user_oper import UserOper
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType, MessageChannel
|
||||
from ._music_utils import normalize_music_type
|
||||
|
||||
|
||||
class AddSubscribeInput(BaseModel):
|
||||
@@ -19,11 +20,15 @@ class AddSubscribeInput(BaseModel):
|
||||
...,
|
||||
description="The title of the media to subscribe to (e.g., 'The Matrix', 'Breaking Bad')",
|
||||
)
|
||||
year: str = Field(
|
||||
...,
|
||||
description="Release year of the media (required for accurate identification)",
|
||||
year: Optional[str] = Field(
|
||||
None,
|
||||
description="Release year. Recommended for video; optional when a stable music ID is provided",
|
||||
)
|
||||
media_type: str = Field(..., description="Allowed values: movie, tv, music")
|
||||
music_type: Optional[str] = Field(
|
||||
None,
|
||||
description="Required for music subscriptions: recording for one track or album for the complete album. Artists cannot be subscribed",
|
||||
)
|
||||
media_type: str = Field(..., description="Allowed values: movie, tv")
|
||||
season: Optional[int] = Field(
|
||||
None,
|
||||
description=(
|
||||
@@ -74,6 +79,8 @@ class AddSubscribeInput(BaseModel):
|
||||
|
||||
|
||||
class AddSubscribeTool(MoviePilotTool):
|
||||
"""添加电影、电视剧、单曲或整张专辑订阅。"""
|
||||
|
||||
name: str = "add_subscribe"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
@@ -81,8 +88,9 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
ToolTag.Media,
|
||||
]
|
||||
description: str = (
|
||||
"Add media subscription to create automated download rules for movies and TV shows. "
|
||||
"The system will automatically search and download new episodes or releases based on the subscription criteria. "
|
||||
"Add automated subscriptions for movies, TV shows, one music recording, or one complete album. "
|
||||
"Music requires the source-native ID and music_type='recording' or 'album'; artists are browse-only. "
|
||||
"Album subscriptions only complete after a resource is confirmed to cover the album's expected track count. "
|
||||
"For TV shows, omitting `season` subscribes season 1 only by default; to subscribe multiple seasons or "
|
||||
"the full series, call this tool once per season. Supports advanced filtering options like quality, "
|
||||
"resolution, and effect filters using regular expressions."
|
||||
@@ -94,6 +102,7 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
title = kwargs.get("title", "")
|
||||
year = kwargs.get("year", "")
|
||||
media_type = kwargs.get("media_type", "")
|
||||
music_type = kwargs.get("music_type")
|
||||
season = kwargs.get("season")
|
||||
|
||||
message = f"添加订阅: {title}"
|
||||
@@ -101,6 +110,8 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
message += f" ({year})"
|
||||
if media_type:
|
||||
message += f" [{media_type}]"
|
||||
if music_type:
|
||||
message += f" [{music_type}]"
|
||||
if season is not None:
|
||||
message += f" 第{season}季"
|
||||
elif media_type == "tv":
|
||||
@@ -143,8 +154,9 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
async def run(
|
||||
self,
|
||||
title: str,
|
||||
year: str,
|
||||
media_type: str,
|
||||
year: Optional[str] = None,
|
||||
music_type: Optional[str] = None,
|
||||
season: Optional[int] = None,
|
||||
tmdb_id: Optional[int] = None,
|
||||
douban_id: Optional[str] = None,
|
||||
@@ -161,9 +173,11 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
sites: Optional[List[int]] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""校验实体语义后调用订阅链添加精确订阅。"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: title={title}, year={year}, media_type={media_type}, "
|
||||
f"season={season}, tmdb_id={tmdb_id}, douban_id={douban_id}, start_episode={start_episode}, "
|
||||
f"music_type={music_type}, season={season}, tmdb_id={tmdb_id}, douban_id={douban_id}, "
|
||||
f"start_episode={start_episode}, "
|
||||
f"total_episode={total_episode}, quality={quality}, resolution={resolution}, "
|
||||
f"effect={effect}, filter_groups={filter_groups}, sites={sites}"
|
||||
)
|
||||
@@ -172,7 +186,25 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
subscribe_chain = SubscribeChain()
|
||||
media_type_enum = MediaType.from_agent(media_type)
|
||||
if not media_type_enum:
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'music'"
|
||||
|
||||
normalized_music_type = None
|
||||
if media_type_enum == MediaType.MUSIC:
|
||||
normalized_music_type = normalize_music_type(
|
||||
music_type,
|
||||
allow_artist=False,
|
||||
)
|
||||
if not normalized_music_type:
|
||||
return (
|
||||
"错误:音乐订阅必须提供 music_type='recording'(单曲)"
|
||||
"或 music_type='album'(整张专辑),艺术家不能订阅"
|
||||
)
|
||||
if not media_source or not media_id:
|
||||
return "错误:音乐订阅必须同时提供 search_media 返回的 media_source 和 media_id"
|
||||
if any(value is not None for value in (season, start_episode, total_episode)):
|
||||
return "错误:音乐订阅没有季集参数,不能传入 season、start_episode 或 total_episode"
|
||||
elif music_type:
|
||||
return "错误:music_type 仅能与 media_type='music' 一起使用"
|
||||
effective_season = (
|
||||
season
|
||||
if season is not None
|
||||
@@ -184,6 +216,8 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
|
||||
# 构建额外的订阅参数
|
||||
subscribe_kwargs = {}
|
||||
if normalized_music_type:
|
||||
subscribe_kwargs["music_type"] = normalized_music_type
|
||||
if start_episode is not None:
|
||||
subscribe_kwargs["start_episode"] = start_episode
|
||||
if total_episode is not None:
|
||||
@@ -202,7 +236,7 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
sid, message = await subscribe_chain.async_add(
|
||||
mtype=media_type_enum,
|
||||
title=title,
|
||||
year=year,
|
||||
year=year or "",
|
||||
tmdbid=tmdb_id,
|
||||
doubanid=douban_id,
|
||||
bangumiid=bangumi_id,
|
||||
@@ -214,14 +248,18 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
**subscribe_kwargs,
|
||||
)
|
||||
if sid:
|
||||
display_year = f" ({year})" if year else ""
|
||||
music_label = (
|
||||
"专辑" if normalized_music_type == "album" else "单曲"
|
||||
) if normalized_music_type else ""
|
||||
if message and "已存在" in message:
|
||||
result_msg = f"订阅已存在:{title} ({year})"
|
||||
result_msg = f"{music_label}订阅已存在:{title}{display_year}"
|
||||
if effective_season is not None:
|
||||
result_msg += f" 第{effective_season}季"
|
||||
result_msg += "。如需修改参数请先删除旧订阅。"
|
||||
return result_msg
|
||||
|
||||
result_msg = f"成功添加订阅:{title} ({year})"
|
||||
result_msg = f"成功添加{music_label}订阅:{title}{display_year}"
|
||||
if effective_season is not None:
|
||||
result_msg += f" 第{effective_season}季"
|
||||
if season is None:
|
||||
|
||||
@@ -23,6 +23,8 @@ class DeleteSubscribeInput(BaseModel):
|
||||
|
||||
|
||||
class DeleteSubscribeTool(MoviePilotTool):
|
||||
"""按订阅 ID 删除影视、单曲或专辑订阅。"""
|
||||
|
||||
name: str = "delete_subscribe"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
@@ -39,6 +41,7 @@ class DeleteSubscribeTool(MoviePilotTool):
|
||||
return f"删除订阅 (ID: {subscribe_id})"
|
||||
|
||||
async def run(self, subscribe_id: int, **kwargs) -> str:
|
||||
"""删除订阅并同步刷新带音乐实体维度的共享统计。"""
|
||||
logger.info(f"执行工具: {self.name}, 参数: subscribe_id={subscribe_id}")
|
||||
|
||||
try:
|
||||
@@ -61,6 +64,8 @@ class DeleteSubscribeTool(MoviePilotTool):
|
||||
"anilistid": subscribe.anilistid,
|
||||
"media_source": subscribe.media_source,
|
||||
"media_id": subscribe.media_id,
|
||||
"music_type": subscribe.music_type,
|
||||
"total_tracks": subscribe.total_tracks,
|
||||
"season": subscribe.season,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,9 +7,16 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.music import MusicChain
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.log import logger
|
||||
from app.modules.listenbrainz import (
|
||||
LISTENBRAINZ_CHART_RANGES,
|
||||
LISTENBRAINZ_FRESH_MAX_DAYS,
|
||||
LISTENBRAINZ_FRESH_SORTS,
|
||||
)
|
||||
from app.schemas.types import MediaType, media_type_to_agent
|
||||
from ._music_utils import normalize_music_type, simplify_music_info
|
||||
|
||||
|
||||
class GetRecommendationsInput(BaseModel):
|
||||
@@ -31,24 +38,53 @@ class GetRecommendationsInput(BaseModel):
|
||||
"'douban_tv_weekly_chinese' for Douban Chinese TV weekly chart, "
|
||||
"'douban_tv_weekly_global' for Douban global TV weekly chart, "
|
||||
"'douban_tv_animation' for Douban popular animation, "
|
||||
"'bangumi_calendar' for Bangumi anime calendar",
|
||||
"'bangumi_calendar' for Bangumi anime calendar, "
|
||||
"'listenbrainz_chart' for popular recordings/albums, "
|
||||
"'listenbrainz_fresh' for fresh album releases",
|
||||
)
|
||||
media_type: Optional[str] = Field(
|
||||
"all", description="Allowed values: movie, tv, all"
|
||||
"all", description="Allowed values: movie, tv, music, all"
|
||||
)
|
||||
page: Optional[int] = Field(
|
||||
1, description="Page number for pagination (default: 1, 20 items per page)"
|
||||
)
|
||||
music_type: Optional[str] = Field(
|
||||
None,
|
||||
description="For listenbrainz_chart: recording or album. Fresh releases are always albums",
|
||||
)
|
||||
range_name: Optional[str] = Field(
|
||||
"this_month",
|
||||
description="ListenBrainz chart range such as this_week, this_month, this_year, or all_time",
|
||||
)
|
||||
sort_by: Optional[str] = Field(
|
||||
"listen_count.desc",
|
||||
description="ListenBrainz chart sort: listen_count.desc or listen_count.asc",
|
||||
)
|
||||
days: Optional[int] = Field(14, description="Fresh release window in days, max 90")
|
||||
fresh_sort: Optional[str] = Field(
|
||||
"release_date",
|
||||
description="Fresh release sort: release_date, artist_credit_name, or release_name",
|
||||
)
|
||||
past: Optional[bool] = Field(True, description="Include already released albums")
|
||||
future: Optional[bool] = Field(True, description="Include upcoming albums")
|
||||
min_listen_count: Optional[int] = Field(0, description="Minimum chart listen count")
|
||||
with_cover: Optional[bool] = Field(False, description="Only return music results with cover art")
|
||||
|
||||
|
||||
class GetRecommendationsTool(MoviePilotTool):
|
||||
"""获取影视推荐或 ListenBrainz 音乐探索结果。"""
|
||||
|
||||
name: str = "get_recommendations"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Media,
|
||||
ToolTag.Recommendation,
|
||||
]
|
||||
description: str = "Get trending and popular media recommendations from various sources. Returns curated lists of popular movies, TV shows, and anime based on different criteria like trending, ratings, or calendar schedules. Supports pagination with 20 items per page."
|
||||
description: str = (
|
||||
"Get movie, TV, anime, or music discovery results. Music supports ListenBrainz chart periods, "
|
||||
"recording/album modes, listen-count sorting, and fresh album releases. Returned music IDs can be "
|
||||
"passed directly to detail, library, subscription, and torrent tools."
|
||||
)
|
||||
args_schema: Type[BaseModel] = GetRecommendationsInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -72,6 +108,8 @@ class GetRecommendationsTool(MoviePilotTool):
|
||||
"douban_tv_weekly_global": "豆瓣全球剧集榜",
|
||||
"douban_tv_animation": "豆瓣热门动漫",
|
||||
"bangumi_calendar": "番组计划",
|
||||
"listenbrainz_chart": "ListenBrainz 音乐榜单",
|
||||
"listenbrainz_fresh": "ListenBrainz 新发行专辑",
|
||||
}
|
||||
source_desc = source_map.get(source, source)
|
||||
|
||||
@@ -87,8 +125,18 @@ class GetRecommendationsTool(MoviePilotTool):
|
||||
source: Optional[str] = "tmdb_trending",
|
||||
media_type: Optional[str] = "all",
|
||||
page: Optional[int] = 1,
|
||||
music_type: Optional[str] = None,
|
||||
range_name: Optional[str] = "this_month",
|
||||
sort_by: Optional[str] = "listen_count.desc",
|
||||
days: Optional[int] = 14,
|
||||
fresh_sort: Optional[str] = "release_date",
|
||||
past: Optional[bool] = True,
|
||||
future: Optional[bool] = True,
|
||||
min_listen_count: Optional[int] = 0,
|
||||
with_cover: Optional[bool] = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""按来源校验参数并返回有界推荐列表。"""
|
||||
page = max(1, page or 1)
|
||||
page_size = 20
|
||||
logger.info(
|
||||
@@ -98,8 +146,65 @@ class GetRecommendationsTool(MoviePilotTool):
|
||||
if media_type != "all":
|
||||
media_type_enum = MediaType.from_agent(media_type)
|
||||
if not media_type_enum:
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'all'"
|
||||
media_type = media_type_enum.to_agent() # 归一化为 "movie"/"tv"
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'music', 'all'"
|
||||
media_type = media_type_enum.to_agent()
|
||||
|
||||
if source in {"listenbrainz_chart", "listenbrainz_fresh"}:
|
||||
if media_type not in {"all", "music"}:
|
||||
return "错误:ListenBrainz 来源只能与 media_type='music' 或 'all' 一起使用"
|
||||
normalized_music_type = None
|
||||
if music_type:
|
||||
normalized_music_type = normalize_music_type(
|
||||
music_type,
|
||||
allow_artist=False,
|
||||
)
|
||||
if not normalized_music_type:
|
||||
return (
|
||||
f"错误:无效的音乐实体类型 '{music_type}',"
|
||||
"支持的类型:'recording', 'album'"
|
||||
)
|
||||
music_chain = MusicChain()
|
||||
if source == "listenbrainz_chart":
|
||||
if range_name not in LISTENBRAINZ_CHART_RANGES:
|
||||
return f"错误:无效的榜单周期 '{range_name}'"
|
||||
if sort_by not in {"listen_count.desc", "listen_count.asc"}:
|
||||
return f"错误:无效的榜单排序 '{sort_by}'"
|
||||
results = await music_chain.async_chart(
|
||||
range_name=range_name,
|
||||
page=page,
|
||||
count=page_size,
|
||||
sort_by=sort_by,
|
||||
min_listen_count=max(0, min_listen_count or 0),
|
||||
with_cover=bool(with_cover),
|
||||
entity=normalized_music_type or "recording",
|
||||
)
|
||||
else:
|
||||
if normalized_music_type and normalized_music_type != "album":
|
||||
return "错误:ListenBrainz 新发行结果只支持 music_type='album'"
|
||||
if fresh_sort not in LISTENBRAINZ_FRESH_SORTS:
|
||||
return f"错误:无效的新发行排序 '{fresh_sort}'"
|
||||
if not past and not future:
|
||||
return "错误:past 和 future 不能同时为 false"
|
||||
normalized_days = max(1, min(days or 14, LISTENBRAINZ_FRESH_MAX_DAYS))
|
||||
results = await music_chain.async_fresh_releases(
|
||||
days=normalized_days,
|
||||
sort=fresh_sort,
|
||||
past=bool(past),
|
||||
future=bool(future),
|
||||
page=page,
|
||||
count=page_size,
|
||||
with_cover=bool(with_cover),
|
||||
)
|
||||
if not results:
|
||||
return "未找到音乐推荐内容。"
|
||||
return json.dumps(
|
||||
[simplify_music_info(item) for item in results],
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
if media_type == "music":
|
||||
return "错误:音乐推荐请使用 listenbrainz_chart 或 listenbrainz_fresh 来源"
|
||||
|
||||
recommend_chain = RecommendChain()
|
||||
results = []
|
||||
@@ -186,6 +291,8 @@ class GetRecommendationsTool(MoviePilotTool):
|
||||
"douban_tv_weekly_global",
|
||||
"douban_tv_animation",
|
||||
"bangumi_calendar",
|
||||
"listenbrainz_chart",
|
||||
"listenbrainz_fresh",
|
||||
]
|
||||
return f"不支持的推荐来源: {source}。支持的来源包括: {', '.join(supported_sources)}"
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ class QueryDownloadTasksInput(BaseModel):
|
||||
|
||||
|
||||
class QueryDownloadTasksTool(MoviePilotTool):
|
||||
"""查询下载器任务并用下载历史补全影视或音乐身份。"""
|
||||
|
||||
name: str = "query_download_tasks"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
@@ -103,14 +105,33 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
if not history:
|
||||
return
|
||||
if hasattr(torrent, "media"):
|
||||
torrent.media = {
|
||||
media_payload = {
|
||||
"tmdbid": history.tmdbid,
|
||||
"type": history.type,
|
||||
"title": history.title,
|
||||
"season": history.seasons,
|
||||
"episode": history.episodes,
|
||||
"image": history.image,
|
||||
"poster": history.poster,
|
||||
"media_source": history.media_source,
|
||||
"media_id": history.media_id,
|
||||
}
|
||||
music_note = (
|
||||
(history.note or {}).get("music")
|
||||
if isinstance(history.note, dict)
|
||||
else None
|
||||
) or {}
|
||||
music_media = music_note.get("media") or {}
|
||||
if media_type_to_agent(history.type) == "music":
|
||||
media_payload.update({
|
||||
"music_type": music_media.get("music_type") or "recording",
|
||||
"artists": music_media.get("artists") or [],
|
||||
"album": music_media.get("album"),
|
||||
"album_id": music_media.get("album_id"),
|
||||
"total_tracks": music_media.get("total_tracks"),
|
||||
"track_number": music_media.get("track_number"),
|
||||
})
|
||||
torrent.media = media_payload
|
||||
if hasattr(torrent, "username"):
|
||||
torrent.username = history.username
|
||||
torrent.userid = history.userid
|
||||
@@ -272,6 +293,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
include_all_tags: Optional[bool] = False,
|
||||
include_trackers: Optional[bool] = False,
|
||||
**kwargs) -> str:
|
||||
"""执行下载任务查询并输出有界的状态摘要。"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: downloader={downloader}, status={status}, "
|
||||
f"hash={hash}, title={title}, tag={tag}, include_all_tags={include_all_tags}, "
|
||||
@@ -333,7 +355,15 @@ class QueryDownloadTasksTool(MoviePilotTool):
|
||||
"type": media_type_to_agent(media.get("type")),
|
||||
"title": media.get("title"),
|
||||
"season": media.get("season"),
|
||||
"episode": media.get("episode")
|
||||
"episode": media.get("episode"),
|
||||
"media_source": media.get("media_source"),
|
||||
"media_id": media.get("media_id"),
|
||||
"music_type": media.get("music_type"),
|
||||
"artists": media.get("artists"),
|
||||
"album": media.get("album"),
|
||||
"album_id": media.get("album_id"),
|
||||
"total_tracks": media.get("total_tracks"),
|
||||
"track_number": media.get("track_number"),
|
||||
}
|
||||
simplified_downloads.append(simplified)
|
||||
result_json = json.dumps(simplified_downloads, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -10,9 +10,11 @@ from pydantic import BaseModel, Field
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.core.context import MUSIC_ENTITY_ALBUM
|
||||
from app.helper.mediaserver import MediaServerHelper
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType, media_type_to_agent
|
||||
from ._music_utils import normalize_music_type
|
||||
|
||||
|
||||
def _sort_seasons(seasons: Optional[dict]) -> dict:
|
||||
@@ -83,17 +85,27 @@ class QueryLibraryExistsInput(BaseModel):
|
||||
anilist_id: Optional[int] = Field(None, description="AniList media ID")
|
||||
media_source: Optional[str] = Field(None, description="Media metadata source")
|
||||
media_id: Optional[str] = Field(None, description="Native ID for media_source")
|
||||
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv")
|
||||
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv, music")
|
||||
music_type: Optional[str] = Field(
|
||||
None,
|
||||
description="For music: recording or album. Artists are not library acquisition targets",
|
||||
)
|
||||
|
||||
|
||||
class QueryLibraryExistsTool(MoviePilotTool):
|
||||
"""查询影视、单曲或完整专辑是否已存在于媒体服务器。"""
|
||||
|
||||
name: str = "query_library_exists"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Library,
|
||||
ToolTag.Media,
|
||||
]
|
||||
description: str = "Check whether media already exists in Plex, Emby, or Jellyfin by a TMDB, Douban, Bangumi, AniList, or source-native media ID. Results are grouped by media server; TV results include existing episodes, total episodes, and missing episodes/seasons."
|
||||
description: str = (
|
||||
"Check whether movies, TV, music recordings, or complete albums exist on configured media servers. "
|
||||
"TV results include episode coverage. Album existence is true only when the server confirms complete "
|
||||
"track coverage for the expected album size; artists cannot be queried as acquisition targets."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QueryLibraryExistsInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -128,7 +140,9 @@ class QueryLibraryExistsTool(MoviePilotTool):
|
||||
async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None,
|
||||
bangumi_id: Optional[int] = None, anilist_id: Optional[int] = None,
|
||||
media_source: Optional[str] = None, media_id: Optional[str] = None,
|
||||
media_type: Optional[str] = None, **kwargs) -> str:
|
||||
media_type: Optional[str] = None,
|
||||
music_type: Optional[str] = None, **kwargs) -> str:
|
||||
"""识别精确媒体身份并按服务器汇总存在性或完整性。"""
|
||||
logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}")
|
||||
try:
|
||||
if not any((tmdb_id, douban_id, bangumi_id, anilist_id, media_id)):
|
||||
@@ -138,7 +152,23 @@ class QueryLibraryExistsTool(MoviePilotTool):
|
||||
if media_type:
|
||||
media_type_enum = MediaType.from_agent(media_type)
|
||||
if not media_type_enum:
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'music'"
|
||||
|
||||
normalized_music_type = None
|
||||
if music_type:
|
||||
normalized_music_type = normalize_music_type(
|
||||
music_type,
|
||||
allow_artist=False,
|
||||
)
|
||||
if not normalized_music_type:
|
||||
return (
|
||||
f"错误:无效的音乐实体类型 '{music_type}',"
|
||||
"支持的类型:'recording', 'album'"
|
||||
)
|
||||
if media_type_enum != MediaType.MUSIC:
|
||||
return "错误:music_type 仅能与 media_type='music' 一起使用"
|
||||
if media_type_enum == MediaType.MUSIC and (not media_source or not media_id):
|
||||
return "错误:音乐媒体库查询必须同时提供 media_source 和 media_id"
|
||||
|
||||
media_chain = MediaServerChain()
|
||||
mediainfo = await media_chain.async_recognize_media(
|
||||
@@ -153,10 +183,19 @@ class QueryLibraryExistsTool(MoviePilotTool):
|
||||
if not mediainfo:
|
||||
identity = media_id or tmdb_id or douban_id or bangumi_id or anilist_id
|
||||
return f"未识别到媒体信息: {identity}"
|
||||
if normalized_music_type and (
|
||||
getattr(mediainfo, "music_type", None) != normalized_music_type
|
||||
):
|
||||
return (
|
||||
f"音乐实体类型不匹配:请求 {normalized_music_type},"
|
||||
f"实际 {getattr(mediainfo, 'music_type', None) or 'unknown'}"
|
||||
)
|
||||
|
||||
# 2. 遍历所有媒体服务器,分别查询存在性信息
|
||||
server_results = OrderedDict()
|
||||
total_seasons = _filter_regular_seasons(mediainfo.seasons)
|
||||
total_seasons = _filter_regular_seasons(
|
||||
mediainfo.seasons if mediainfo.type == MediaType.TV else None
|
||||
)
|
||||
service_names = self._get_media_server_names()
|
||||
|
||||
server_checks = await asyncio.gather(
|
||||
@@ -183,7 +222,16 @@ class QueryLibraryExistsTool(MoviePilotTool):
|
||||
)
|
||||
else:
|
||||
server_results[service_name] = {
|
||||
"exists": True
|
||||
"exists": True,
|
||||
**(
|
||||
{
|
||||
"complete": True,
|
||||
"expected_tracks": mediainfo.total_tracks,
|
||||
}
|
||||
if mediainfo.type == MediaType.MUSIC
|
||||
and getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
if not server_results:
|
||||
@@ -201,7 +249,16 @@ class QueryLibraryExistsTool(MoviePilotTool):
|
||||
)
|
||||
else:
|
||||
server_results[fallback_server_name] = {
|
||||
"exists": True
|
||||
"exists": True,
|
||||
**(
|
||||
{
|
||||
"complete": True,
|
||||
"expected_tracks": mediainfo.total_tracks,
|
||||
}
|
||||
if mediainfo.type == MediaType.MUSIC
|
||||
and getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
# 3. 组装统一的存在性结果,不查询媒体服务器详情
|
||||
@@ -211,6 +268,15 @@ class QueryLibraryExistsTool(MoviePilotTool):
|
||||
"type": media_type_to_agent(mediainfo.type),
|
||||
"servers": server_results
|
||||
}
|
||||
if mediainfo.type == MediaType.MUSIC:
|
||||
result_dict.update({
|
||||
"music_type": mediainfo.music_type,
|
||||
"artists": list(mediainfo.artists or []),
|
||||
"album": mediainfo.album,
|
||||
"total_tracks": mediainfo.total_tracks,
|
||||
"media_source": mediainfo.source,
|
||||
"media_id": mediainfo.media_id,
|
||||
})
|
||||
|
||||
return json.dumps([result_dict], ensure_ascii=False)
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""查询媒体服务器最近入库影片工具"""
|
||||
"""查询媒体服务器最近入库条目工具"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
@@ -16,7 +16,7 @@ PAGE_SIZE = 20
|
||||
|
||||
|
||||
class QueryLibraryLatestInput(BaseModel):
|
||||
"""查询媒体服务器最近入库影片工具的输入参数模型"""
|
||||
"""查询媒体服务器最近入库条目工具的输入参数模型"""
|
||||
|
||||
server: Optional[str] = Field(
|
||||
None,
|
||||
@@ -28,13 +28,18 @@ class QueryLibraryLatestInput(BaseModel):
|
||||
|
||||
|
||||
class QueryLibraryLatestTool(MoviePilotTool):
|
||||
"""查询媒体服务器最近入库的影视或音乐条目。"""
|
||||
|
||||
name: str = "query_library_latest"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Library,
|
||||
ToolTag.Media,
|
||||
]
|
||||
description: str = "Query the latest media items added to the media server (Plex, Emby, Jellyfin). Returns recently added movies and TV series with their titles, images, links, and other metadata. Supports pagination with 20 items per page."
|
||||
description: str = (
|
||||
"Query the latest media items added to configured media servers. Returns any server-supported movies, "
|
||||
"TV, recordings, or albums with titles, images, links, and metadata. Supports 20-item pagination."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QueryLibraryLatestInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -42,7 +47,7 @@ class QueryLibraryLatestTool(MoviePilotTool):
|
||||
server = kwargs.get("server")
|
||||
page = kwargs.get("page", 1)
|
||||
|
||||
parts = ["查询媒体服务器最近入库影片"]
|
||||
parts = ["查询媒体服务器最近入库条目"]
|
||||
|
||||
if server:
|
||||
parts.append(f"服务器: {server}")
|
||||
@@ -82,6 +87,7 @@ class QueryLibraryLatestTool(MoviePilotTool):
|
||||
async def run(
|
||||
self, server: Optional[str] = None, page: Optional[int] = 1, **kwargs
|
||||
) -> str:
|
||||
"""并行读取媒体服务器最近入库结果并执行统一分页。"""
|
||||
page = max(1, page or 1)
|
||||
# 为了支持分页,需要获取足够多的数据再切片
|
||||
fetch_count = page * PAGE_SIZE
|
||||
@@ -118,7 +124,7 @@ class QueryLibraryLatestTool(MoviePilotTool):
|
||||
|
||||
if not results:
|
||||
server_info = f"服务器 {server}" if server else "所有服务器"
|
||||
return f"未找到 {server_info} 的最近入库影片"
|
||||
return f"未找到 {server_info} 的最近入库条目"
|
||||
|
||||
# 分页
|
||||
total_count = len(results)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""查询媒体详情工具"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
@@ -8,8 +9,20 @@ from pydantic import BaseModel, Field
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.music import MusicChain
|
||||
from app.core.context import (
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
MUSIC_ENTITY_ARTIST,
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
)
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType
|
||||
from ._music_utils import (
|
||||
normalize_music_type,
|
||||
simplify_music_album,
|
||||
simplify_music_artist,
|
||||
simplify_music_info,
|
||||
)
|
||||
|
||||
DIRECTOR_PREVIEW_LIMIT = 10
|
||||
ACTOR_PREVIEW_LIMIT = 20
|
||||
@@ -24,16 +37,40 @@ class QueryMediaDetailInput(BaseModel):
|
||||
anilist_id: Optional[int] = Field(None, description="AniList media ID")
|
||||
media_source: Optional[str] = Field(None, description="Media metadata source")
|
||||
media_id: Optional[str] = Field(None, description="Native ID for media_source")
|
||||
media_type: str = Field(..., description="Allowed values: movie, tv")
|
||||
media_type: str = Field(..., description="Allowed values: movie, tv, music")
|
||||
music_type: Optional[str] = Field(
|
||||
None,
|
||||
description="Required for music: recording, album, or artist",
|
||||
)
|
||||
include_artist_albums: Optional[bool] = Field(
|
||||
False,
|
||||
description="For artist detail, include a paged album/EP/single catalog preview",
|
||||
)
|
||||
include_related_artists: Optional[bool] = Field(
|
||||
False,
|
||||
description="For artist detail, include related artists",
|
||||
)
|
||||
page: Optional[int] = Field(1, description="Artist album catalog page, default 1")
|
||||
count: Optional[int] = Field(20, description="Artist catalog/related result count, max 30")
|
||||
album_type: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional artist catalog filter: album, single, ep, broadcast, other, compilation, soundtrack, live, or remix",
|
||||
)
|
||||
|
||||
|
||||
class QueryMediaDetailTool(MoviePilotTool):
|
||||
"""按稳定媒体身份查询影视、单曲、专辑或艺术家详情。"""
|
||||
|
||||
name: str = "query_media_detail"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Media,
|
||||
]
|
||||
description: str = "Query supplementary media details from a metadata source by ID and media_type. Accepts a TMDB, Douban, Bangumi, AniList, or source-native media ID. media_type accepts 'movie' or 'tv'. Returns non-duplicated detail fields such as status, genres, directors, actors, and season info for TV series."
|
||||
description: str = (
|
||||
"Query details by stable metadata ID for movies, TV, music recordings, albums, or artists. "
|
||||
"Music requires media_source, media_id, and music_type. Album details include a bounded track list; "
|
||||
"artist details may optionally include catalog and related-artist previews. Artists are browse-only."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QueryMediaDetailInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -56,9 +93,18 @@ class QueryMediaDetailTool(MoviePilotTool):
|
||||
self, media_type: str, tmdb_id: Optional[int] = None,
|
||||
douban_id: Optional[str] = None, bangumi_id: Optional[int] = None,
|
||||
anilist_id: Optional[int] = None, media_source: Optional[str] = None,
|
||||
media_id: Optional[str] = None, **kwargs,
|
||||
media_id: Optional[str] = None, music_type: Optional[str] = None,
|
||||
include_artist_albums: Optional[bool] = False,
|
||||
include_related_artists: Optional[bool] = False,
|
||||
page: Optional[int] = 1, count: Optional[int] = 20,
|
||||
album_type: Optional[str] = None, **kwargs,
|
||||
) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}")
|
||||
"""执行媒体详情查询,并限制音乐目录型结果的上下文大小。"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, "
|
||||
f"media_type={media_type}, music_type={music_type}, media_source={media_source}, "
|
||||
f"media_id={media_id}"
|
||||
)
|
||||
|
||||
if not any((tmdb_id, douban_id, bangumi_id, anilist_id, media_id)):
|
||||
return json.dumps({
|
||||
@@ -67,15 +113,110 @@ class QueryMediaDetailTool(MoviePilotTool):
|
||||
}, ensure_ascii=False)
|
||||
|
||||
try:
|
||||
media_chain = MediaChain()
|
||||
|
||||
media_type_enum = MediaType.from_agent(media_type)
|
||||
if not media_type_enum:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
|
||||
"message": (
|
||||
f"无效的媒体类型 '{media_type}',"
|
||||
"支持的类型:'movie', 'tv', 'music'"
|
||||
)
|
||||
}, ensure_ascii=False)
|
||||
|
||||
if media_type_enum == MediaType.MUSIC:
|
||||
normalized_music_type = normalize_music_type(music_type)
|
||||
if not normalized_music_type:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": (
|
||||
"查询音乐详情必须提供 music_type:"
|
||||
"'recording', 'album' 或 'artist'"
|
||||
),
|
||||
}, ensure_ascii=False)
|
||||
if not media_source or not media_id:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": "查询音乐详情必须同时提供 media_source 和 media_id",
|
||||
}, ensure_ascii=False)
|
||||
|
||||
music_chain = MusicChain()
|
||||
if normalized_music_type == MUSIC_ENTITY_ALBUM:
|
||||
album_info = await music_chain.async_album(media_source, media_id)
|
||||
if not album_info:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"未找到专辑 {media_source}:{media_id}",
|
||||
}, ensure_ascii=False)
|
||||
return json.dumps(
|
||||
simplify_music_album(album_info),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
if normalized_music_type == MUSIC_ENTITY_ARTIST:
|
||||
artist_info = await music_chain.async_artist(media_source, media_id)
|
||||
if not artist_info:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"未找到艺术家 {media_source}:{media_id}",
|
||||
}, ensure_ascii=False)
|
||||
normalized_page = max(1, page or 1)
|
||||
normalized_count = max(1, min(count or 20, 30))
|
||||
result = simplify_music_artist(artist_info)
|
||||
pending = []
|
||||
if include_artist_albums:
|
||||
pending.append((
|
||||
"albums",
|
||||
music_chain.async_artist_albums(
|
||||
source=media_source,
|
||||
media_id=media_id,
|
||||
page=normalized_page,
|
||||
count=normalized_count,
|
||||
album_type=album_type,
|
||||
),
|
||||
))
|
||||
if include_related_artists:
|
||||
pending.append((
|
||||
"related_artists",
|
||||
music_chain.async_artist_related(
|
||||
source=media_source,
|
||||
media_id=media_id,
|
||||
count=normalized_count,
|
||||
),
|
||||
))
|
||||
if pending:
|
||||
values = await asyncio.gather(*(call for _, call in pending))
|
||||
for (key, _), items in zip(pending, values):
|
||||
result[key] = [
|
||||
simplify_music_info(item)
|
||||
if key == "albums"
|
||||
else simplify_music_artist(item)
|
||||
for item in items
|
||||
]
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
media_chain = MediaChain()
|
||||
mediainfo = await media_chain.async_recognize_media(
|
||||
source=media_source,
|
||||
mediaid=media_id,
|
||||
mtype=MediaType.MUSIC,
|
||||
)
|
||||
if (
|
||||
not mediainfo
|
||||
or getattr(mediainfo, "music_type", MUSIC_ENTITY_RECORDING)
|
||||
!= MUSIC_ENTITY_RECORDING
|
||||
):
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"未找到单曲 {media_source}:{media_id}",
|
||||
}, ensure_ascii=False)
|
||||
return json.dumps(
|
||||
simplify_music_info(mediainfo),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
media_chain = MediaChain()
|
||||
mediainfo = await media_chain.async_recognize_media(
|
||||
tmdbid=tmdb_id,
|
||||
doubanid=douban_id,
|
||||
|
||||
@@ -12,13 +12,18 @@ from app.core.context import MediaInfo
|
||||
from app.helper.server import MoviePilotServerHelper
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType, media_type_to_agent
|
||||
from ._music_utils import normalize_music_type
|
||||
|
||||
MAX_PAGE_SIZE = 50
|
||||
|
||||
|
||||
class QueryPopularSubscribesInput(BaseModel):
|
||||
"""查询热门订阅工具的输入参数模型"""
|
||||
media_type: str = Field(..., description="Allowed values: movie, tv")
|
||||
media_type: str = Field(..., description="Allowed values: movie, tv, music")
|
||||
music_type: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional music entity filter: recording or album",
|
||||
)
|
||||
page: Optional[int] = Field(1, description="Page number for pagination (default: 1)")
|
||||
count: Optional[int] = Field(30, description="Number of items per page (default: 30, max: 50)")
|
||||
min_sub: Optional[int] = Field(None, description="Minimum number of subscribers filter (optional, e.g., 5)")
|
||||
@@ -29,13 +34,18 @@ class QueryPopularSubscribesInput(BaseModel):
|
||||
|
||||
|
||||
class QueryPopularSubscribesTool(MoviePilotTool):
|
||||
"""查询社区热门的影视、单曲或专辑订阅。"""
|
||||
|
||||
name: str = "query_popular_subscribes"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Subscription,
|
||||
ToolTag.Recommendation,
|
||||
]
|
||||
description: str = "Query popular subscriptions based on user shared data. Shows media with the most subscribers, supports filtering by genre, rating, minimum subscribers, and pagination."
|
||||
description: str = (
|
||||
"Query popular movie, TV, recording, or album subscriptions from shared statistics. "
|
||||
"Music results can be filtered by recording or album entity type."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QueryPopularSubscribesInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -60,6 +70,7 @@ class QueryPopularSubscribesTool(MoviePilotTool):
|
||||
return " | ".join(parts) if len(parts) > 1 else parts[0]
|
||||
|
||||
async def run(self, media_type: str,
|
||||
music_type: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
min_sub: Optional[int] = None,
|
||||
@@ -67,6 +78,7 @@ class QueryPopularSubscribesTool(MoviePilotTool):
|
||||
min_rating: Optional[float] = None,
|
||||
max_rating: Optional[float] = None,
|
||||
sort_type: Optional[str] = None, **kwargs) -> str:
|
||||
"""查询有界的订阅统计并保留音乐实体身份。"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: media_type={media_type}, page={page}, count={count}, min_sub={min_sub}, "
|
||||
f"genre_id={genre_id}, min_rating={min_rating}, max_rating={max_rating}, sort_type={sort_type}")
|
||||
@@ -80,7 +92,20 @@ class QueryPopularSubscribesTool(MoviePilotTool):
|
||||
count = min(count, MAX_PAGE_SIZE)
|
||||
media_type_enum = MediaType.from_agent(media_type)
|
||||
if not media_type_enum:
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'music'"
|
||||
normalized_music_type = None
|
||||
if music_type:
|
||||
normalized_music_type = normalize_music_type(
|
||||
music_type,
|
||||
allow_artist=False,
|
||||
)
|
||||
if not normalized_music_type:
|
||||
return (
|
||||
f"错误:无效的音乐实体类型 '{music_type}',"
|
||||
"支持的类型:'recording', 'album'"
|
||||
)
|
||||
if media_type_enum != MediaType.MUSIC:
|
||||
return "错误:music_type 仅能与 media_type='music' 一起使用"
|
||||
|
||||
subscribes = await MoviePilotServerHelper.async_get_subscribe_statistic(
|
||||
stype=media_type_enum.to_agent(),
|
||||
@@ -106,6 +131,37 @@ class QueryPopularSubscribesTool(MoviePilotTool):
|
||||
|
||||
media = MediaInfo()
|
||||
raw_type = str(sub.get("type") or "").strip().lower()
|
||||
if raw_type in ["music", "音乐"]:
|
||||
sub_music_type = normalize_music_type(
|
||||
sub.get("music_type") or "recording",
|
||||
allow_artist=False,
|
||||
)
|
||||
if not sub_music_type:
|
||||
logger.warning(f"跳过未知音乐订阅实体: {sub.get('music_type')}")
|
||||
continue
|
||||
if normalized_music_type and sub_music_type != normalized_music_type:
|
||||
continue
|
||||
artists = sub.get("artists") or []
|
||||
if isinstance(artists, str):
|
||||
artists = [artists]
|
||||
if not artists and sub.get("artist"):
|
||||
artists = [sub.get("artist")]
|
||||
ret_medias.append({
|
||||
"type": "music",
|
||||
"title": sub.get("name"),
|
||||
"year": sub.get("year"),
|
||||
"music_type": sub_music_type,
|
||||
"artists": artists,
|
||||
"album": sub.get("album"),
|
||||
"total_tracks": sub.get("total_tracks"),
|
||||
"media_source": sub.get("media_source"),
|
||||
"media_id": sub.get("media_id"),
|
||||
"poster_path": sub.get("poster"),
|
||||
"backdrop_path": sub.get("backdrop"),
|
||||
"subscriber_count": subscriber_count,
|
||||
"popularity": subscriber_count,
|
||||
})
|
||||
continue
|
||||
if raw_type in ["movie", "电影"]:
|
||||
media.type = MediaType.MOVIE
|
||||
elif raw_type in ["tv", "电视剧"]:
|
||||
@@ -143,6 +199,9 @@ class QueryPopularSubscribesTool(MoviePilotTool):
|
||||
# 转换为字典格式,只保留关键信息
|
||||
simplified_medias = []
|
||||
for media in ret_medias:
|
||||
if isinstance(media, dict):
|
||||
simplified_medias.append(media)
|
||||
continue
|
||||
media_dict = media.to_dict()
|
||||
simplified = {
|
||||
"type": media_type_to_agent(media_dict.get("type")),
|
||||
|
||||
@@ -9,7 +9,8 @@ from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.db.subscribehistory_oper import SubscribeHistoryOper
|
||||
from app.log import logger
|
||||
from app.schemas.types import media_type_to_agent
|
||||
from app.schemas.types import MediaType, media_type_to_agent
|
||||
from ._music_utils import normalize_music_type
|
||||
|
||||
PAGE_SIZE = 20
|
||||
|
||||
@@ -18,7 +19,11 @@ class QuerySubscribeHistoryInput(BaseModel):
|
||||
"""查询订阅历史工具的输入参数模型"""
|
||||
|
||||
media_type: Optional[str] = Field(
|
||||
"all", description="Allowed values: movie, tv, all"
|
||||
"all", description="Allowed values: movie, tv, music, all"
|
||||
)
|
||||
music_type: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional music history filter: recording or album",
|
||||
)
|
||||
name: Optional[str] = Field(
|
||||
None, description="Filter by media name (partial match, optional)"
|
||||
@@ -30,6 +35,8 @@ class QuerySubscribeHistoryInput(BaseModel):
|
||||
|
||||
|
||||
class QuerySubscribeHistoryTool(MoviePilotTool):
|
||||
"""查询已完成的影视、单曲与整张专辑订阅历史。"""
|
||||
|
||||
name: str = "query_subscribe_history"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
@@ -58,38 +65,63 @@ class QuerySubscribeHistoryTool(MoviePilotTool):
|
||||
async def run(
|
||||
self,
|
||||
media_type: Optional[str] = "all",
|
||||
music_type: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""按规范化数据库类型查询并合并订阅历史。"""
|
||||
page = max(1, page or 1)
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: media_type={media_type}, name={name}, page={page}"
|
||||
)
|
||||
|
||||
try:
|
||||
if media_type not in ["all", "movie", "tv"]:
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'all'"
|
||||
if media_type == "all":
|
||||
requested_types = [
|
||||
MediaType.MOVIE.value,
|
||||
MediaType.TV.value,
|
||||
MediaType.MUSIC.value,
|
||||
]
|
||||
else:
|
||||
media_type_enum = MediaType.from_agent(media_type)
|
||||
if not media_type_enum:
|
||||
return (
|
||||
f"错误:无效的媒体类型 '{media_type}',"
|
||||
"支持的类型:'movie', 'tv', 'music', 'all'"
|
||||
)
|
||||
requested_types = [media_type_enum.value]
|
||||
|
||||
normalized_music_type = None
|
||||
if music_type:
|
||||
normalized_music_type = normalize_music_type(
|
||||
music_type,
|
||||
allow_artist=False,
|
||||
)
|
||||
if not normalized_music_type:
|
||||
return (
|
||||
f"错误:无效的音乐实体类型 '{music_type}',"
|
||||
"支持的类型:'recording', 'album'"
|
||||
)
|
||||
if MediaType.MUSIC.value not in requested_types:
|
||||
return "错误:music_type 仅能与 media_type='music' 或 'all' 一起使用"
|
||||
|
||||
subscribe_history_oper = SubscribeHistoryOper()
|
||||
if name:
|
||||
# 有名称过滤时,获取足够多的记录在内存中过滤,不分页
|
||||
fetch_count = 500
|
||||
if media_type == "all":
|
||||
movie_history = await subscribe_history_oper.async_list_by_type(
|
||||
mtype="movie", page=1, count=fetch_count
|
||||
)
|
||||
tv_history = await subscribe_history_oper.async_list_by_type(
|
||||
mtype="tv", page=1, count=fetch_count
|
||||
)
|
||||
all_history = list(movie_history) + list(tv_history)
|
||||
all_history.sort(key=lambda x: x.date or "", reverse=True)
|
||||
else:
|
||||
all_history = list(
|
||||
await subscribe_history_oper.async_list_by_type(
|
||||
mtype=media_type, page=1, count=fetch_count
|
||||
)
|
||||
history_groups = [
|
||||
await subscribe_history_oper.async_list_by_type(
|
||||
mtype=requested_type,
|
||||
page=1,
|
||||
count=fetch_count,
|
||||
)
|
||||
for requested_type in requested_types
|
||||
]
|
||||
all_history = [
|
||||
record for group in history_groups for record in group
|
||||
]
|
||||
all_history.sort(key=lambda x: x.date or "", reverse=True)
|
||||
|
||||
# 按名称过滤
|
||||
name_lower = name.lower()
|
||||
@@ -97,6 +129,7 @@ class QuerySubscribeHistoryTool(MoviePilotTool):
|
||||
record
|
||||
for record in all_history
|
||||
if record.name and name_lower in record.name.lower()
|
||||
and self._matches_music_type(record, normalized_music_type)
|
||||
]
|
||||
|
||||
if not filtered_history:
|
||||
@@ -110,22 +143,23 @@ class QuerySubscribeHistoryTool(MoviePilotTool):
|
||||
return result_json
|
||||
else:
|
||||
# 无名称过滤时,直接利用数据库分页
|
||||
if media_type == "all":
|
||||
movie_history = await subscribe_history_oper.async_list_by_type(
|
||||
mtype="movie", page=1, count=page * PAGE_SIZE
|
||||
)
|
||||
tv_history = await subscribe_history_oper.async_list_by_type(
|
||||
mtype="tv", page=1, count=page * PAGE_SIZE
|
||||
)
|
||||
all_history = list(movie_history) + list(tv_history)
|
||||
all_history.sort(key=lambda x: x.date or "", reverse=True)
|
||||
filtered_history = all_history
|
||||
else:
|
||||
filtered_history = list(
|
||||
await subscribe_history_oper.async_list_by_type(
|
||||
mtype=media_type, page=1, count=page * PAGE_SIZE
|
||||
)
|
||||
history_groups = [
|
||||
await subscribe_history_oper.async_list_by_type(
|
||||
mtype=requested_type,
|
||||
page=1,
|
||||
count=page * PAGE_SIZE,
|
||||
)
|
||||
for requested_type in requested_types
|
||||
]
|
||||
all_history = [
|
||||
record for group in history_groups for record in group
|
||||
]
|
||||
all_history.sort(key=lambda x: x.date or "", reverse=True)
|
||||
filtered_history = [
|
||||
record
|
||||
for record in all_history
|
||||
if self._matches_music_type(record, normalized_music_type)
|
||||
]
|
||||
|
||||
if not filtered_history:
|
||||
return "未找到相关订阅历史记录"
|
||||
@@ -156,6 +190,15 @@ class QuerySubscribeHistoryTool(MoviePilotTool):
|
||||
logger.error(f"查询订阅历史失败: {e}", exc_info=True)
|
||||
return f"查询订阅历史时发生错误: {str(e)}"
|
||||
|
||||
@staticmethod
|
||||
def _matches_music_type(record, music_type: Optional[str]) -> bool:
|
||||
"""匹配音乐实体类型,旧空值历史按单曲兼容。"""
|
||||
if not music_type:
|
||||
return True
|
||||
if media_type_to_agent(getattr(record, "type", None)) != "music":
|
||||
return False
|
||||
return (getattr(record, "music_type", None) or "recording") == music_type
|
||||
|
||||
@staticmethod
|
||||
def _simplify_records(records) -> list:
|
||||
"""转换为字典格式,只保留关键信息"""
|
||||
@@ -173,6 +216,10 @@ class QuerySubscribeHistoryTool(MoviePilotTool):
|
||||
"anilistid": record.anilistid,
|
||||
"media_source": record.media_source,
|
||||
"media_id": record.media_id,
|
||||
"music_type": getattr(record, "music_type", None) or (
|
||||
"recording" if media_type_to_agent(record.type) == "music" else None
|
||||
),
|
||||
"total_tracks": getattr(record, "total_tracks", None),
|
||||
"poster": record.poster,
|
||||
"vote": record.vote,
|
||||
"total_episode": record.total_episode,
|
||||
|
||||
@@ -9,6 +9,8 @@ from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.helper.server import MoviePilotServerHelper
|
||||
from app.log import logger
|
||||
from app.schemas.types import media_type_to_agent
|
||||
from ._music_utils import normalize_music_type
|
||||
|
||||
MAX_PAGE_SIZE = 50
|
||||
|
||||
@@ -25,6 +27,8 @@ class QuerySubscribeSharesInput(BaseModel):
|
||||
|
||||
|
||||
class QuerySubscribeSharesTool(MoviePilotTool):
|
||||
"""查询社区共享的影视、单曲与专辑订阅。"""
|
||||
|
||||
name: str = "query_subscribe_shares"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
@@ -60,6 +64,7 @@ class QuerySubscribeSharesTool(MoviePilotTool):
|
||||
min_rating: Optional[float] = None,
|
||||
max_rating: Optional[float] = None,
|
||||
sort_type: Optional[str] = None, **kwargs) -> str:
|
||||
"""查询订阅分享并保留音乐实体及整专曲目字段。"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: name={name}, page={page}, count={count}, genre_id={genre_id}, "
|
||||
f"min_rating={min_rating}, max_rating={max_rating}, sort_type={sort_type}")
|
||||
@@ -88,11 +93,23 @@ class QuerySubscribeSharesTool(MoviePilotTool):
|
||||
# 简化字段,只保留关键信息
|
||||
simplified_shares = []
|
||||
for share in shares:
|
||||
normalized_type = media_type_to_agent(share.get("type"))
|
||||
normalized_music_type = None
|
||||
if normalized_type == "music":
|
||||
normalized_music_type = normalize_music_type(
|
||||
share.get("music_type") or "recording",
|
||||
allow_artist=False,
|
||||
)
|
||||
if not normalized_music_type:
|
||||
logger.warning(
|
||||
f"跳过未知音乐订阅分享实体: {share.get('music_type')}"
|
||||
)
|
||||
continue
|
||||
simplified = {
|
||||
"id": share.get("id"),
|
||||
"name": share.get("name"),
|
||||
"year": share.get("year"),
|
||||
"type": share.get("type"),
|
||||
"type": normalized_type,
|
||||
"season": share.get("season"),
|
||||
"tmdbid": share.get("tmdbid"),
|
||||
"doubanid": share.get("doubanid"),
|
||||
@@ -100,6 +117,9 @@ class QuerySubscribeSharesTool(MoviePilotTool):
|
||||
"anilistid": share.get("anilistid"),
|
||||
"media_source": share.get("media_source"),
|
||||
"media_id": share.get("media_id"),
|
||||
"music_type": normalized_music_type,
|
||||
"total_tracks": share.get("total_tracks"),
|
||||
"description": share.get("description"),
|
||||
"poster": share.get("poster"),
|
||||
"vote": share.get("vote"),
|
||||
"share_title": share.get("share_title"),
|
||||
|
||||
@@ -10,7 +10,8 @@ from app.agent.tools.tags import ToolTag
|
||||
from app.db.subscribe_oper import SubscribeOper
|
||||
from app.log import logger
|
||||
from app.schemas.subscribe import Subscribe as SubscribeSchema
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas.types import MediaType, media_type_to_agent
|
||||
from ._music_utils import normalize_music_type
|
||||
|
||||
PAGE_SIZE = 100
|
||||
|
||||
@@ -19,6 +20,14 @@ QUERY_SUBSCRIBE_OUTPUT_FIELDS = [
|
||||
"name",
|
||||
"year",
|
||||
"type",
|
||||
"tmdbid",
|
||||
"doubanid",
|
||||
"bangumiid",
|
||||
"anilistid",
|
||||
"media_source",
|
||||
"media_id",
|
||||
"music_type",
|
||||
"total_tracks",
|
||||
"season",
|
||||
"total_episode",
|
||||
"start_episode",
|
||||
@@ -42,6 +51,10 @@ QUERY_SUBSCRIBE_OUTPUT_FIELDS = [
|
||||
"media_category",
|
||||
"filter_groups",
|
||||
"episode_group",
|
||||
"poster",
|
||||
"backdrop",
|
||||
"description",
|
||||
"username",
|
||||
]
|
||||
|
||||
|
||||
@@ -53,7 +66,11 @@ class QuerySubscribesInput(BaseModel):
|
||||
description="Filter subscriptions by status: 'R' for enabled subscriptions, 'S' for paused ones, 'all' for all subscriptions",
|
||||
)
|
||||
media_type: Optional[str] = Field(
|
||||
"all", description="Allowed values: movie, tv, all"
|
||||
"all", description="Allowed values: movie, tv, music, all"
|
||||
)
|
||||
music_type: Optional[str] = Field(
|
||||
None,
|
||||
description="Optional music subscription filter: recording or album",
|
||||
)
|
||||
tmdb_id: Optional[int] = Field(
|
||||
None,
|
||||
@@ -73,6 +90,8 @@ class QuerySubscribesInput(BaseModel):
|
||||
|
||||
|
||||
class QuerySubscribesTool(MoviePilotTool):
|
||||
"""查询电影、电视剧、单曲与专辑订阅。"""
|
||||
|
||||
name: str = "query_subscribes"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
@@ -106,6 +125,7 @@ class QuerySubscribesTool(MoviePilotTool):
|
||||
self,
|
||||
status: Optional[str] = "all",
|
||||
media_type: Optional[str] = "all",
|
||||
music_type: Optional[str] = None,
|
||||
tmdb_id: Optional[int] = None,
|
||||
douban_id: Optional[str] = None,
|
||||
bangumi_id: Optional[int] = None,
|
||||
@@ -115,13 +135,27 @@ class QuerySubscribesTool(MoviePilotTool):
|
||||
page: Optional[int] = 1,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""按状态、媒体身份及音乐实体类型筛选订阅。"""
|
||||
page = max(1, page or 1)
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: status={status}, media_type={media_type}, tmdb_id={tmdb_id}, douban_id={douban_id}, page={page}"
|
||||
)
|
||||
try:
|
||||
if media_type != "all" and not MediaType.from_agent(media_type):
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'all'"
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'music', 'all'"
|
||||
normalized_music_type = None
|
||||
if music_type:
|
||||
normalized_music_type = normalize_music_type(
|
||||
music_type,
|
||||
allow_artist=False,
|
||||
)
|
||||
if not normalized_music_type:
|
||||
return (
|
||||
f"错误:无效的音乐实体类型 '{music_type}',"
|
||||
"支持的类型:'recording', 'album'"
|
||||
)
|
||||
if media_type not in ("all", "music"):
|
||||
return "错误:music_type 仅能与 media_type='music' 或 'all' 一起使用"
|
||||
|
||||
subscribe_oper = SubscribeOper()
|
||||
subscribes = await subscribe_oper.async_list()
|
||||
@@ -146,6 +180,10 @@ class QuerySubscribesTool(MoviePilotTool):
|
||||
continue
|
||||
if media_id is not None and sub.media_id != media_id:
|
||||
continue
|
||||
if normalized_music_type:
|
||||
sub_music_type = sub.music_type or "recording"
|
||||
if sub_music_type != normalized_music_type:
|
||||
continue
|
||||
filtered_subscribes.append(sub)
|
||||
if filtered_subscribes:
|
||||
total_count = len(filtered_subscribes)
|
||||
@@ -158,12 +196,18 @@ class QuerySubscribesTool(MoviePilotTool):
|
||||
total_pages = (total_count + PAGE_SIZE - 1) // PAGE_SIZE
|
||||
return f"第 {page} 页没有数据,共 {total_count} 条结果,共 {total_pages} 页。"
|
||||
|
||||
full_subscribes = [
|
||||
SubscribeSchema.model_validate(s, from_attributes=True).model_dump(
|
||||
full_subscribes = []
|
||||
for subscribe in page_subscribes:
|
||||
payload = SubscribeSchema.model_validate(
|
||||
subscribe,
|
||||
from_attributes=True,
|
||||
).model_dump(
|
||||
include=set(QUERY_SUBSCRIBE_OUTPUT_FIELDS), exclude_none=True
|
||||
)
|
||||
for s in page_subscribes
|
||||
]
|
||||
payload["type"] = media_type_to_agent(payload.get("type"))
|
||||
if payload["type"] == "music" and not payload.get("music_type"):
|
||||
payload["music_type"] = "recording"
|
||||
full_subscribes.append(payload)
|
||||
result_json = json.dumps(full_subscribes, ensure_ascii=False, indent=2)
|
||||
|
||||
total_pages = (total_count + PAGE_SIZE - 1) // PAGE_SIZE
|
||||
|
||||
@@ -22,12 +22,18 @@ class QueryTransferHistoryInput(BaseModel):
|
||||
|
||||
|
||||
class QueryTransferHistoryTool(MoviePilotTool):
|
||||
"""查询影视与音乐整理历史及可复用的媒体身份。"""
|
||||
|
||||
name: str = "query_transfer_history"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Transfer,
|
||||
]
|
||||
description: str = "Query file transfer history records. Shows transfer status, source and destination paths, media information, and transfer details. Supports filtering by title and status."
|
||||
description: str = (
|
||||
"Query movie, TV, and music transfer history. Returns source/destination paths, status, errors, and "
|
||||
"stable media_source/media_id values that can be reused for retries. Music album retries should group "
|
||||
"tracks by the shared album directory/identity instead of treating every track as unrelated media."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QueryTransferHistoryInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -51,6 +57,7 @@ class QueryTransferHistoryTool(MoviePilotTool):
|
||||
async def run(self, title: Optional[str] = None,
|
||||
status: Optional[str] = "all",
|
||||
page: Optional[int] = 1, **kwargs) -> str:
|
||||
"""执行整理历史筛选并返回有界分页结果。"""
|
||||
logger.info(f"执行工具: {self.name}, 参数: title={title}, status={status}, page={page}")
|
||||
|
||||
try:
|
||||
@@ -128,6 +135,10 @@ class QueryTransferHistoryTool(MoviePilotTool):
|
||||
simplified["media_source"] = record.media_source
|
||||
if record.media_id:
|
||||
simplified["media_id"] = record.media_id
|
||||
if getattr(record, "music_type", None):
|
||||
simplified["music_type"] = record.music_type
|
||||
if getattr(record, "total_tracks", None):
|
||||
simplified["total_tracks"] = record.total_tracks
|
||||
simplified_records.append(simplified)
|
||||
|
||||
result_json = json.dumps(simplified_records, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
"""识别媒体信息工具"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.music import MusicChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import Context
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.schemas.types import media_type_to_agent
|
||||
from app.schemas.types import MediaType, media_type_to_agent
|
||||
from ._music_utils import simplify_music_info
|
||||
|
||||
|
||||
class RecognizeMediaInput(BaseModel):
|
||||
@@ -19,16 +23,34 @@ class RecognizeMediaInput(BaseModel):
|
||||
title: Optional[str] = Field(None, description="The title of the torrent/media to recognize (required for torrent recognition)")
|
||||
subtitle: Optional[str] = Field(None, description="The subtitle or description of the torrent (optional, helps improve recognition accuracy)")
|
||||
path: Optional[str] = Field(None, description="The file path to recognize (required for file recognition, mutually exclusive with title)")
|
||||
media_type: Optional[str] = Field(
|
||||
None,
|
||||
description="Allowed values: movie, tv, music. Audio file paths are automatically treated as music",
|
||||
)
|
||||
artist: Optional[str] = Field(
|
||||
None,
|
||||
description="Artist name for music title recognition. Only valid with media_type='music'",
|
||||
)
|
||||
album: Optional[str] = Field(
|
||||
None,
|
||||
description="Album name for music title recognition. Only valid with media_type='music'",
|
||||
)
|
||||
|
||||
|
||||
class RecognizeMediaTool(MoviePilotTool):
|
||||
"""从标题或文件路径识别影视与单曲信息。"""
|
||||
|
||||
name: str = "recognize_media"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Media,
|
||||
ToolTag.Metadata,
|
||||
]
|
||||
description: str = "Extract/identify media information from torrent titles or file paths (NOT database search). Supports two modes: 1) Extract from torrent title and optional subtitle, 2) Extract from file path. Returns detailed media information. Use 'search_media' to search TMDB database, or 'scrape_metadata' to generate metadata files for existing files."
|
||||
description: str = (
|
||||
"Extract media information from torrent titles or file paths. Supports movies, TV, and individual "
|
||||
"audio files. For music title recognition set media_type='music' and provide artist when known. "
|
||||
"Album and artist browsing by stable ID belongs to query_media_detail; use scrape_metadata for directories."
|
||||
)
|
||||
args_schema: Type[BaseModel] = RecognizeMediaInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -36,6 +58,7 @@ class RecognizeMediaTool(MoviePilotTool):
|
||||
title = kwargs.get("title")
|
||||
subtitle = kwargs.get("subtitle")
|
||||
path = kwargs.get("path")
|
||||
media_type = kwargs.get("media_type")
|
||||
|
||||
if path:
|
||||
message = f"识别文件媒体信息: {path}"
|
||||
@@ -45,14 +68,85 @@ class RecognizeMediaTool(MoviePilotTool):
|
||||
message += f" ({subtitle})"
|
||||
else:
|
||||
message = "识别媒体信息"
|
||||
if media_type:
|
||||
message += f" [{media_type}]"
|
||||
|
||||
return message
|
||||
|
||||
async def run(self, title: Optional[str] = None, subtitle: Optional[str] = None,
|
||||
path: Optional[str] = None, **kwargs) -> str:
|
||||
logger.info(f"执行工具: {self.name}, 参数: title={title}, subtitle={subtitle}, path={path}")
|
||||
path: Optional[str] = None, media_type: Optional[str] = None,
|
||||
artist: Optional[str] = None, album: Optional[str] = None,
|
||||
**kwargs) -> str:
|
||||
"""执行路径或标题识别,并按媒体类型选择正确的元数据链。"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: title={title}, subtitle={subtitle}, "
|
||||
f"path={path}, media_type={media_type}, artist={artist}, album={album}"
|
||||
)
|
||||
|
||||
try:
|
||||
media_type_enum = None
|
||||
if media_type:
|
||||
media_type_enum = MediaType.from_agent(media_type)
|
||||
if not media_type_enum:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": (
|
||||
f"无效的媒体类型 '{media_type}',"
|
||||
"支持的类型:'movie', 'tv', 'music'"
|
||||
),
|
||||
}, ensure_ascii=False)
|
||||
if (artist or album) and media_type_enum != MediaType.MUSIC:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": "artist 和 album 仅能与 media_type='music' 一起使用",
|
||||
}, ensure_ascii=False)
|
||||
|
||||
is_audio_path = bool(
|
||||
path and Path(path).suffix.lower() in settings.RMT_AUDIOEXT
|
||||
)
|
||||
recognize_music = media_type_enum == MediaType.MUSIC or (
|
||||
media_type_enum is None and is_audio_path
|
||||
)
|
||||
if recognize_music:
|
||||
music_chain = MusicChain()
|
||||
if path:
|
||||
if not is_audio_path:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": (
|
||||
"音乐路径识别只支持单个音频文件;目录请使用 "
|
||||
"scrape_metadata(media_type='music')"
|
||||
),
|
||||
"path": path,
|
||||
}, ensure_ascii=False)
|
||||
metainfo, mediainfo = await music_chain.async_recognize_by_path(path)
|
||||
context = Context(meta_info=metainfo, media_info=mediainfo)
|
||||
return self._format_context_result(context, "音频文件")
|
||||
if title:
|
||||
metainfo = music_chain.parse_query(title)
|
||||
if artist:
|
||||
metainfo.artists = [artist]
|
||||
if album:
|
||||
metainfo.album = album
|
||||
mediainfo = await music_chain.async_recognize_media(
|
||||
meta=metainfo,
|
||||
source="musicbrainz",
|
||||
)
|
||||
if mediainfo:
|
||||
context = Context(meta_info=metainfo, media_info=mediainfo)
|
||||
return self._format_context_result(context, "音乐标题")
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"无法识别音乐信息: {title}",
|
||||
"title": title,
|
||||
"artist": artist,
|
||||
"album": album,
|
||||
}, ensure_ascii=False)
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": "音乐识别必须提供 title 或单个音频文件 path",
|
||||
}, ensure_ascii=False)
|
||||
|
||||
media_chain = MediaChain()
|
||||
|
||||
# 根据提供的参数选择识别方式
|
||||
@@ -132,49 +226,64 @@ class RecognizeMediaTool(MoviePilotTool):
|
||||
|
||||
# 处理媒体信息
|
||||
if media_info:
|
||||
result["media_info"] = {
|
||||
"title": media_info.get("title"),
|
||||
"en_title": media_info.get("en_title"),
|
||||
"year": media_info.get("year"),
|
||||
"type": media_type_to_agent(media_info.get("type")),
|
||||
"season": media_info.get("season"),
|
||||
"tmdb_id": media_info.get("tmdb_id"),
|
||||
"imdb_id": media_info.get("imdb_id"),
|
||||
"douban_id": media_info.get("douban_id"),
|
||||
"bangumi_id": media_info.get("bangumi_id"),
|
||||
"anilist_id": media_info.get("anilist_id"),
|
||||
"media_source": media_info.get("source"),
|
||||
"media_id": media_info.get("media_id"),
|
||||
"overview": media_info.get("overview"),
|
||||
"vote_average": media_info.get("vote_average"),
|
||||
"poster_path": media_info.get("poster_path"),
|
||||
"backdrop_path": media_info.get("backdrop_path"),
|
||||
"detail_link": media_info.get("detail_link"),
|
||||
"title_year": media_info.get("title_year"),
|
||||
"source": media_info.get("source")
|
||||
}
|
||||
if getattr(context.media_info, "type", None) == MediaType.MUSIC:
|
||||
result["media_info"] = simplify_music_info(context.media_info)
|
||||
else:
|
||||
result["media_info"] = {
|
||||
"title": media_info.get("title"),
|
||||
"en_title": media_info.get("en_title"),
|
||||
"year": media_info.get("year"),
|
||||
"type": media_type_to_agent(media_info.get("type")),
|
||||
"season": media_info.get("season"),
|
||||
"tmdb_id": media_info.get("tmdb_id"),
|
||||
"imdb_id": media_info.get("imdb_id"),
|
||||
"douban_id": media_info.get("douban_id"),
|
||||
"bangumi_id": media_info.get("bangumi_id"),
|
||||
"anilist_id": media_info.get("anilist_id"),
|
||||
"media_source": media_info.get("source"),
|
||||
"media_id": media_info.get("media_id"),
|
||||
"overview": media_info.get("overview"),
|
||||
"vote_average": media_info.get("vote_average"),
|
||||
"poster_path": media_info.get("poster_path"),
|
||||
"backdrop_path": media_info.get("backdrop_path"),
|
||||
"detail_link": media_info.get("detail_link"),
|
||||
"title_year": media_info.get("title_year"),
|
||||
"source": media_info.get("source")
|
||||
}
|
||||
|
||||
# 处理元数据信息
|
||||
if meta_info:
|
||||
result["meta_info"] = {
|
||||
"name": meta_info.get("name"),
|
||||
"title": meta_info.get("title"),
|
||||
"year": meta_info.get("year"),
|
||||
"type": media_type_to_agent(meta_info.get("type")),
|
||||
"begin_season": meta_info.get("begin_season"),
|
||||
"end_season": meta_info.get("end_season"),
|
||||
"begin_episode": meta_info.get("begin_episode"),
|
||||
"end_episode": meta_info.get("end_episode"),
|
||||
"total_episode": meta_info.get("total_episode"),
|
||||
"part": meta_info.get("part"),
|
||||
"season_episode": meta_info.get("season_episode"),
|
||||
"episode_list": meta_info.get("episode_list"),
|
||||
"tmdbid": meta_info.get("tmdbid"),
|
||||
"doubanid": meta_info.get("doubanid"),
|
||||
"bangumiid": meta_info.get("bangumiid"),
|
||||
"anilistid": meta_info.get("anilistid"),
|
||||
"media_source": meta_info.get("media_source"),
|
||||
"media_id": meta_info.get("media_id"),
|
||||
}
|
||||
if getattr(context.media_info, "type", None) == MediaType.MUSIC:
|
||||
result["meta_info"] = {
|
||||
key: meta_info.get(key)
|
||||
for key in (
|
||||
"title", "artists", "album", "album_artist", "year",
|
||||
"disc_number", "track_number", "total_discs", "total_tracks",
|
||||
"version", "audio_format", "bit_depth", "sample_rate", "bitrate",
|
||||
"duration", "isrc", "media_source", "media_id",
|
||||
)
|
||||
if meta_info.get(key) not in (None, "", [])
|
||||
}
|
||||
else:
|
||||
result["meta_info"] = {
|
||||
"name": meta_info.get("name"),
|
||||
"title": meta_info.get("title"),
|
||||
"year": meta_info.get("year"),
|
||||
"type": media_type_to_agent(meta_info.get("type")),
|
||||
"begin_season": meta_info.get("begin_season"),
|
||||
"end_season": meta_info.get("end_season"),
|
||||
"begin_episode": meta_info.get("begin_episode"),
|
||||
"end_episode": meta_info.get("end_episode"),
|
||||
"total_episode": meta_info.get("total_episode"),
|
||||
"part": meta_info.get("part"),
|
||||
"season_episode": meta_info.get("season_episode"),
|
||||
"episode_list": meta_info.get("episode_list"),
|
||||
"tmdbid": meta_info.get("tmdbid"),
|
||||
"doubanid": meta_info.get("doubanid"),
|
||||
"bangumiid": meta_info.get("bangumiid"),
|
||||
"anilistid": meta_info.get("anilistid"),
|
||||
"media_source": meta_info.get("media_source"),
|
||||
"media_id": meta_info.get("media_id"),
|
||||
}
|
||||
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -9,8 +9,13 @@ from pydantic import BaseModel, Field
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.music import MusicChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_ARTIST
|
||||
from app.log import logger
|
||||
from app.schemas import FileItem
|
||||
from app.schemas.types import MediaType, media_type_to_agent
|
||||
from ._music_utils import normalize_music_type, simplify_music_info
|
||||
|
||||
|
||||
class ScrapeMetadataInput(BaseModel):
|
||||
@@ -28,9 +33,27 @@ class ScrapeMetadataInput(BaseModel):
|
||||
False,
|
||||
description="Whether to overwrite existing metadata files (default: False)",
|
||||
)
|
||||
media_type: Optional[str] = Field(
|
||||
None,
|
||||
description="Allowed values: movie, tv, music. Audio files are auto-detected; music directories should set music explicitly",
|
||||
)
|
||||
music_type: Optional[str] = Field(
|
||||
None,
|
||||
description="For an explicit music ID: recording for one file or album for a complete album directory",
|
||||
)
|
||||
media_source: Optional[str] = Field(
|
||||
None,
|
||||
description="Music metadata source, normally musicbrainz. Must be paired with media_id",
|
||||
)
|
||||
media_id: Optional[str] = Field(
|
||||
None,
|
||||
description="Source-native recording or album ID. Must be paired with media_source",
|
||||
)
|
||||
|
||||
|
||||
class ScrapeMetadataTool(MoviePilotTool):
|
||||
"""刮削影视 NFO/图片或音乐标签、封面与旁挂歌词。"""
|
||||
|
||||
name: str = "scrape_metadata"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
@@ -39,7 +62,12 @@ class ScrapeMetadataTool(MoviePilotTool):
|
||||
ToolTag.File,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Generate metadata files (NFO files, posters, backgrounds, etc.) for existing media files or directories. Automatically recognizes media information from the file path and creates metadata files. Supports both local and remote storage. Use 'search_media' to search TMDB database, or 'recognize_media' to extract info from torrent titles/file paths without generating files."
|
||||
description: str = (
|
||||
"Scrape existing movie, TV, or music files on local/remote storage. Video generates configured NFO and "
|
||||
"images. Music applies configured audio-tag and cover policies and can automatically download LRCLIB "
|
||||
"lyrics as same-name .lrc/.txt sidecars. A directory with an album ID is treated as one complete album; "
|
||||
"without an ID, each audio file is recognized independently."
|
||||
)
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = ScrapeMetadataInput
|
||||
|
||||
@@ -48,12 +76,15 @@ class ScrapeMetadataTool(MoviePilotTool):
|
||||
path = kwargs.get("path", "")
|
||||
storage = kwargs.get("storage", "local")
|
||||
overwrite = kwargs.get("overwrite", False)
|
||||
media_type = kwargs.get("media_type")
|
||||
|
||||
message = f"刮削媒体元数据: {path}"
|
||||
if storage != "local":
|
||||
message += f" [存储: {storage}]"
|
||||
if overwrite:
|
||||
message += " [覆盖模式]"
|
||||
if media_type:
|
||||
message += f" [{media_type}]"
|
||||
|
||||
return message
|
||||
|
||||
@@ -62,10 +93,17 @@ class ScrapeMetadataTool(MoviePilotTool):
|
||||
path: str,
|
||||
storage: Optional[str] = "local",
|
||||
overwrite: Optional[bool] = False,
|
||||
media_type: Optional[str] = None,
|
||||
music_type: Optional[str] = None,
|
||||
media_source: Optional[str] = None,
|
||||
media_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""识别刮削类型并将同步文件及外部元数据操作放入线程池。"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: path={path}, storage={storage}, overwrite={overwrite}"
|
||||
f"执行工具: {self.name}, 参数: path={path}, storage={storage}, "
|
||||
f"overwrite={overwrite}, media_type={media_type}, music_type={music_type}, "
|
||||
f"media_source={media_source}, media_id={media_id}"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -76,9 +114,30 @@ class ScrapeMetadataTool(MoviePilotTool):
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# 创建 FileItem
|
||||
media_type_enum = None
|
||||
if media_type:
|
||||
media_type_enum = MediaType.from_agent(media_type)
|
||||
if not media_type_enum:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": (
|
||||
f"无效的媒体类型 '{media_type}',"
|
||||
"支持的类型:'movie', 'tv', 'music'"
|
||||
),
|
||||
}, ensure_ascii=False)
|
||||
if bool(media_source) != bool(media_id):
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": "media_source 和 media_id 必须同时提供",
|
||||
}, ensure_ascii=False)
|
||||
|
||||
local_path = Path(path)
|
||||
is_local_directory = (storage or "local") == "local" and local_path.is_dir()
|
||||
file_type = "dir" if is_local_directory or not local_path.suffix else "file"
|
||||
fileitem = FileItem(
|
||||
storage=storage, path=path, type="file" if Path(path).suffix else "dir"
|
||||
storage=storage or "local",
|
||||
path=path,
|
||||
type=file_type,
|
||||
)
|
||||
|
||||
# 检查本地存储路径是否存在
|
||||
@@ -89,8 +148,85 @@ class ScrapeMetadataTool(MoviePilotTool):
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# 识别媒体信息
|
||||
media_chain = MediaChain()
|
||||
is_audio_file = (
|
||||
fileitem.type == "file"
|
||||
and Path(path).suffix.lower() in settings.RMT_AUDIOEXT
|
||||
)
|
||||
scrape_music = media_type_enum == MediaType.MUSIC or (
|
||||
media_type_enum is None and is_audio_file
|
||||
)
|
||||
if scrape_music:
|
||||
normalized_music_type = None
|
||||
if music_type:
|
||||
normalized_music_type = normalize_music_type(music_type)
|
||||
if not normalized_music_type:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": (
|
||||
f"无效的音乐实体类型 '{music_type}',"
|
||||
"支持的类型:'recording', 'album'"
|
||||
),
|
||||
}, ensure_ascii=False)
|
||||
if normalized_music_type == MUSIC_ENTITY_ARTIST:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": "艺术家是浏览实体,不能用于文件刮削",
|
||||
}, ensure_ascii=False)
|
||||
|
||||
mediainfo = None
|
||||
if media_source and media_id:
|
||||
if normalized_music_type == MUSIC_ENTITY_ALBUM:
|
||||
album_info = await MusicChain().async_album(
|
||||
source=media_source,
|
||||
media_id=media_id,
|
||||
)
|
||||
mediainfo = album_info.to_music_info() if album_info else None
|
||||
else:
|
||||
mediainfo = await media_chain.async_recognize_media(
|
||||
source=media_source,
|
||||
mediaid=media_id,
|
||||
mtype=MediaType.MUSIC,
|
||||
)
|
||||
if not mediainfo:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"未识别到音乐信息: {media_source}:{media_id}",
|
||||
}, ensure_ascii=False)
|
||||
actual_music_type = getattr(mediainfo, "music_type", None)
|
||||
if normalized_music_type and actual_music_type != normalized_music_type:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": (
|
||||
f"音乐实体类型不匹配:请求 {normalized_music_type},"
|
||||
f"实际 {actual_music_type or 'unknown'}"
|
||||
),
|
||||
}, ensure_ascii=False)
|
||||
|
||||
success, message = await self.run_blocking(
|
||||
"storage",
|
||||
media_chain.scrape_music_metadata,
|
||||
fileitem=fileitem,
|
||||
mediainfo=mediainfo,
|
||||
overwrite=bool(overwrite),
|
||||
)
|
||||
result = {
|
||||
"success": success,
|
||||
"message": message,
|
||||
"path": path,
|
||||
"type": "music",
|
||||
}
|
||||
if mediainfo:
|
||||
result["media_info"] = simplify_music_info(mediainfo)
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
if music_type or media_source or media_id:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": "music_type、media_source 和 media_id 仅能用于音乐刮削",
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# 影视沿用路径识别与 NFO/图片刮削链路。
|
||||
context = await media_chain.async_recognize_by_path(
|
||||
path,
|
||||
obtain_images=True,
|
||||
@@ -124,7 +260,7 @@ class ScrapeMetadataTool(MoviePilotTool):
|
||||
"media_info": {
|
||||
"title": context.media_info.title,
|
||||
"year": context.media_info.year,
|
||||
"type": context.media_info.type.value if context.media_info.type else None,
|
||||
"type": media_type_to_agent(context.media_info.type),
|
||||
"tmdb_id": context.media_info.tmdb_id,
|
||||
"season": context.media_info.season,
|
||||
},
|
||||
|
||||
@@ -8,9 +8,11 @@ from pydantic import BaseModel, Field
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.music import MusicChain
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType, media_type_to_agent
|
||||
from app.utils.media import resolve_media_identity
|
||||
from ._music_utils import normalize_music_type, simplify_music_info
|
||||
|
||||
|
||||
class SearchMediaInput(BaseModel):
|
||||
@@ -18,18 +20,28 @@ class SearchMediaInput(BaseModel):
|
||||
title: str = Field(..., description="The title of the media to search for (e.g., 'The Matrix', 'Breaking Bad')")
|
||||
year: Optional[str] = Field(None, description="Release year of the media (optional, helps narrow down results)")
|
||||
media_type: Optional[str] = Field(None,
|
||||
description="Allowed values: movie, tv")
|
||||
description="Allowed values: movie, tv, music")
|
||||
music_type: Optional[str] = Field(
|
||||
None,
|
||||
description="Music entity filter: recording, album, or artist. Only valid when media_type='music'",
|
||||
)
|
||||
season: Optional[int] = Field(None,
|
||||
description="Season number for TV shows and anime (optional, only applicable for series)")
|
||||
|
||||
|
||||
class SearchMediaTool(MoviePilotTool):
|
||||
"""按标题搜索影视或音乐元数据候选。"""
|
||||
|
||||
name: str = "search_media"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Media,
|
||||
]
|
||||
description: str = "Search TMDB database for media resources (movies, TV shows, anime, etc.) by title, year, type, and other criteria. Returns detailed media information from TMDB. Use 'recognize_media' to extract info from torrent titles/file paths, or 'scrape_metadata' to generate metadata files."
|
||||
description: str = (
|
||||
"Search metadata databases for movies, TV shows, music recordings, albums, or artists. "
|
||||
"For music, set media_type='music' and optionally filter music_type as recording, album, or artist. "
|
||||
"Returns source-native IDs that must be reused for detail, subscription, torrent, and library operations."
|
||||
)
|
||||
args_schema: Type[BaseModel] = SearchMediaInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -37,6 +49,7 @@ class SearchMediaTool(MoviePilotTool):
|
||||
title = kwargs.get("title", "")
|
||||
year = kwargs.get("year")
|
||||
media_type = kwargs.get("media_type")
|
||||
music_type = kwargs.get("music_type")
|
||||
season = kwargs.get("season")
|
||||
|
||||
message = f"搜索媒体: {title}"
|
||||
@@ -44,32 +57,76 @@ class SearchMediaTool(MoviePilotTool):
|
||||
message += f" ({year})"
|
||||
if media_type:
|
||||
message += f" [{media_type}]"
|
||||
if music_type:
|
||||
message += f" [{music_type}]"
|
||||
if season is not None:
|
||||
message += f" 第{season}季"
|
||||
|
||||
return message
|
||||
|
||||
async def run(self, title: str, year: Optional[str] = None,
|
||||
media_type: Optional[str] = None, season: Optional[int] = None, **kwargs) -> str:
|
||||
media_type: Optional[str] = None, season: Optional[int] = None,
|
||||
music_type: Optional[str] = None, **kwargs) -> str:
|
||||
"""执行元数据搜索并返回可复用的精简媒体身份。"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: title={title}, year={year}, media_type={media_type}, season={season}")
|
||||
f"执行工具: {self.name}, 参数: title={title}, year={year}, "
|
||||
f"media_type={media_type}, season={season}, music_type={music_type}")
|
||||
|
||||
try:
|
||||
media_type_enum = None
|
||||
if media_type:
|
||||
media_type_enum = MediaType.from_agent(media_type)
|
||||
if not media_type_enum:
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'music'"
|
||||
|
||||
if music_type and media_type_enum != MediaType.MUSIC:
|
||||
return "错误:music_type 仅能与 media_type='music' 一起使用"
|
||||
|
||||
if media_type_enum == MediaType.MUSIC:
|
||||
if season is not None:
|
||||
return "错误:音乐没有季号,搜索音乐时不能传入 season"
|
||||
normalized_music_type = None
|
||||
if music_type:
|
||||
normalized_music_type = normalize_music_type(music_type)
|
||||
if not normalized_music_type:
|
||||
return (
|
||||
f"错误:无效的音乐实体类型 '{music_type}',"
|
||||
"支持的类型:'recording', 'album', 'artist'"
|
||||
)
|
||||
results = await MusicChain().async_search(query=title, limit=100)
|
||||
filtered_music = [
|
||||
item
|
||||
for item in results or []
|
||||
if (not year or str(item.year or "") == str(year))
|
||||
and (
|
||||
not normalized_music_type
|
||||
or item.music_type == normalized_music_type
|
||||
)
|
||||
]
|
||||
if not filtered_music:
|
||||
return f"未找到符合条件的音乐资源: {title}"
|
||||
total_count = len(filtered_music)
|
||||
limited_results = filtered_music[:30]
|
||||
result_json = json.dumps(
|
||||
[simplify_music_info(item) for item in limited_results],
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
if total_count > len(limited_results):
|
||||
return (
|
||||
f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,"
|
||||
f"仅显示前 {len(limited_results)} 条结果。\n\n{result_json}"
|
||||
)
|
||||
return result_json
|
||||
|
||||
media_chain = MediaChain()
|
||||
# 使用 MediaChain.search 方法
|
||||
meta, results = await media_chain.async_search(title=title)
|
||||
_, results = await media_chain.async_search(title=title)
|
||||
|
||||
# 过滤结果
|
||||
if results:
|
||||
media_type_enum = None
|
||||
if media_type:
|
||||
media_type_enum = MediaType.from_agent(media_type)
|
||||
if not media_type_enum:
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
|
||||
|
||||
filtered_results = []
|
||||
for result in results:
|
||||
if year and result.year != year:
|
||||
if year and str(result.year or "") != str(year):
|
||||
continue
|
||||
if media_type_enum and result.type != media_type_enum:
|
||||
continue
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""搜索订阅缺失剧集工具"""
|
||||
"""搜索订阅缺失资源工具"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Type, List
|
||||
@@ -14,14 +14,16 @@ from app.schemas.types import media_type_to_agent
|
||||
|
||||
|
||||
class SearchSubscribeInput(BaseModel):
|
||||
"""搜索订阅缺失剧集工具的输入参数模型"""
|
||||
subscribe_id: int = Field(..., description="The ID of the subscription to search for missing episodes (can be obtained from query_subscribes tool)")
|
||||
"""搜索订阅缺失资源工具的输入参数模型"""
|
||||
subscribe_id: int = Field(..., description="The subscription ID to search for missing resources (from query_subscribes)")
|
||||
manual: Optional[bool] = Field(False, description="Whether this is a manual search (default: False)")
|
||||
filter_groups: Optional[List[str]] = Field(None,
|
||||
description="List of filter rule group names to apply for this search (optional, can be obtained from query_rule_groups tool. If provided, will temporarily update the subscription's filter groups before searching)")
|
||||
|
||||
|
||||
class SearchSubscribeTool(MoviePilotTool):
|
||||
"""立即搜索电影、剧集、单曲或专辑订阅的缺失资源。"""
|
||||
|
||||
name: str = "search_subscribe"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
@@ -29,7 +31,11 @@ class SearchSubscribeTool(MoviePilotTool):
|
||||
ToolTag.Subscription,
|
||||
ToolTag.Resource,
|
||||
]
|
||||
description: str = "Search for missing episodes/resources for a specific subscription. This tool will search torrent sites for the missing episodes of the subscription and automatically download matching resources. Use this when a user wants to search for missing episodes of a specific subscription."
|
||||
description: str = (
|
||||
"Search and automatically download missing resources for one subscription. Supports movies, TV episodes, "
|
||||
"single recordings, and complete albums. Album search keeps the subscription active unless downloaded "
|
||||
"files are confirmed to cover the expected track count."
|
||||
)
|
||||
args_schema: Type[BaseModel] = SearchSubscribeInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -37,7 +43,7 @@ class SearchSubscribeTool(MoviePilotTool):
|
||||
subscribe_id = kwargs.get("subscribe_id")
|
||||
manual = kwargs.get("manual", False)
|
||||
|
||||
message = f"搜索订阅 #{subscribe_id} 的缺失剧集"
|
||||
message = f"搜索订阅 #{subscribe_id} 的缺失资源"
|
||||
if manual:
|
||||
message += "(手动搜索)"
|
||||
|
||||
@@ -45,6 +51,7 @@ class SearchSubscribeTool(MoviePilotTool):
|
||||
|
||||
async def run(self, subscribe_id: int, manual: Optional[bool] = False,
|
||||
filter_groups: Optional[List[str]] = None, **kwargs) -> str:
|
||||
"""验证订阅状态并触发一次订阅资源搜索。"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: subscribe_id={subscribe_id}, manual={manual}, filter_groups={filter_groups}")
|
||||
|
||||
@@ -75,6 +82,9 @@ class SearchSubscribeTool(MoviePilotTool):
|
||||
"anilistid": subscribe.anilistid,
|
||||
"media_source": subscribe.media_source,
|
||||
"media_id": subscribe.media_id,
|
||||
"music_type": subscribe.music_type,
|
||||
"total_tracks": subscribe.total_tracks,
|
||||
"description": subscribe.description,
|
||||
}
|
||||
|
||||
# 检查订阅状态
|
||||
@@ -122,8 +132,8 @@ class SearchSubscribeTool(MoviePilotTool):
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"搜索订阅缺失剧集失败: {str(e)}"
|
||||
logger.error(f"搜索订阅缺失剧集失败: {e}", exc_info=True)
|
||||
error_message = f"搜索订阅缺失资源失败: {str(e)}"
|
||||
logger.error(f"搜索订阅缺失资源失败: {e}", exc_info=True)
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": error_message,
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.sites import SitesHelper # noqa
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType, SystemConfigKey
|
||||
from ._music_utils import normalize_music_type
|
||||
from ._torrent_search_utils import (
|
||||
SEARCH_RESULT_CACHE_FILE,
|
||||
build_filter_options,
|
||||
@@ -26,13 +27,19 @@ class SearchTorrentsInput(BaseModel):
|
||||
anilist_id: Optional[int] = Field(None, description="AniList media ID")
|
||||
media_source: Optional[str] = Field(None, description="Media metadata source")
|
||||
media_id: Optional[str] = Field(None, description="Native ID for media_source")
|
||||
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv")
|
||||
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv, music")
|
||||
music_type: Optional[str] = Field(
|
||||
None,
|
||||
description="Music target entity: recording or album. Artists cannot be searched as torrent targets",
|
||||
)
|
||||
area: Optional[str] = Field(None, description="Search scope: 'title' (default) or 'imdbid'")
|
||||
sites: Optional[List[int]] = Field(None,
|
||||
description="Array of specific site IDs to search on (optional, if not provided searches all configured sites)")
|
||||
|
||||
|
||||
class SearchTorrentsTool(MoviePilotTool):
|
||||
"""按稳定媒体身份搜索影视、单曲或整张专辑资源。"""
|
||||
|
||||
name: str = "search_torrents"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
@@ -43,7 +50,8 @@ class SearchTorrentsTool(MoviePilotTool):
|
||||
description: str = (
|
||||
"Search for torrent files by media ID across configured indexer sites, cache the matched results, "
|
||||
"and return available filter options for follow-up selection. "
|
||||
"Accepts a TMDB, Douban, Bangumi, AniList, or source-native media ID for accurate matching.")
|
||||
"Accepts a TMDB, Douban, Bangumi, AniList, MusicBrainz, or plugin source-native media ID. "
|
||||
"Music targets are one recording or one complete album; artists are browse-only.")
|
||||
args_schema: Type[BaseModel] = SearchTorrentsInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -74,7 +82,9 @@ class SearchTorrentsTool(MoviePilotTool):
|
||||
bangumi_id: Optional[int] = None, anilist_id: Optional[int] = None,
|
||||
media_source: Optional[str] = None, media_id: Optional[str] = None,
|
||||
media_type: Optional[str] = None, area: Optional[str] = None,
|
||||
sites: Optional[List[int]] = None, **kwargs) -> str:
|
||||
sites: Optional[List[int]] = None,
|
||||
music_type: Optional[str] = None, **kwargs) -> str:
|
||||
"""执行精确资源搜索并缓存带完整音乐上下文的候选。"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}, area={area}, sites={sites}")
|
||||
|
||||
@@ -87,7 +97,26 @@ class SearchTorrentsTool(MoviePilotTool):
|
||||
if media_type:
|
||||
media_type_enum = MediaType.from_agent(media_type)
|
||||
if not media_type_enum:
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'music'"
|
||||
|
||||
normalized_music_type = None
|
||||
if music_type:
|
||||
normalized_music_type = normalize_music_type(
|
||||
music_type,
|
||||
allow_artist=False,
|
||||
)
|
||||
if not normalized_music_type:
|
||||
return (
|
||||
f"错误:无效的音乐实体类型 '{music_type}',"
|
||||
"支持的类型:'recording', 'album'"
|
||||
)
|
||||
if media_type_enum != MediaType.MUSIC:
|
||||
return "错误:music_type 仅能与 media_type='music' 一起使用"
|
||||
if media_type_enum == MediaType.MUSIC:
|
||||
if not media_source or not media_id:
|
||||
return "错误:音乐资源搜索必须同时提供 media_source 和 media_id"
|
||||
if area == "imdbid":
|
||||
return "错误:音乐不支持按 IMDb ID 搜索"
|
||||
|
||||
filtered_torrents = await search_chain.async_search_by_id(
|
||||
tmdbid=tmdb_id,
|
||||
@@ -101,6 +130,13 @@ class SearchTorrentsTool(MoviePilotTool):
|
||||
sites=sites,
|
||||
cache_local=False,
|
||||
)
|
||||
if normalized_music_type:
|
||||
filtered_torrents = [
|
||||
context
|
||||
for context in filtered_torrents or []
|
||||
if getattr(context.media_info, "music_type", None)
|
||||
== normalized_music_type
|
||||
]
|
||||
|
||||
# 获取站点信息
|
||||
all_indexers = await SitesHelper().async_get_indexers()
|
||||
|
||||
@@ -7,8 +7,10 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.core.context import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING
|
||||
from app.log import logger
|
||||
from app.schemas import FileItem, MediaType
|
||||
from ._music_utils import normalize_music_type
|
||||
|
||||
|
||||
class TransferFileInput(BaseModel):
|
||||
@@ -30,7 +32,11 @@ class TransferFileInput(BaseModel):
|
||||
None,
|
||||
description="Target storage type (optional, uses default storage if not specified)",
|
||||
)
|
||||
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv")
|
||||
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv, music")
|
||||
music_type: Optional[str] = Field(
|
||||
None,
|
||||
description="For music: recording for one audio file or album for one album directory. Artists cannot be transferred",
|
||||
)
|
||||
tmdbid: Optional[int] = Field(
|
||||
None,
|
||||
description="TMDB ID for precise media identification (optional but recommended for accuracy)",
|
||||
@@ -40,8 +46,14 @@ class TransferFileInput(BaseModel):
|
||||
)
|
||||
bangumiid: Optional[int] = Field(None, description="Bangumi media ID")
|
||||
anilistid: Optional[int] = Field(None, description="AniList media ID")
|
||||
media_source: Optional[str] = Field(None, description="Media metadata source")
|
||||
media_id: Optional[str] = Field(None, description="Native ID for media_source")
|
||||
media_source: Optional[str] = Field(
|
||||
None,
|
||||
description="Media metadata source; for music use the MusicBrainz recording or album source from search_media",
|
||||
)
|
||||
media_id: Optional[str] = Field(
|
||||
None,
|
||||
description="Native ID for media_source; an album ID organizes the directory as one album",
|
||||
)
|
||||
season: Optional[int] = Field(
|
||||
None, description="Season number for TV shows (optional)"
|
||||
)
|
||||
@@ -56,6 +68,8 @@ class TransferFileInput(BaseModel):
|
||||
|
||||
|
||||
class TransferFileTool(MoviePilotTool):
|
||||
"""通过统一整理链将影视或音乐文件写入媒体库。"""
|
||||
|
||||
name: str = "transfer_file"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
@@ -64,12 +78,20 @@ class TransferFileTool(MoviePilotTool):
|
||||
ToolTag.File,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Transfer/organize a file or directory to the media library. Automatically recognizes media information and organizes files according to configured rules. Supports custom target paths, media identification, and transfer modes."
|
||||
description: str = (
|
||||
"Transfer/organize movie, TV, or music files through MoviePilot's configured library pipeline. "
|
||||
"For a complete album, pass the album directory once with media_type='music', music_type='album', "
|
||||
"and its source-native ID; for one recording, pass one audio file with music_type='recording'."
|
||||
)
|
||||
args_schema: Type[BaseModel] = TransferFileInput
|
||||
require_admin: bool = True
|
||||
|
||||
@staticmethod
|
||||
def _get_fileitem_type(file_path: str, storage: Optional[str] = "local") -> str:
|
||||
def _get_fileitem_type(
|
||||
file_path: str,
|
||||
storage: Optional[str] = "local",
|
||||
music_type: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
判断待整理路径的文件类型。
|
||||
|
||||
@@ -79,6 +101,9 @@ class TransferFileTool(MoviePilotTool):
|
||||
"""
|
||||
if (storage or "local") == "local" and Path(file_path).is_dir():
|
||||
return "dir"
|
||||
if (storage or "local") != "local" and music_type == MUSIC_ENTITY_ALBUM:
|
||||
# 远程存储无法 stat,显式专辑语义比路径尾斜杠更可靠。
|
||||
return "dir"
|
||||
return "dir" if file_path.endswith("/") else "file"
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -111,6 +136,7 @@ class TransferFileTool(MoviePilotTool):
|
||||
target_path: Optional[str] = None,
|
||||
target_storage: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
music_type: Optional[str] = None,
|
||||
tmdbid: Optional[int] = None,
|
||||
doubanid: Optional[str] = None,
|
||||
bangumiid: Optional[int] = None,
|
||||
@@ -135,18 +161,51 @@ class TransferFileTool(MoviePilotTool):
|
||||
elif not file_path.startswith("/"):
|
||||
file_path = "/" + file_path
|
||||
|
||||
fileitem = FileItem(
|
||||
storage=storage or "local",
|
||||
path=file_path,
|
||||
type=TransferFileTool._get_fileitem_type(file_path, storage),
|
||||
)
|
||||
target_path_obj = Path(target_path) if target_path else None
|
||||
|
||||
media_type_enum = None
|
||||
if media_type:
|
||||
media_type_enum = MediaType.from_agent(media_type)
|
||||
if not media_type_enum:
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
|
||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'music'"
|
||||
normalized_music_type = None
|
||||
if music_type:
|
||||
normalized_music_type = normalize_music_type(
|
||||
music_type,
|
||||
allow_artist=False,
|
||||
)
|
||||
if not normalized_music_type:
|
||||
return (
|
||||
f"错误:无效的音乐实体类型 '{music_type}',"
|
||||
"支持的类型:'recording', 'album'"
|
||||
)
|
||||
if media_type_enum != MediaType.MUSIC:
|
||||
return "错误:music_type 仅能与 media_type='music' 一起使用"
|
||||
if bool(media_source) != bool(media_id):
|
||||
return "错误:media_source 和 media_id 必须同时提供"
|
||||
if media_type_enum == MediaType.MUSIC and season is not None:
|
||||
return "错误:音乐没有季号,整理音乐时不能传入 season"
|
||||
|
||||
fileitem = FileItem(
|
||||
storage=storage or "local",
|
||||
path=file_path,
|
||||
type=TransferFileTool._get_fileitem_type(
|
||||
file_path,
|
||||
storage,
|
||||
normalized_music_type,
|
||||
),
|
||||
)
|
||||
if (
|
||||
media_type_enum == MediaType.MUSIC
|
||||
and normalized_music_type == MUSIC_ENTITY_ALBUM
|
||||
and fileitem.type != "dir"
|
||||
):
|
||||
return "错误:专辑必须按一个目录整理,不能把单个音频文件作为整张专辑"
|
||||
if (
|
||||
media_type_enum == MediaType.MUSIC
|
||||
and normalized_music_type == MUSIC_ENTITY_RECORDING
|
||||
and fileitem.type != "file"
|
||||
):
|
||||
return "错误:单曲必须按一个音频文件整理,不能把目录作为一首单曲"
|
||||
target_path_obj = Path(target_path) if target_path else None
|
||||
|
||||
from app.chain.transfer import TransferChain
|
||||
|
||||
@@ -188,6 +247,7 @@ class TransferFileTool(MoviePilotTool):
|
||||
target_path: Optional[str] = None,
|
||||
target_storage: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
music_type: Optional[str] = None,
|
||||
tmdbid: Optional[int] = None,
|
||||
doubanid: Optional[str] = None,
|
||||
bangumiid: Optional[int] = None,
|
||||
@@ -199,9 +259,11 @@ class TransferFileTool(MoviePilotTool):
|
||||
background: Optional[bool] = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""在线程池中执行文件整理并返回最终状态。"""
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: file_path={file_path}, storage={storage}, target_path={target_path}, "
|
||||
f"target_storage={target_storage}, media_type={media_type}, tmdbid={tmdbid}, doubanid={doubanid}, "
|
||||
f"target_storage={target_storage}, media_type={media_type}, music_type={music_type}, "
|
||||
f"tmdbid={tmdbid}, doubanid={doubanid}, "
|
||||
f"season={season}, transfer_type={transfer_type}, background={background}"
|
||||
)
|
||||
|
||||
@@ -214,6 +276,7 @@ class TransferFileTool(MoviePilotTool):
|
||||
target_path,
|
||||
target_storage,
|
||||
media_type,
|
||||
music_type,
|
||||
tmdbid,
|
||||
doubanid,
|
||||
bangumiid,
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.core.event import eventmanager
|
||||
from app.db.subscribe_oper import SubscribeOper
|
||||
from app.log import logger
|
||||
from app.schemas.event import SubscribeModifiedEventData
|
||||
from app.schemas.types import EventType
|
||||
from app.schemas.types import EventType, media_type_to_agent
|
||||
|
||||
|
||||
class UpdateSubscribeInput(BaseModel):
|
||||
@@ -87,13 +87,19 @@ class UpdateSubscribeInput(BaseModel):
|
||||
|
||||
|
||||
class UpdateSubscribeTool(MoviePilotTool):
|
||||
"""更新影视、单曲或专辑订阅的运行参数。"""
|
||||
|
||||
name: str = "update_subscribe"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.Subscription,
|
||||
ToolTag.Admin,
|
||||
]
|
||||
description: str = "Update subscription properties including filters, episode counts, state, and other settings. Supports updating quality/resolution filters, episode tracking, subscription state, and download configuration."
|
||||
description: str = (
|
||||
"Update subscription filters, state, sites, downloader, save path, and other runtime settings. "
|
||||
"Episode fields are TV-only; music recording/album identity and expected album track count are metadata "
|
||||
"facts and are not changed by this tool."
|
||||
)
|
||||
args_schema: Type[BaseModel] = UpdateSubscribeInput
|
||||
require_admin: bool = True
|
||||
|
||||
@@ -152,6 +158,7 @@ class UpdateSubscribeTool(MoviePilotTool):
|
||||
episode_group: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""更新可变订阅字段并发送订阅调整事件。"""
|
||||
logger.info(f"执行工具: {self.name}, 参数: subscribe_id={subscribe_id}")
|
||||
|
||||
try:
|
||||
@@ -162,6 +169,24 @@ class UpdateSubscribeTool(MoviePilotTool):
|
||||
{"success": False, "message": f"订阅不存在: {subscribe_id}"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
if media_type_to_agent(subscribe.type) == "music" and any(
|
||||
value is not None
|
||||
for value in (
|
||||
season,
|
||||
total_episode,
|
||||
lack_episode,
|
||||
start_episode,
|
||||
best_version_full,
|
||||
episode_group,
|
||||
)
|
||||
):
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": "音乐订阅不能更新季集、整季洗版或剧集组字段",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# 保存旧数据用于事件
|
||||
old_subscribe_dict = subscribe.to_dict()
|
||||
@@ -285,7 +310,11 @@ class UpdateSubscribeTool(MoviePilotTool):
|
||||
"id": updated_subscribe.id,
|
||||
"name": updated_subscribe.name,
|
||||
"year": updated_subscribe.year,
|
||||
"type": updated_subscribe.type,
|
||||
"type": media_type_to_agent(updated_subscribe.type),
|
||||
"music_type": updated_subscribe.music_type,
|
||||
"total_tracks": updated_subscribe.total_tracks,
|
||||
"media_source": updated_subscribe.media_source,
|
||||
"media_id": updated_subscribe.media_id,
|
||||
"season": updated_subscribe.season,
|
||||
"state": updated_subscribe.state,
|
||||
"total_episode": updated_subscribe.total_episode,
|
||||
|
||||
@@ -54,6 +54,10 @@ class TransferHistory(Base):
|
||||
# 统一媒体数据源与原生ID
|
||||
media_source = Column(String, index=True)
|
||||
media_id = Column(String, index=True)
|
||||
# 音乐实体类型:recording 单曲、album 专辑
|
||||
music_type = Column(String)
|
||||
# 专辑预期总曲目数
|
||||
total_tracks = Column(Integer)
|
||||
# Sxx
|
||||
seasons = Column(String)
|
||||
# Exx
|
||||
|
||||
@@ -250,6 +250,8 @@ class TransferHistoryOper(DbOper):
|
||||
anilistid=mediainfo.anilist_id,
|
||||
media_source=mediainfo.source,
|
||||
media_id=mediainfo.to_dict().get("media_id"),
|
||||
music_type=getattr(mediainfo, "music_type", None),
|
||||
total_tracks=getattr(mediainfo, "total_tracks", None),
|
||||
seasons=meta.season,
|
||||
episodes=meta.episode,
|
||||
image=mediainfo.get_poster_image(),
|
||||
@@ -285,6 +287,8 @@ class TransferHistoryOper(DbOper):
|
||||
anilistid=mediainfo.anilist_id,
|
||||
media_source=mediainfo.source,
|
||||
media_id=mediainfo.to_dict().get("media_id"),
|
||||
music_type=getattr(mediainfo, "music_type", None),
|
||||
total_tracks=getattr(mediainfo, "total_tracks", None),
|
||||
seasons=meta.season,
|
||||
episodes=meta.episode,
|
||||
image=mediainfo.get_poster_image(),
|
||||
|
||||
@@ -81,7 +81,7 @@ class TransferHistory(BaseModel):
|
||||
dest: Optional[str] = None
|
||||
# 转移模式
|
||||
mode: Optional[str] = None
|
||||
# 类型:电影、电视剧
|
||||
# 类型:电影、电视剧、音乐
|
||||
type: Optional[str] = None
|
||||
# 二级分类
|
||||
category: Optional[str] = None
|
||||
@@ -105,6 +105,10 @@ class TransferHistory(BaseModel):
|
||||
media_source: Optional[str] = None
|
||||
# 数据源原生ID
|
||||
media_id: Optional[str] = None
|
||||
# 音乐实体类型:recording 单曲、album 专辑
|
||||
music_type: Optional[str] = None
|
||||
# 专辑预期总曲目数
|
||||
total_tracks: Optional[int] = None
|
||||
# 季Sxx
|
||||
seasons: Optional[str] = None
|
||||
# 集Exx
|
||||
|
||||
@@ -30,11 +30,16 @@ class MediaType(Enum):
|
||||
|
||||
|
||||
def media_type_to_agent(value) -> Optional[str]:
|
||||
"""将 MediaType 枚举或字符串统一转换为 Agent 媒体类型。"""
|
||||
"""将枚举、Agent 键或数据库枚举值统一转换为 Agent 媒体类型。"""
|
||||
if isinstance(value, MediaType):
|
||||
return value.to_agent()
|
||||
if isinstance(value, str):
|
||||
mt = MediaType.from_agent(value)
|
||||
if not mt:
|
||||
try:
|
||||
mt = MediaType(value)
|
||||
except ValueError:
|
||||
pass
|
||||
return mt.to_agent() if mt else value
|
||||
return None
|
||||
|
||||
|
||||
49
database/versions/d4f6a8c2e1b7_2_2_17.py
Normal file
49
database/versions/d4f6a8c2e1b7_2_2_17.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""2.2.17
|
||||
为整理历史保存音乐实体类型和专辑总曲目数
|
||||
|
||||
Revision ID: d4f6a8c2e1b7
|
||||
Revises: c9d4e7f1a2b3
|
||||
Create Date: 2026-08-10
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "d4f6a8c2e1b7"
|
||||
down_revision = "c9d4e7f1a2b3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
"""检查数据表是否已存在指定字段。"""
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if table_name not in inspector.get_table_names():
|
||||
return False
|
||||
return any(
|
||||
column["name"] == column_name
|
||||
for column in inspector.get_columns(table_name)
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""为整理历史补充音乐重试所需的实体字段。"""
|
||||
if not _has_column("transferhistory", "music_type"):
|
||||
op.add_column(
|
||||
"transferhistory",
|
||||
sa.Column("music_type", sa.String(), nullable=True),
|
||||
)
|
||||
if not _has_column("transferhistory", "total_tracks"):
|
||||
op.add_column(
|
||||
"transferhistory",
|
||||
sa.Column("total_tracks", sa.Integer(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""移除整理历史中的音乐实体字段。"""
|
||||
if _has_column("transferhistory", "total_tracks"):
|
||||
op.drop_column("transferhistory", "total_tracks")
|
||||
if _has_column("transferhistory", "music_type"):
|
||||
op.drop_column("transferhistory", "music_type")
|
||||
@@ -142,7 +142,7 @@ FastAPI 的 HTTP 异常在 v1、v2 均统一使用 `message`,不再返回顶
|
||||
| GET | `/api/v1/media/recognize` | 识别标题,参数:`title`、`subtitle`、`custom_words`,可选 `source`;当 `title` 为含目录的媒体文件路径时,会合并父目录中的名称、年份等信息 |
|
||||
| GET | `/api/v1/media/recognize_file` | 识别文件路径,参数:`path`,可选 `source` |
|
||||
| GET | `/api/v1/media/{mediaid}` | 查询媒体详情,`mediaid` 支持 `tmdb:`、`douban:`、`bangumi:`、`anilist:` 及插件自定义来源前缀 |
|
||||
| POST | `/api/v1/media/scrape/{storage}` | 刮削媒体元数据;请求体为 `FileItem`,可选查询参数 `media_source`、`media_id`、`type_name`(电影/电视剧)可指定本次刮削媒体 |
|
||||
| POST | `/api/v1/media/scrape/{storage}` | 刮削媒体元数据;请求体为 `FileItem`,可选查询参数 `media_source`、`media_id`、`type_name`(电影/电视剧/音乐)。音乐会按策略处理音频标签、封面和歌词 |
|
||||
| POST | `/api/v1/transfer/manual/target-path` | 匹配手动整理目标路径;请求体可用 `media_source` + `media_id` 指定数据源原生ID |
|
||||
| POST | `/api/v1/transfer/manual/history` | 查询文件、批量文件或目录命中的成功整理历史摘要,用于进入手动整理界面时显示重新整理状态 |
|
||||
| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源,同时兼容 `tmdbid`、`doubanid`、`bangumiid`、`anilistid`;命中失败历史时自动清理旧目标和记录后重试,`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 |
|
||||
@@ -188,15 +188,21 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
|
||||
|
||||
#### 音乐元数据 / 推荐 / 探索
|
||||
|
||||
音乐元数据使用 `MusicMeta` / `MusicInfo` 独立模型,媒体身份为 `musicbrainz:<recording_mbid>`。这些接口只负责搜索、识别、订阅和资源获取,不提供音乐库、歌单或歌手库管理。
|
||||
音乐元数据使用 `MusicMeta` / `MusicInfo` 独立模型。`music_type=recording` 表示单曲,`album` 表示包含多首曲目的完整专辑,`artist` 仅用于浏览;稳定身份分别使用对应的 `musicbrainz:<mbid>`。单曲和专辑可进入搜索、订阅、下载、整理、刮削和已配置音乐媒体服务器的入库检查,艺术家不能作为订阅或下载目标。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| GET | `/api/v1/music/search` | 按歌曲、专辑或歌手关键词搜索音乐元数据,参数:`query`、`count` |
|
||||
| GET | `/api/v1/media/search` | 当 `type=music` 或 `source=musicbrainz` 时按歌曲、专辑或歌手关键词搜索音乐元数据,参数:`title`、`type`、`count` |
|
||||
| POST | `/api/v1/music/recognize` | 按 `source` + `media_id` 识别音乐详情,请求体:`MusicRecognizeRequest` |
|
||||
| GET | `/api/v1/music/explore` | 浏览月度热门音乐,参数:`page`、`count` |
|
||||
| GET | `/api/v1/music/explore` | 浏览 ListenBrainz 热门单曲/专辑或新发行专辑,参数:`mode=chart|fresh`、`entity=recording|album`、`range_name`、`sort_by`、`sort`、`days`、`past`、`future`、`min_listen_count`、`with_cover`、`page`、`count` |
|
||||
| GET | `/api/v1/music/album/{album_id}` | 按 MusicBrainz 专辑 ID 查询专辑详情、完整曲目和发行版本,参数:`source` |
|
||||
| GET | `/api/v1/music/artist/{artist_id}` | 查询艺术家详情;艺术家为只读浏览实体,参数:`source` |
|
||||
| GET | `/api/v1/music/artist/{artist_id}/albums` | 分页查询艺术家的专辑、EP 和单曲,参数:`source`、`page`、`count`、`album_type` |
|
||||
| GET | `/api/v1/music/artist/{artist_id}/related` | 查询关联艺术家,参数:`source`、`count` |
|
||||
| GET | `/api/v1/recommend/music_weekly` | 浏览本周热门音乐,参数:`page`、`count` |
|
||||
|
||||
专辑下载与订阅按“整包”处理:下载层会读取种子文件清单并以专辑 `total_tracks` 校验独立音频文件数量;未确认完整覆盖时不会把专辑订阅销订,也不会把部分曲目报告为完整专辑已入库。音乐刮削遵循 `music` 的标签、封面和歌词策略,歌词通过带有界 TTL/LRU 缓存的 LRCLIB 模块保存为同名 `.lrc` 或 `.txt` 旁挂文件。
|
||||
|
||||
#### 下载
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
@@ -280,7 +286,9 @@ TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized`
|
||||
其中 `read_file` 单次最多返回 50KB 文件内容;超出时会截断并提示 Agent 使用
|
||||
`start_line`、`end_line` 指定更小的行号范围继续读取。
|
||||
|
||||
媒体相关 MCP 工具(如 `query_media_detail`、`search_torrents`、`query_library_exists`、`add_subscribe`、`transfer_file`)接受 `tmdb_id`/`tmdbid`、`douban_id`/`doubanid`、`bangumi_id`/`bangumiid`、`anilist_id`/`anilistid`,也接受 `media_source` + `media_id`。工具返回的媒体、订阅、下载和整理记录会同步带回可用的四种专用 ID 及通用主身份。
|
||||
媒体相关 MCP 工具(如 `search_media`、`query_media_detail`、`search_torrents`、`query_library_exists`、`add_subscribe`、`transfer_file`、`scrape_metadata`)接受 `tmdb_id`/`tmdbid`、`douban_id`/`doubanid`、`bangumi_id`/`bangumiid`、`anilist_id`/`anilistid`,也接受 `media_source` + `media_id`。音乐调用还使用 `media_type=music` 与 `music_type=recording|album|artist`;其中艺术家只允许搜索和详情浏览。工具返回的媒体、订阅、下载和整理记录会带回可复用的专用 ID、通用主身份以及音乐实体字段。
|
||||
|
||||
Agent 音乐流程与影视共用同一采集管线,但实体边界不同:单曲通过 `music_type=recording` 按一个文件处理;专辑通过 `music_type=album` 类似电视剧整季包,按一个目录/资源处理并校验总曲目数;艺术家不是采集目标。`scrape_metadata(media_type="music")` 会按策略写音频标签、封面和歌词,并返回歌词新增、已存在、未匹配和失败数量。
|
||||
|
||||
`get_search_results` 可使用 `title_pattern` 对种子标题执行正则筛选,也可使用 `content_pattern` 联合匹配种子标题、简介和标签。`title_pattern` 保持仅匹配标题的兼容语义;需要在结果中查看种子简介时,传入 `include_description=true`。两种正则参数与站点、分辨率等结构化筛选条件同时传入时按 AND 关系组合。
|
||||
|
||||
@@ -339,7 +347,7 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
|
||||
},
|
||||
...
|
||||
},
|
||||
"required": ["title", "year", "media_type"]
|
||||
"required": ["title", "media_type"]
|
||||
}
|
||||
},
|
||||
...
|
||||
@@ -413,7 +421,7 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
|
||||
},
|
||||
...
|
||||
},
|
||||
"required": ["title", "year", "media_type"]
|
||||
"required": ["title", "media_type"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: moviepilot-api
|
||||
version: 10
|
||||
version: 11
|
||||
description: >-
|
||||
Use this skill when you need to call MoviePilot REST API endpoints directly
|
||||
with the bundled Python client. Covers MoviePilot HTTP endpoints across media
|
||||
@@ -175,18 +175,31 @@ AniList endpoints prefer the `anilist-chinese` proxy and fall back to official A
|
||||
| GET | `/api/v1/anilist/person/{person_id}` | Staff detail |
|
||||
| GET | `/api/v1/anilist/person/credits/{person_id}` | Staff anime credits. Params: `page`, `count` |
|
||||
|
||||
### Music (3 endpoints)
|
||||
### Music (6 entity endpoints plus unified search)
|
||||
|
||||
Music uses the independent `MusicMeta` / `MusicInfo` contract and a
|
||||
`musicbrainz:<recording_mbid>` identity. MoviePilot searches, recognizes,
|
||||
subscribes to, downloads, and organizes music; it does not manage a music
|
||||
library, playlists, or an artist library.
|
||||
source-native MusicBrainz identity. `music_type=recording` is one track,
|
||||
`album` is a multi-track collection, and `artist` is browse-only. MoviePilot
|
||||
searches, recognizes, subscribes to, downloads, organizes, scrapes, and checks
|
||||
music on configured music-capable media servers; it does not manage playlists.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/music/search` | Search tracks, albums, or artists. Params: `query`, `count` |
|
||||
| GET | `/api/v1/media/search` | Search tracks, albums, or artists with `type=music` or `source=musicbrainz`. Params: `title`, `type`, `count` |
|
||||
| POST | `/api/v1/music/recognize` | Resolve music metadata. Body: `source`, `media_id` |
|
||||
| GET | `/api/v1/music/explore` | Explore the monthly site-wide music chart. Params: `page`, `count` |
|
||||
| GET | `/api/v1/music/explore` | Explore ListenBrainz charts or fresh albums. Params: `mode`, `entity`, `range_name`, `sort_by`, `sort`, `days`, `past`, `future`, `min_listen_count`, `with_cover`, `page`, `count` |
|
||||
| GET | `/api/v1/music/album/{album_id}` | Album detail with tracks and releases. Params: `source` |
|
||||
| GET | `/api/v1/music/artist/{artist_id}` | Browse artist detail. Params: `source` |
|
||||
| GET | `/api/v1/music/artist/{artist_id}/albums` | Browse artist albums/EPs/singles. Params: `source`, `page`, `count`, `album_type` |
|
||||
| GET | `/api/v1/music/artist/{artist_id}/related` | Browse related artists. Params: `source`, `count` |
|
||||
|
||||
Music acquisition rules:
|
||||
|
||||
- Reuse `source`, `media_id`, and `music_type` from search/detail results. Never substitute a same-name entity.
|
||||
- Subscribe/download one recording as one track. Subscribe/download one album as a complete multi-track pack.
|
||||
- Album torrent validation compares supported audio files with `total_tracks`; incomplete resources do not complete the subscription.
|
||||
- Artist IDs are never subscription, torrent, download, transfer, or library-existence targets.
|
||||
- `/api/v1/media/scrape/{storage}` writes configured music tags/covers and can fetch LRCLIB lyrics as `.lrc`/`.txt` sidecars. External metadata, cover, exploration, statistics, and lyrics requests use bounded TTL/LRU caches in their owning modules/helpers.
|
||||
|
||||
### Search / Torrents / Subtitles (11 endpoints)
|
||||
|
||||
@@ -224,7 +237,7 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/subscribe/` | List all subscriptions |
|
||||
| POST | `/api/v1/subscribe/` | Add subscription. Body accepts `media_source` + `media_id` and compatible `tmdbid`, `doubanid`, `bangumiid`, `anilistid` fields |
|
||||
| POST | `/api/v1/subscribe/` | Add subscription. Music requires `type=music`, `music_type=recording|album`, and exact `media_source` + `media_id`; video also accepts compatible dedicated IDs |
|
||||
| PUT | `/api/v1/subscribe/` | Update subscription. Body: Subscribe JSON |
|
||||
| GET | `/api/v1/subscribe/list` | List subscriptions (API_TOKEN auth, use `--token-param`) |
|
||||
| GET | `/api/v1/subscribe/{subscribe_id}` | Subscription detail |
|
||||
@@ -615,6 +628,26 @@ python scripts/mp-api.py GET /api/v1/search/last
|
||||
python scripts/mp-api.py POST /api/v1/download/add --json '{"torrent_url":"<url_from_search>"}'
|
||||
```
|
||||
|
||||
### Search and subscribe to one recording or complete album
|
||||
|
||||
```bash
|
||||
# 1. Search MusicBrainz entities through the unified media search
|
||||
python scripts/mp-api.py GET /api/v1/media/search title="Artist - Title" type="music" count=20
|
||||
|
||||
# 2a. For an album, inspect its complete track list before subscribing
|
||||
python scripts/mp-api.py GET /api/v1/music/album/<album_mbid> source="musicbrainz"
|
||||
|
||||
# 2b. Check the exact entity subscription separately; music_type prevents recording/album ambiguity
|
||||
python scripts/mp-api.py GET /api/v1/subscribe/media/musicbrainz:<mbid> music_type="album"
|
||||
|
||||
# 3. Add one exact album subscription. REST enum values use the localized MediaType value.
|
||||
python scripts/mp-api.py POST /api/v1/subscribe/ --json '{"name":"Album Title","type":"音乐","music_type":"album","media_source":"musicbrainz","media_id":"<album_mbid>"}'
|
||||
|
||||
# For one track, use that track's recording MBID and music_type=recording instead.
|
||||
```
|
||||
|
||||
Do not create an artist subscription. Select a recording or album from the artist catalog first. For an album manual download, use one matched album resource; the download layer rejects resources whose audio-file list does not cover `total_tracks`.
|
||||
|
||||
### Search and download subtitles
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: organize-files
|
||||
version: 1
|
||||
version: 2
|
||||
description: >-
|
||||
Use this skill when the user asks the MoviePilot agent to identify and organize downloaded/local media files that automatic transfer cannot handle. Typical triggers include: manually organize a file or folder, organize unrecognized downloads, fix files stuck in a download directory, identify a messy episode pack, move/copy/link files into the library, or organize files by explicit TMDB/Douban ID. If the user gives failed transfer history IDs, prefer transfer-failed-retry instead.
|
||||
allowed-tools: list_directory query_directory_settings query_download_tasks query_transfer_history delete_transfer_history recognize_media search_media query_media_detail query_library_exists transfer_file ask_user_choice send_message
|
||||
Use this skill when the user asks the MoviePilot agent to identify and organize downloaded/local video or music files that automatic transfer cannot handle. Typical triggers include manually organizing a file or folder, a TV season pack, one music recording, or a complete album directory. If the user gives failed transfer history IDs, prefer transfer-failed-retry instead.
|
||||
allowed-tools: list_directory query_directory_settings query_download_tasks query_transfer_history delete_transfer_history recognize_media search_media query_media_detail query_library_exists transfer_file scrape_metadata ask_user_choice send_message
|
||||
---
|
||||
|
||||
# Organize Files (智能整理文件)
|
||||
@@ -17,7 +17,7 @@ MoviePilot's normal flow is:
|
||||
1. `DownloadChain.download_single` adds a downloader task, records `DownloadHistory` and `DownloadFiles`, runs downloader-specific `download_added`, then sends `DownloadAdded`.
|
||||
2. `TransferChain.process` scans completed downloader tasks in monitored download directories. If a `DownloadHistory` exists for the hash, it reuses the recorded media IDs; otherwise it falls back to path recognition.
|
||||
3. Agent/manual organization calls `transfer_file`, which enters `TransferFileTool` -> `TransferChain.manual_transfer` -> `TransferChain.do_transfer`.
|
||||
4. `do_transfer` recursively collects eligible media/subtitle/audio files, ignores recycle/hidden paths and configured exclude words, resolves download history when possible, builds `MetaInfoPath`, then either uses explicit media info or calls `MediaChain.recognize_by_meta`.
|
||||
4. `do_transfer` recursively collects eligible video/subtitle/audio files, ignores recycle/hidden paths and configured exclude words, and reuses download history when possible. Video uses `MetaInfoPath`; music uses audio tags plus `MetaMusic`/`MusicInfo` and keeps the selected recording or album identity.
|
||||
5. `TransferChain.__handle_transfer` chooses the target directory through `DirectoryHelper`, delegates file operations to the file manager module, and lets `TransHandler` build the final target path and name.
|
||||
6. The callback writes `TransferHistory` success/failure records, emits transfer events, sends notifications, and may trigger `transfer-failed-retry` for failed history records.
|
||||
|
||||
@@ -43,6 +43,7 @@ Treat these as transfer candidates:
|
||||
- main media files and Blu-ray folders;
|
||||
- matching subtitle and external audio files in the same media folder;
|
||||
- episode packs where files share the same title/season pattern.
|
||||
- individual supported audio files and album folders containing multiple tracks.
|
||||
|
||||
Skip obvious samples, trailers, screenshots, hidden folders, recycle folders, and files that are not media/subtitle/audio.
|
||||
|
||||
@@ -56,18 +57,18 @@ recognize_media(path="<source file path>")
|
||||
|
||||
If recognition fails or looks wrong:
|
||||
|
||||
1. Extract likely title, year, media type, season, and episode range from filenames.
|
||||
2. Call `search_media(title="...", year="...", media_type="movie|tv")`.
|
||||
1. Extract likely title, year, media type, season/episode range, or music artist/track/album from filenames and audio tags.
|
||||
2. For video, call `search_media(title="...", year="...", media_type="movie|tv")`. For music, call `search_media(title="<artist> - <title>", media_type="music", music_type="recording|album")`.
|
||||
3. If several results are plausible, use `ask_user_choice` when available, or ask the user directly to choose the correct title/TMDB ID.
|
||||
4. For TV season confusion, use `query_media_detail(tmdb_id=<id>, media_type="tv")` before deciding the season number.
|
||||
4. For TV season confusion, use `query_media_detail(tmdb_id=<id>, media_type="tv")` before deciding the season number. For an album, use `query_media_detail(media_type="music", music_type="album", media_source="musicbrainz", media_id="<album_id>")` and verify `total_tracks` before treating the directory as complete.
|
||||
|
||||
Never invent a TMDB/Douban ID. When unsure, ask for confirmation.
|
||||
Never invent an ID. Preserve the exact source-native entity returned by search: a recording is one track, an album is a multi-track collection, and an artist is browse-only and cannot be organized.
|
||||
|
||||
### 4. Check Existing State
|
||||
|
||||
Before writing:
|
||||
|
||||
- Use `query_library_exists` when a precise `tmdb_id` and `media_type` are known and duplicate risk matters.
|
||||
- Use `query_library_exists` when a precise video or music identity is known and duplicate risk matters. For albums, an exists result is only true after complete track coverage is confirmed.
|
||||
- Use `query_transfer_history(title="<title or path keyword>", status="all")` if the file may already have a success or failure record.
|
||||
- If `transfer_file` later returns "已整理过", query transfer history, identify the matching source path, and ask before deleting the stale record.
|
||||
|
||||
@@ -87,6 +88,18 @@ transfer_file(
|
||||
)
|
||||
```
|
||||
|
||||
For one recording:
|
||||
|
||||
```text
|
||||
transfer_file(file_path="<audio file>", media_type="music", music_type="recording", media_source="musicbrainz", media_id="<recording_id>")
|
||||
```
|
||||
|
||||
For a complete album, transfer the album directory once:
|
||||
|
||||
```text
|
||||
transfer_file(file_path="<album directory>/", media_type="music", music_type="album", media_source="musicbrainz", media_id="<album_id>")
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- For directories, pass a trailing slash in `file_path` so the tool treats it as a directory.
|
||||
@@ -95,6 +108,10 @@ Rules:
|
||||
- For a single movie or a single TV season folder, transfer the folder once with the shared identity.
|
||||
- For mixed folders, split by media and transfer each file/subfolder separately.
|
||||
- For episode packs, identify the media once, then reuse `tmdbid`, `media_type="tv"`, and the confirmed `season` for each item.
|
||||
- For one recording, transfer only that audio file with the recording ID.
|
||||
- For one album, verify the directory belongs to the selected album, then transfer the directory once with the album ID. Do not submit every track as an unrelated recording.
|
||||
- Never transfer an artist search result. Select a recording or album first.
|
||||
- When the user asks to refresh music tags, cover, or lyrics after transfer, call `scrape_metadata(media_type="music", ...)`; album scraping may use the album ID and reports actual lyrics counts.
|
||||
|
||||
### 6. Report Clearly
|
||||
|
||||
@@ -122,6 +139,21 @@ If the result creates failed history records, tell the user they can retry with
|
||||
3. Confirm `tmdbid`, `media_type="tv"`, and season.
|
||||
4. `transfer_file(file_path="<folder>/", media_type="tv", tmdbid=<id>, season=<season>)`
|
||||
|
||||
### User Gives One Music Track
|
||||
|
||||
1. `recognize_media(path=..., media_type="music")`
|
||||
2. Confirm the artist and recording title; use `search_media(..., music_type="recording")` when ambiguous.
|
||||
3. Check the exact recording with `query_library_exists` when duplicate risk matters.
|
||||
4. Transfer the audio file once with the recording `media_source` + `media_id`.
|
||||
|
||||
### User Gives An Album Folder
|
||||
|
||||
1. `list_directory(path=...)` and confirm the files form one album rather than a mixed folder.
|
||||
2. Recognize a representative track, then search/select the album entity and query album detail.
|
||||
3. Compare the folder's supported audio-file count with album `total_tracks`; ask before proceeding when the folder appears incomplete or mixed.
|
||||
4. Check album library existence, then transfer the directory once with `media_type="music"`, `music_type="album"`, and the album identity.
|
||||
5. If requested, scrape the album directory for configured tags, cover, and lyrics; do not claim every lyric was found unless the tool reports it.
|
||||
|
||||
### User Gives A Messy Mixed Folder
|
||||
|
||||
1. `list_directory(path=...)`
|
||||
@@ -143,5 +175,7 @@ If the result creates failed history records, tell the user they can retry with
|
||||
- Do not delete transfer history without an exact matching source path and user confirmation.
|
||||
- Do not use broad download roots as transfer targets unless the user explicitly confirms the scope.
|
||||
- Do not process unrelated media in one directory transfer.
|
||||
- Do not confuse a same-name recording, album, and artist; preserve `music_type` and source-native IDs.
|
||||
- Do not report a partial album as complete or present in the library.
|
||||
- Do not override target directories or transfer modes unless necessary.
|
||||
- Prefer asking one focused question over guessing media identity, season mapping, or destructive cleanup.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: transfer-failed-retry
|
||||
version: 2
|
||||
description: Use this skill when you need to retry failed file transfers/organizations. Given one or more failed transfer history record IDs, this skill guides you through querying the failure details, deleting the old records, and re-identifying and re-organizing the files. Supports batch processing of multiple files from the same media (e.g., multiple episodes of a TV show). This skill is automatically triggered when the system detects transfer failures and the AI agent retry feature is enabled.
|
||||
version: 3
|
||||
description: Use this skill when you need to retry failed video or music transfers/organizations. Given failed transfer history IDs, query the exact records, group by trustworthy movie/series/recording/album identity, delete only the old records being retried, then re-identify and re-organize through MoviePilot. This skill is automatically triggered when transfer failures occur and AI retry is enabled.
|
||||
allowed-tools: query_transfer_history delete_transfer_history recognize_media transfer_file search_media
|
||||
---
|
||||
|
||||
@@ -16,7 +16,7 @@ You need the following tools:
|
||||
- `delete_transfer_history` - Delete a transfer history record
|
||||
- `recognize_media` - Recognize media info from file path or title
|
||||
- `transfer_file` - Transfer/organize files to the media library
|
||||
- `search_media` - Search TMDB for media information
|
||||
- `search_media` - Search video metadata or MusicBrainz recording/album/artist candidates
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -35,11 +35,12 @@ From each record, extract the following key information:
|
||||
- **src**: Source file path
|
||||
- **title**: The recognized title (may be incorrect)
|
||||
- **errmsg**: The error message explaining why the transfer failed
|
||||
- **type**: Media type (movie/tv)
|
||||
- **type**: Media type (movie/tv/music)
|
||||
- **tmdbid**: TMDB ID (if available)
|
||||
- **seasons/episodes**: Season/episode info (if TV show)
|
||||
- **downloader**: Which downloader was used
|
||||
- **download_hash**: The torrent hash
|
||||
- **media_source/media_id**: Exact source-native identity; required to preserve selected music entities
|
||||
|
||||
### Step 2: Analyze the Failure Reason
|
||||
|
||||
@@ -47,7 +48,7 @@ Common failure reasons and how to handle them:
|
||||
|
||||
| Error Message | Cause | Solution |
|
||||
|---------------|-------|----------|
|
||||
| 未识别到媒体信息 | File name couldn't be matched to any media | Use `search_media` to find the correct TMDB ID, then use `transfer_file` with explicit `tmdbid` |
|
||||
| 未识别到媒体信息 | File name or audio tags could not be matched | Use `search_media` to find the exact video ID or music recording/album identity, then transfer with explicit IDs |
|
||||
| 源目录不存在 | Source file was moved or deleted | Cannot retry - skip this record |
|
||||
| 目标路径不存在 | Target directory issue | Retry transfer - the directory config may have been fixed |
|
||||
| 文件已存在 | Target file already exists | May need to use `force` mode or skip |
|
||||
@@ -73,14 +74,18 @@ Based on the failure analysis in Step 2:
|
||||
recognize_media(path="<source_file_path>")
|
||||
```
|
||||
|
||||
2. If recognition fails, try searching TMDB with keywords extracted from the filename:
|
||||
2. If recognition fails, search the appropriate metadata source with keywords extracted from the filename or audio tags:
|
||||
```
|
||||
search_media(title="<extracted_title>", media_type="movie" or "tv")
|
||||
# or for music
|
||||
search_media(title="<artist> - <track_or_album>", media_type="music", music_type="recording" or "album")
|
||||
```
|
||||
|
||||
3. Once you have the correct TMDB ID, re-transfer with explicit identification:
|
||||
3. Once you have the exact identity, re-transfer with explicit identification:
|
||||
```
|
||||
transfer_file(file_path="<source_path>", tmdbid=<tmdb_id>, media_type="movie" or "tv")
|
||||
# or for music
|
||||
transfer_file(file_path="<source_path>", media_type="music", music_type="recording" or "album", media_source="musicbrainz", media_id="<recording_or_album_id>")
|
||||
```
|
||||
|
||||
#### Case B: Transfer Error (file operation failed)
|
||||
@@ -99,6 +104,13 @@ For TV shows where episode info couldn't be determined:
|
||||
transfer_file(file_path="<source_path>", tmdbid=<tmdb_id>, media_type="tv", season=<season_number>)
|
||||
```
|
||||
|
||||
#### Case D: Music Recording Or Album
|
||||
|
||||
1. A recording is one track. Retry the individual audio file with its recording ID.
|
||||
2. An album is a collection like a TV season pack. If several failed tracks share one album directory and album ID, verify the group and retry the directory once with the album ID.
|
||||
3. Never use an artist ID as a transfer target. Search/select a recording or album instead.
|
||||
4. Do not infer that a directory is complete merely because it has multiple files. Preserve the album identity and let the transfer/download pipeline enforce expected-track semantics where available.
|
||||
|
||||
### Step 5: Report Result
|
||||
|
||||
After the retry attempt, report the result:
|
||||
@@ -108,13 +120,13 @@ After the retry attempt, report the result:
|
||||
|
||||
## Batch Processing (批量处理)
|
||||
|
||||
When multiple files from the same source fail simultaneously (e.g., 10 episodes of the same TV show all fail with the same error), the system groups them and triggers a single batch retry.
|
||||
When multiple files fail simultaneously (for example, TV episodes or tracks from one album), the system may trigger one batch retry. Treat the batch as candidates for grouping, not proof that every record has the same identity.
|
||||
|
||||
### Key Optimization Rules for Batch Processing:
|
||||
|
||||
1. **Identify media ONCE, apply to ALL files**: Since batch files typically belong to the same media, perform media recognition (`recognize_media`) or search (`search_media`) only ONCE using the first file, then reuse the result (tmdbid, media_type) for all subsequent files.
|
||||
1. **Group first, identify once per verified group**: Group by source directory and exact media identity. Reuse video IDs within one movie/series group and reuse an album ID for tracks from one album. Do not apply one recording ID to multiple different tracks.
|
||||
|
||||
2. **Process each file individually for delete + transfer**: Even though the media identity is shared, you must still:
|
||||
2. **Choose the correct retry unit**: For movies, recordings, and TV episode files, delete and retry each exact failed record/file as needed. For a verified album directory, delete the selected failed records and submit the album directory once rather than repeatedly transferring every track.
|
||||
- Delete each failed history record individually
|
||||
- Transfer each file individually (they have different source paths)
|
||||
|
||||
@@ -158,7 +170,8 @@ transfer_file(file_path="/downloads/Show.Name.S01E04.1080p.mkv", tmdbid=789, med
|
||||
- **Do not retry** if the error is about missing directory configuration - this requires user intervention.
|
||||
- **For unrecognized media**, always try `recognize_media` with the file path first before falling back to `search_media`.
|
||||
- **Be cautious with TV shows** - ensure the correct season and episode information is used.
|
||||
- **For batch processing**, always reuse media identification results across all files to save time and resources.
|
||||
- **For batch processing**, reuse media identification only inside a verified group. Same source location alone does not prove shared identity.
|
||||
- **For music**, keep recording, album, and artist semantics distinct. Artists are browse-only; albums are multi-track retry units.
|
||||
- When this skill is triggered automatically by the system, it provides the `history_id`(s) directly. Start from Step 1 with those specific IDs.
|
||||
|
||||
## Example: Single File Retry Flow
|
||||
|
||||
480
tests/test_agent_music_tools.py
Normal file
480
tests/test_agent_music_tools.py
Normal file
@@ -0,0 +1,480 @@
|
||||
"""Agent 音乐工具的实体语义与跨工具上下文契约测试。"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agent.tools.impl._torrent_search_utils import simplify_search_result
|
||||
from app.agent.tools.impl.add_download_tasks import AddDownloadTasksTool
|
||||
from app.agent.tools.impl.add_subscribe import AddSubscribeTool
|
||||
from app.agent.tools.impl.get_recommendations import GetRecommendationsTool
|
||||
from app.agent.tools.impl.query_library_exists import QueryLibraryExistsTool
|
||||
from app.agent.tools.impl.query_media_detail import QueryMediaDetailTool
|
||||
from app.agent.tools.impl.query_subscribe_shares import QuerySubscribeSharesTool
|
||||
from app.agent.tools.impl.query_subscribe_history import QuerySubscribeHistoryTool
|
||||
from app.agent.tools.impl.scrape_metadata import ScrapeMetadataTool
|
||||
from app.agent.tools.impl.search_media import SearchMediaTool
|
||||
from app.core.context import (
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
MUSIC_ENTITY_ARTIST,
|
||||
Context,
|
||||
MusicAlbumInfo,
|
||||
MusicArtistInfo,
|
||||
MusicInfo,
|
||||
TorrentInfo,
|
||||
)
|
||||
from app.core.meta import MetaMusic
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
def _recording() -> MusicInfo:
|
||||
"""构造 Agent 工具测试使用的单曲信息。"""
|
||||
return MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
album="叶惠美",
|
||||
album_id="release-group-1",
|
||||
year=2003,
|
||||
track_number=3,
|
||||
total_tracks=11,
|
||||
)
|
||||
|
||||
|
||||
def _album() -> MusicInfo:
|
||||
"""构造 Agent 工具测试使用的整张专辑信息。"""
|
||||
return MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title="叶惠美",
|
||||
artists=["周杰伦"],
|
||||
album="叶惠美",
|
||||
album_id="release-group-1",
|
||||
year=2003,
|
||||
total_tracks=11,
|
||||
)
|
||||
|
||||
|
||||
def test_search_media_filters_music_entities_and_returns_stable_identity():
|
||||
"""音乐搜索应区分单曲和专辑,并返回后续工具可复用的来源 ID。"""
|
||||
async_search = AsyncMock(return_value=[_recording(), _album()])
|
||||
tool = SearchMediaTool(session_id="session-1", user_id="10001")
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.search_media.MusicChain.async_search",
|
||||
new=async_search,
|
||||
):
|
||||
result = asyncio.run(
|
||||
tool.run(
|
||||
title="叶惠美",
|
||||
media_type="music",
|
||||
music_type="album",
|
||||
)
|
||||
)
|
||||
|
||||
payload = json.loads(result)
|
||||
assert len(payload) == 1
|
||||
assert payload[0]["music_type"] == "album"
|
||||
assert payload[0]["media_source"] == "musicbrainz"
|
||||
assert payload[0]["media_id"] == "release-group-1"
|
||||
assert payload[0]["total_tracks"] == 11
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("music_type", "expected_label"),
|
||||
[("recording", "单曲"), ("album", "专辑")],
|
||||
)
|
||||
def test_add_subscribe_preserves_track_and_album_modes(music_type, expected_label):
|
||||
"""单曲与整专订阅应使用同一稳定身份,但保留不同实体模式。"""
|
||||
async_add = AsyncMock(return_value=(1, ""))
|
||||
tool = AddSubscribeTool(session_id="session-1", user_id="10001")
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.add_subscribe.SubscribeChain.async_add",
|
||||
new=async_add,
|
||||
):
|
||||
result = asyncio.run(
|
||||
tool.run(
|
||||
title="叶惠美" if music_type == "album" else "晴天",
|
||||
media_type="music",
|
||||
music_type=music_type,
|
||||
media_source="musicbrainz",
|
||||
media_id=(
|
||||
"release-group-1" if music_type == "album" else "recording-1"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
kwargs = async_add.await_args.kwargs
|
||||
assert kwargs["mtype"] == MediaType.MUSIC
|
||||
assert kwargs["music_type"] == music_type
|
||||
assert kwargs["media_source"] == "musicbrainz"
|
||||
assert kwargs["season"] is None
|
||||
assert expected_label in result
|
||||
|
||||
|
||||
def test_add_subscribe_rejects_artist_as_browse_only_entity():
|
||||
"""艺术家只能用于浏览,不能误建成无法完成的订阅。"""
|
||||
async_add = AsyncMock(return_value=(1, ""))
|
||||
tool = AddSubscribeTool(session_id="session-1", user_id="10001")
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.add_subscribe.SubscribeChain.async_add",
|
||||
new=async_add,
|
||||
):
|
||||
result = asyncio.run(
|
||||
tool.run(
|
||||
title="周杰伦",
|
||||
media_type="music",
|
||||
music_type="artist",
|
||||
media_source="musicbrainz",
|
||||
media_id="artist-1",
|
||||
)
|
||||
)
|
||||
|
||||
assert "艺术家不能订阅" in result
|
||||
async_add.assert_not_awaited()
|
||||
|
||||
|
||||
def test_query_album_detail_exposes_complete_track_contract():
|
||||
"""专辑详情应返回预期曲目总数和曲目身份,供整包搜索与校验使用。"""
|
||||
album = MusicAlbumInfo(
|
||||
source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
title="叶惠美",
|
||||
artists=["周杰伦"],
|
||||
release_date="2003-07-31",
|
||||
tracks=[_recording(), MusicInfo(title="以父之名", track_number=1)],
|
||||
)
|
||||
async_album = AsyncMock(return_value=album)
|
||||
tool = QueryMediaDetailTool(session_id="session-1", user_id="10001")
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.query_media_detail.MusicChain.async_album",
|
||||
new=async_album,
|
||||
):
|
||||
result = asyncio.run(
|
||||
tool.run(
|
||||
media_type="music",
|
||||
music_type="album",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
)
|
||||
)
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload["music_type"] == "album"
|
||||
assert payload["total_tracks"] == 2
|
||||
assert payload["tracks_total"] == 2
|
||||
assert payload["tracks"][0]["media_id"] == "recording-1"
|
||||
|
||||
|
||||
def test_query_artist_detail_marks_entity_as_non_subscribable():
|
||||
"""艺术家详情应明确标记为不可订阅,避免 Agent 混入获取流程。"""
|
||||
artist = MusicArtistInfo(
|
||||
source="musicbrainz",
|
||||
media_id="artist-1",
|
||||
name="周杰伦",
|
||||
artist_type="Person",
|
||||
)
|
||||
async_artist = AsyncMock(return_value=artist)
|
||||
tool = QueryMediaDetailTool(session_id="session-1", user_id="10001")
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.query_media_detail.MusicChain.async_artist",
|
||||
new=async_artist,
|
||||
):
|
||||
result = asyncio.run(
|
||||
tool.run(
|
||||
media_type="music",
|
||||
music_type=MUSIC_ENTITY_ARTIST,
|
||||
media_source="musicbrainz",
|
||||
media_id="artist-1",
|
||||
)
|
||||
)
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload["music_type"] == "artist"
|
||||
assert payload["subscribable"] is False
|
||||
|
||||
|
||||
def test_torrent_result_serializes_music_without_video_only_attributes():
|
||||
"""音乐种子结果不得访问季号、视频编码等影视专属属性而崩溃。"""
|
||||
context = Context(
|
||||
meta_info=MetaMusic(
|
||||
title="叶惠美",
|
||||
artists=["周杰伦"],
|
||||
album="叶惠美",
|
||||
total_tracks=11,
|
||||
),
|
||||
media_info=_album(),
|
||||
torrent_info=TorrentInfo(
|
||||
title="周杰伦 - 叶惠美 FLAC",
|
||||
enclosure="https://example.invalid/download/1",
|
||||
site_name="Demo",
|
||||
category=MediaType.MUSIC.value,
|
||||
),
|
||||
)
|
||||
|
||||
payload = simplify_search_result(context, index=1)
|
||||
|
||||
assert payload["media_info"]["music_type"] == "album"
|
||||
assert payload["meta_info"]["type"] == "music"
|
||||
assert payload["meta_info"]["total_tracks"] == 11
|
||||
assert payload["torrent_info"]["torrent_url"].endswith(":1")
|
||||
|
||||
|
||||
def test_add_download_preserves_album_context_and_full_coverage_marker():
|
||||
"""Agent 从搜索引用下载整专时必须保留 MetaMusic 和完整覆盖事实。"""
|
||||
meta = MetaMusic(
|
||||
title="叶惠美",
|
||||
artists=["周杰伦"],
|
||||
album="叶惠美",
|
||||
total_tracks=11,
|
||||
)
|
||||
cached_context = Context(
|
||||
meta_info=meta,
|
||||
media_info=_album(),
|
||||
torrent_info=TorrentInfo(
|
||||
title="周杰伦 - 叶惠美 FLAC",
|
||||
enclosure="https://example.invalid/download/1",
|
||||
site_name="Demo",
|
||||
category=MediaType.MUSIC.value,
|
||||
),
|
||||
confirmed_full_coverage=True,
|
||||
)
|
||||
site = SimpleNamespace(
|
||||
ua="ua",
|
||||
cookie="cookie",
|
||||
proxy=False,
|
||||
pri=1,
|
||||
downloader="qb",
|
||||
)
|
||||
submitted_contexts = []
|
||||
|
||||
def fake_download(context, _downloader, _save_path, _labels):
|
||||
"""记录提交给下载链的上下文。"""
|
||||
submitted_contexts.append(context)
|
||||
return "download-1", None
|
||||
|
||||
tool = AddDownloadTasksTool(session_id="session-1", user_id="10001")
|
||||
with patch.object(
|
||||
AddDownloadTasksTool,
|
||||
"_async_resolve_cached_context",
|
||||
new=AsyncMock(return_value=cached_context),
|
||||
), patch(
|
||||
"app.agent.tools.impl.add_download_tasks.SiteOper.async_get_by_name",
|
||||
new=AsyncMock(return_value=site),
|
||||
), patch.object(
|
||||
AddDownloadTasksTool,
|
||||
"_download_single_sync",
|
||||
side_effect=fake_download,
|
||||
):
|
||||
result = asyncio.run(tool.run(torrent_url=["abcdef0:1"]))
|
||||
|
||||
assert result == "任务添加成功"
|
||||
assert len(submitted_contexts) == 1
|
||||
submitted = submitted_contexts[0]
|
||||
assert submitted is not cached_context
|
||||
assert isinstance(submitted.meta_info, MetaMusic)
|
||||
assert submitted.meta_info.total_tracks == 11
|
||||
assert submitted.media_info.music_type == "album"
|
||||
assert submitted.confirmed_full_coverage is True
|
||||
|
||||
|
||||
def test_query_subscribe_history_uses_database_media_values_and_music_fields(monkeypatch):
|
||||
"""历史查询应使用数据库中文枚举值,并返回音乐实体身份。"""
|
||||
calls = []
|
||||
record = SimpleNamespace(
|
||||
id=1,
|
||||
name="叶惠美",
|
||||
year="2003",
|
||||
type=MediaType.MUSIC.value,
|
||||
season=None,
|
||||
tmdbid=None,
|
||||
doubanid=None,
|
||||
bangumiid=None,
|
||||
anilistid=None,
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
music_type="album",
|
||||
total_tracks=11,
|
||||
poster=None,
|
||||
vote=0,
|
||||
total_episode=None,
|
||||
date="2026-08-10 07:00:00",
|
||||
username="tester",
|
||||
filter=None,
|
||||
quality=None,
|
||||
resolution=None,
|
||||
)
|
||||
|
||||
class FakeHistoryOper:
|
||||
"""记录订阅历史查询参数的最小测试替身。"""
|
||||
|
||||
async def async_list_by_type(self, mtype, page, count):
|
||||
"""仅为音乐类型返回一条历史。"""
|
||||
calls.append((mtype, page, count))
|
||||
return [record] if mtype == MediaType.MUSIC.value else []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.agent.tools.impl.query_subscribe_history.SubscribeHistoryOper",
|
||||
FakeHistoryOper,
|
||||
)
|
||||
tool = QuerySubscribeHistoryTool(session_id="session-1", user_id="10001")
|
||||
|
||||
result = asyncio.run(tool.run(media_type="all"))
|
||||
|
||||
payload = json.loads(result.split("\n\n", 1)[1])
|
||||
assert [call[0] for call in calls] == [
|
||||
MediaType.MOVIE.value,
|
||||
MediaType.TV.value,
|
||||
MediaType.MUSIC.value,
|
||||
]
|
||||
assert payload[0]["type"] == "music"
|
||||
assert payload[0]["music_type"] == "album"
|
||||
assert payload[0]["total_tracks"] == 11
|
||||
|
||||
|
||||
def test_music_history_filter_excludes_video_records(monkeypatch):
|
||||
"""all + recording 过滤不能把缺少 music_type 的影视历史当成旧单曲。"""
|
||||
movie_record = SimpleNamespace(
|
||||
type=MediaType.MOVIE.value,
|
||||
music_type=None,
|
||||
date="2026-08-10 08:00:00",
|
||||
)
|
||||
|
||||
class FakeHistoryOper:
|
||||
"""返回一条电影历史的最小测试替身。"""
|
||||
|
||||
async def async_list_by_type(self, mtype, page, count):
|
||||
"""仅为电影类型返回记录。"""
|
||||
return [movie_record] if mtype == MediaType.MOVIE.value else []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.agent.tools.impl.query_subscribe_history.SubscribeHistoryOper",
|
||||
FakeHistoryOper,
|
||||
)
|
||||
tool = QuerySubscribeHistoryTool(session_id="session-1", user_id="10001")
|
||||
|
||||
result = asyncio.run(
|
||||
tool.run(media_type="all", music_type="recording")
|
||||
)
|
||||
|
||||
assert result == "未找到相关订阅历史记录"
|
||||
|
||||
|
||||
def test_music_scrape_routes_audio_to_tag_cover_and_lyrics_pipeline(tmp_path):
|
||||
"""音频刮削应进入音乐流程,并原样返回歌词处理统计消息。"""
|
||||
audio_file = tmp_path / "晴天.flac"
|
||||
audio_file.write_bytes(b"audio")
|
||||
tool = ScrapeMetadataTool(session_id="session-1", user_id="10001")
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.scrape_metadata.MediaChain.scrape_music_metadata",
|
||||
return_value=(True, "音乐刮削完成,歌词新增 1 首"),
|
||||
) as scrape_music:
|
||||
result = asyncio.run(
|
||||
tool.run(path=str(audio_file), media_type="music")
|
||||
)
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload["success"] is True
|
||||
assert payload["type"] == "music"
|
||||
assert "歌词新增 1 首" in payload["message"]
|
||||
scrape_music.assert_called_once()
|
||||
|
||||
|
||||
def test_query_library_exists_treats_album_as_atomic_complete_entity():
|
||||
"""整专媒体库查询应返回曲目完整性,且不进入电视剧 seasons 分支。"""
|
||||
async_recognize = AsyncMock(return_value=_album())
|
||||
exists = SimpleNamespace(
|
||||
type=MediaType.MUSIC,
|
||||
server="Navidrome",
|
||||
)
|
||||
tool = QueryLibraryExistsTool(session_id="session-1", user_id="10001")
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.query_library_exists."
|
||||
"MediaServerChain.async_recognize_media",
|
||||
new=async_recognize,
|
||||
), patch.object(
|
||||
QueryLibraryExistsTool,
|
||||
"_get_media_server_names",
|
||||
return_value=[],
|
||||
), patch.object(
|
||||
QueryLibraryExistsTool,
|
||||
"_query_media_exists",
|
||||
return_value=exists,
|
||||
):
|
||||
result = asyncio.run(
|
||||
tool.run(
|
||||
media_type="music",
|
||||
music_type="album",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
)
|
||||
)
|
||||
|
||||
payload = json.loads(result)[0]
|
||||
assert payload["music_type"] == "album"
|
||||
assert payload["total_tracks"] == 11
|
||||
assert payload["servers"]["Navidrome"] == {
|
||||
"exists": True,
|
||||
"complete": True,
|
||||
"expected_tracks": 11,
|
||||
}
|
||||
|
||||
|
||||
def test_listenbrainz_album_chart_preserves_entity_and_bounded_page_size():
|
||||
"""音乐榜单应把专辑实体与有界分页参数传递给缓存后的 MusicChain。"""
|
||||
async_chart = AsyncMock(return_value=[_album()])
|
||||
tool = GetRecommendationsTool(session_id="session-1", user_id="10001")
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.get_recommendations.MusicChain.async_chart",
|
||||
new=async_chart,
|
||||
):
|
||||
result = asyncio.run(
|
||||
tool.run(
|
||||
source="listenbrainz_chart",
|
||||
media_type="music",
|
||||
music_type="album",
|
||||
page=2,
|
||||
)
|
||||
)
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload[0]["music_type"] == "album"
|
||||
assert async_chart.await_args.kwargs["entity"] == "album"
|
||||
assert async_chart.await_args.kwargs["page"] == 2
|
||||
assert async_chart.await_args.kwargs["count"] == 20
|
||||
|
||||
|
||||
def test_subscribe_shares_normalize_legacy_music_type():
|
||||
"""旧音乐分享应输出 Agent 类型并按单曲语义兼容空实体字段。"""
|
||||
async_shares = AsyncMock(return_value=[{
|
||||
"id": 1,
|
||||
"name": "晴天",
|
||||
"type": MediaType.MUSIC.value,
|
||||
"music_type": None,
|
||||
"media_source": "musicbrainz",
|
||||
"media_id": "recording-1",
|
||||
}])
|
||||
tool = QuerySubscribeSharesTool(session_id="session-1", user_id="10001")
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl.query_subscribe_shares."
|
||||
"MoviePilotServerHelper.async_get_subscribe_shares",
|
||||
new=async_shares,
|
||||
):
|
||||
result = asyncio.run(tool.run())
|
||||
|
||||
payload = json.loads(result.split("\n\n", 1)[1])
|
||||
assert payload[0]["type"] == "music"
|
||||
assert payload[0]["music_type"] == "recording"
|
||||
@@ -27,3 +27,45 @@ def test_popular_subscribe_title_distinguishes_special_season_zero(monkeypatch):
|
||||
|
||||
assert payload[0]["title"] == "Demo Show 第零季"
|
||||
assert payload[0]["season"] == 0
|
||||
|
||||
|
||||
def test_popular_music_subscribes_can_filter_complete_albums(monkeypatch):
|
||||
"""热门音乐订阅应能区分单曲和整张专辑。"""
|
||||
async def fake_statistics(**_kwargs):
|
||||
"""返回一条单曲和一条专辑统计。"""
|
||||
return [
|
||||
{
|
||||
"type": "music",
|
||||
"name": "晴天",
|
||||
"music_type": "recording",
|
||||
"media_source": "musicbrainz",
|
||||
"media_id": "recording-1",
|
||||
"count": 8,
|
||||
},
|
||||
{
|
||||
"type": "音乐",
|
||||
"name": "叶惠美",
|
||||
"music_type": "album",
|
||||
"media_source": "musicbrainz",
|
||||
"media_id": "release-group-1",
|
||||
"total_tracks": 11,
|
||||
"count": 6,
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.agent.tools.impl.query_popular_subscribes."
|
||||
"MoviePilotServerHelper.async_get_subscribe_statistic",
|
||||
fake_statistics,
|
||||
)
|
||||
|
||||
tool = QueryPopularSubscribesTool(session_id="session-1", user_id="10001")
|
||||
result = asyncio.run(
|
||||
tool.run(media_type="music", music_type="album")
|
||||
)
|
||||
payload = json.loads(result.split("\n\n", 1)[1])
|
||||
|
||||
assert len(payload) == 1
|
||||
assert payload[0]["music_type"] == "album"
|
||||
assert payload[0]["total_tracks"] == 11
|
||||
assert payload[0]["media_id"] == "release-group-1"
|
||||
|
||||
@@ -2,6 +2,7 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.agent.tools.impl.transfer_file import TransferFileTool
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
def test_transfer_file_local_directory_without_trailing_slash_uses_dir(tmp_path):
|
||||
@@ -69,3 +70,49 @@ def test_transfer_file_remote_directory_still_uses_trailing_slash():
|
||||
assert result == "整理成功:/downloads/Show/"
|
||||
assert captured["fileitem"].type == "dir"
|
||||
assert captured["fileitem"].path == "/downloads/Show/"
|
||||
|
||||
|
||||
def test_transfer_file_remote_album_uses_entity_type_without_trailing_slash():
|
||||
"""远程专辑路径应由显式 album 语义判定为目录。"""
|
||||
captured = {}
|
||||
|
||||
def _manual_transfer(self, **kwargs):
|
||||
"""记录工具传给整理链路的参数。"""
|
||||
captured.update(kwargs)
|
||||
return True, None
|
||||
|
||||
with patch(
|
||||
"app.chain.transfer.TransferChain.manual_transfer",
|
||||
new=_manual_transfer,
|
||||
):
|
||||
result = TransferFileTool._transfer_file_sync(
|
||||
"downloads/Jay/叶惠美",
|
||||
storage="alist",
|
||||
media_type="music",
|
||||
music_type="album",
|
||||
media_source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
)
|
||||
|
||||
assert result == "整理成功:/downloads/Jay/叶惠美"
|
||||
assert captured["fileitem"].type == "dir"
|
||||
assert captured["mtype"] == MediaType.MUSIC
|
||||
assert captured["media_id"] == "release-group-1"
|
||||
|
||||
|
||||
def test_transfer_file_rejects_recording_directory(tmp_path):
|
||||
"""单曲实体不能覆盖一个目录,避免把整专误当成一首歌。"""
|
||||
source_dir = tmp_path / "Album"
|
||||
source_dir.mkdir()
|
||||
|
||||
with patch("app.chain.transfer.TransferChain.manual_transfer") as manual_transfer:
|
||||
result = TransferFileTool._transfer_file_sync(
|
||||
str(source_dir),
|
||||
media_type="music",
|
||||
music_type="recording",
|
||||
media_source="musicbrainz",
|
||||
media_id="recording-1",
|
||||
)
|
||||
|
||||
assert "单曲必须按一个音频文件整理" in result
|
||||
manual_transfer.assert_not_called()
|
||||
|
||||
@@ -23,10 +23,11 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
"""本次修改过的内置技能必须递增版本,确保用户端同步更新。"""
|
||||
expected_versions = {
|
||||
"database-operation": "3",
|
||||
"moviepilot-api": "10",
|
||||
"moviepilot-api": "11",
|
||||
"moviepilot-cli": "6",
|
||||
"moviepilot-update": "3",
|
||||
"transfer-failed-retry": "2",
|
||||
"organize-files": "2",
|
||||
"transfer-failed-retry": "3",
|
||||
"create-moviepilot-plugin": "3",
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ def test_media_type_supports_music_agent_conversion():
|
||||
assert MediaType.MUSIC.to_agent() == "music"
|
||||
assert media_type_to_agent(MediaType.MUSIC) == "music"
|
||||
assert media_type_to_agent("music") == "music"
|
||||
assert media_type_to_agent(MediaType.MUSIC.value) == "music"
|
||||
assert media_type_to_agent(MediaType.MOVIE.value) == "movie"
|
||||
assert media_type_to_agent(MediaType.TV.value) == "tv"
|
||||
|
||||
|
||||
def test_music_meta_round_trip_preserves_list_isolation():
|
||||
|
||||
@@ -6,9 +6,10 @@ import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.context import MUSIC_ENTITY_ALBUM, MusicInfo
|
||||
from app.core.meta import MetaBase, MetaMusic
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.schemas import FileItem
|
||||
from app.schemas import FileItem, TransferInfo
|
||||
|
||||
|
||||
def test_transferhistory_migration_backfills_existing_source_ids(monkeypatch) -> None:
|
||||
@@ -80,3 +81,70 @@ def test_failed_transfer_history_preserves_explicit_media_source() -> None:
|
||||
call = oper.add_force.call_args
|
||||
assert call.kwargs["media_source"] == "anilist"
|
||||
assert call.kwargs["media_id"] == "154587"
|
||||
|
||||
|
||||
def test_transferhistory_music_migration_is_idempotent(monkeypatch) -> None:
|
||||
"""整理历史音乐字段迁移应支持重复执行。"""
|
||||
migration = importlib.import_module(
|
||||
"database.versions.d4f6a8c2e1b7_2_2_17"
|
||||
)
|
||||
engine = sa.create_engine("sqlite://")
|
||||
metadata = sa.MetaData()
|
||||
sa.Table(
|
||||
"transferhistory",
|
||||
metadata,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
)
|
||||
|
||||
with engine.begin() as connection:
|
||||
metadata.create_all(connection)
|
||||
context = MigrationContext.configure(connection)
|
||||
monkeypatch.setattr(migration, "op", Operations(context))
|
||||
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
|
||||
columns = {
|
||||
column["name"]
|
||||
for column in sa.inspect(connection).get_columns("transferhistory")
|
||||
}
|
||||
|
||||
assert {"music_type", "total_tracks"}.issubset(columns)
|
||||
|
||||
|
||||
def test_transfer_history_preserves_album_entity_context() -> None:
|
||||
"""整理成功记录应保存整专实体和预期曲目数供 Agent 重试。"""
|
||||
oper = object.__new__(TransferHistoryOper)
|
||||
oper.add_force = Mock(return_value=SimpleNamespace(id=1))
|
||||
media = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_id="release-group-1",
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title="叶惠美",
|
||||
album="叶惠美",
|
||||
artists=["周杰伦"],
|
||||
total_tracks=11,
|
||||
)
|
||||
meta = MetaMusic(title="叶惠美", artists=["周杰伦"], total_tracks=11)
|
||||
|
||||
oper.add_success(
|
||||
fileitem=FileItem(
|
||||
storage="local",
|
||||
path="/downloads/叶惠美/01.flac",
|
||||
type="file",
|
||||
),
|
||||
mode="copy",
|
||||
meta=meta,
|
||||
mediainfo=media,
|
||||
transferinfo=TransferInfo(
|
||||
target_item=FileItem(
|
||||
storage="local",
|
||||
path="/music/周杰伦/叶惠美/01.flac",
|
||||
type="file",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
call = oper.add_force.call_args
|
||||
assert call.kwargs["music_type"] == "album"
|
||||
assert call.kwargs["total_tracks"] == 11
|
||||
|
||||
Reference in New Issue
Block a user