feat(music): add audio quality workflow

This commit is contained in:
jxxghp
2026-08-10 10:23:02 +08:00
parent b3b376f2b1
commit 75e56d8e9a
31 changed files with 1085 additions and 12 deletions

View File

@@ -119,6 +119,10 @@ SYSTEMCONFIG_SETTING_METADATA = {
"group": "subscribe_defaults",
"label": "默认电视剧订阅规则",
},
SystemConfigKey.DefaultMusicSubscribeConfig.value: {
"group": "subscribe_defaults",
"label": "默认音乐订阅规则",
},
SystemConfigKey.UserInstalledPlugins.value: {
"group": "plugins",
"label": "已安装插件列表",

View File

@@ -68,6 +68,21 @@ class AddSubscribeInput(BaseModel):
None,
description="Effect filter as regular expression (optional, e.g., 'HDR|DV|SDR')",
)
audio_quality: Optional[str] = Field(
None,
description="Music quality tier filter: hires, lossless, lossy, or a regular-expression combination",
)
audio_format: Optional[str] = Field(
None,
description="Music audio-format filter as a regular expression, e.g. FLAC|ALAC|DSD",
)
min_bitrate: Optional[int] = Field(None, description="Minimum music bitrate in bits per second")
min_bit_depth: Optional[int] = Field(None, description="Minimum music bit depth")
min_sample_rate: Optional[int] = Field(None, description="Minimum music sample rate in Hz")
best_version: Optional[int] = Field(
None,
description="Enable quality upgrades: 0 for no, 1 for yes. Music upgrades use normalized audio quality",
)
filter_groups: Optional[List[str]] = Field(
None,
description="List of filter rule group names to apply (optional, can be obtained from query_rule_groups tool)",
@@ -169,6 +184,12 @@ class AddSubscribeTool(MoviePilotTool):
quality: Optional[str] = None,
resolution: Optional[str] = None,
effect: Optional[str] = None,
audio_quality: Optional[str] = None,
audio_format: Optional[str] = None,
min_bitrate: Optional[int] = None,
min_bit_depth: Optional[int] = None,
min_sample_rate: Optional[int] = None,
best_version: Optional[int] = None,
filter_groups: Optional[List[str]] = None,
sites: Optional[List[int]] = None,
**kwargs,
@@ -205,6 +226,13 @@ class AddSubscribeTool(MoviePilotTool):
return "错误:音乐订阅没有季集参数,不能传入 season、start_episode 或 total_episode"
elif music_type:
return "错误music_type 仅能与 media_type='music' 一起使用"
audio_filter_values = (
audio_quality, audio_format, min_bitrate, min_bit_depth, min_sample_rate
)
if media_type_enum != MediaType.MUSIC and any(
value is not None for value in audio_filter_values
):
return "错误audio_quality、audio_format 和音频技术参数仅用于音乐订阅"
effective_season = (
season
if season is not None
@@ -228,6 +256,18 @@ class AddSubscribeTool(MoviePilotTool):
subscribe_kwargs["resolution"] = resolution
if effect:
subscribe_kwargs["effect"] = effect
if audio_quality:
subscribe_kwargs["audio_quality"] = audio_quality
if audio_format:
subscribe_kwargs["audio_format"] = audio_format
if min_bitrate is not None:
subscribe_kwargs["min_bitrate"] = min_bitrate
if min_bit_depth is not None:
subscribe_kwargs["min_bit_depth"] = min_bit_depth
if min_sample_rate is not None:
subscribe_kwargs["min_sample_rate"] = min_sample_rate
if best_version is not None:
subscribe_kwargs["best_version"] = best_version
if filter_groups:
subscribe_kwargs["filter_groups"] = filter_groups
if sites:
@@ -276,6 +316,18 @@ class AddSubscribeTool(MoviePilotTool):
params.append(f"分辨率过滤: {resolution}")
if effect:
params.append(f"特效过滤: {effect}")
if audio_quality:
params.append(f"音质等级: {audio_quality}")
if audio_format:
params.append(f"音频格式: {audio_format}")
if min_bitrate is not None:
params.append(f"最低码率: {round(min_bitrate / 1000)}kbps")
if min_bit_depth is not None:
params.append(f"最低位深: {min_bit_depth}bit")
if min_sample_rate is not None:
params.append(f"最低采样率: {min_sample_rate / 1000:g}kHz")
if best_version is not None:
params.append(f"音质洗版: {'开启' if best_version else '关闭'}")
if filter_groups:
params.append(f"规则组: {', '.join(filter_groups)}")
if sites:

View File

@@ -38,6 +38,11 @@ QUERY_SUBSCRIBE_OUTPUT_FIELDS = [
"quality",
"resolution",
"effect",
"audio_quality",
"audio_format",
"min_bitrate",
"min_bit_depth",
"min_sample_rate",
"state",
"last_update",
"sites",
@@ -45,6 +50,10 @@ QUERY_SUBSCRIBE_OUTPUT_FIELDS = [
"best_version",
"best_version_full",
"current_priority",
"current_audio_format",
"current_bitrate",
"current_bit_depth",
"current_sample_rate",
"episode_priority",
"save_path",
"custom_words",

View File

@@ -47,6 +47,11 @@ class UpdateSubscribeInput(BaseModel):
None,
description="Effect filter as regular expression (optional, e.g., 'HDR|DV|SDR')",
)
audio_quality: Optional[str] = Field(None, description="Music quality tier filter")
audio_format: Optional[str] = Field(None, description="Music audio-format regular expression")
min_bitrate: Optional[int] = Field(None, description="Minimum music bitrate in bits per second")
min_bit_depth: Optional[int] = Field(None, description="Minimum music bit depth")
min_sample_rate: Optional[int] = Field(None, description="Minimum music sample rate in Hz")
include: Optional[str] = Field(
None, description="Include filter as regular expression (optional)"
)
@@ -144,6 +149,11 @@ class UpdateSubscribeTool(MoviePilotTool):
quality: Optional[str] = None,
resolution: Optional[str] = None,
effect: Optional[str] = None,
audio_quality: Optional[str] = None,
audio_format: Optional[str] = None,
min_bitrate: Optional[int] = None,
min_bit_depth: Optional[int] = None,
min_sample_rate: Optional[int] = None,
include: Optional[str] = None,
exclude: Optional[str] = None,
filter: Optional[str] = None,
@@ -187,6 +197,14 @@ class UpdateSubscribeTool(MoviePilotTool):
},
ensure_ascii=False,
)
if media_type_to_agent(subscribe.type) != "music" and any(
value is not None
for value in (audio_quality, audio_format, min_bitrate, min_bit_depth, min_sample_rate)
):
return json.dumps(
{"success": False, "message": "音质等级、音频格式和音频技术参数仅用于音乐订阅"},
ensure_ascii=False,
)
# 保存旧数据用于事件
old_subscribe_dict = subscribe.to_dict()
@@ -231,6 +249,16 @@ class UpdateSubscribeTool(MoviePilotTool):
subscribe_dict["resolution"] = resolution
if effect is not None:
subscribe_dict["effect"] = effect
if audio_quality is not None:
subscribe_dict["audio_quality"] = audio_quality
if audio_format is not None:
subscribe_dict["audio_format"] = audio_format
if min_bitrate is not None:
subscribe_dict["min_bitrate"] = min_bitrate
if min_bit_depth is not None:
subscribe_dict["min_bit_depth"] = min_bit_depth
if min_sample_rate is not None:
subscribe_dict["min_sample_rate"] = min_sample_rate
if include is not None:
subscribe_dict["include"] = include
if exclude is not None:

View File

@@ -375,6 +375,10 @@ async def reset_subscribes(
"note": [],
"lack_episode": subscribe.total_episode,
"current_priority": None,
"current_audio_format": None,
"current_bitrate": None,
"current_bit_depth": None,
"current_sample_rate": None,
"episode_priority": {},
# 重置代表放弃手动总集数,后续订阅检查重新按 TMDB 集数更新。
"manual_total_episode": 0,

View File

@@ -70,6 +70,7 @@ _PUBLIC_SYSTEM_CONFIG_KEYS = {
SystemConfigKey.EpisodeFormatRuleTable,
SystemConfigKey.DefaultMovieSubscribeConfig,
SystemConfigKey.DefaultTvSubscribeConfig,
SystemConfigKey.DefaultMusicSubscribeConfig,
SystemConfigKey.FollowSubscribers,
)
}

View File

@@ -31,6 +31,7 @@ class MusicChain(ChainBase):
"""将用户输入的搜索关键词解析为音乐元数据。"""
normalized = cls._normalize_text(query)
meta = MetaMusic(org_string=query, title=normalized)
meta.apply_audio_quality(normalized)
match = cls._artist_title_pattern.match(normalized)
if match:
meta.artists = [match.group("artist").strip()]
@@ -332,7 +333,7 @@ class MusicChain(ChainBase):
meta = await run_in_threadpool(self.read_path_meta, path)
# 统一识别入口分发到音乐模块,模块负责详情/搜索/匹配/兜底
info = await self.async_recognize_media(meta=meta, source=source)
return meta, info or self._info_from_meta(meta)
return meta, self._merge_audio_quality(info or self._info_from_meta(meta), meta)
def recognize_by_path(
self,
@@ -343,7 +344,7 @@ class MusicChain(ChainBase):
meta = self.read_path_meta(path)
# 统一识别入口分发到音乐模块,模块负责详情/搜索/匹配/兜底
info = self.recognize_media(meta=meta, source=source)
return meta, info or self._info_from_meta(meta)
return meta, self._merge_audio_quality(info or self._info_from_meta(meta), meta)
@classmethod
def to_meta(cls, info: MusicInfo) -> MetaMusic:
@@ -358,6 +359,11 @@ class MusicChain(ChainBase):
track_number=info.track_number,
total_tracks=info.total_tracks,
version=info.version,
audio_format=info.audio_format,
audio_lossless=info.audio_lossless,
bit_depth=info.bit_depth,
sample_rate=info.sample_rate,
bitrate=info.bitrate,
duration=info.duration,
isrc=info.isrc,
media_source=info.source,
@@ -381,9 +387,23 @@ class MusicChain(ChainBase):
duration=meta.duration,
isrc=meta.isrc,
version=meta.version,
audio_format=meta.audio_format,
audio_lossless=meta.audio_lossless,
bit_depth=meta.bit_depth,
sample_rate=meta.sample_rate,
bitrate=meta.bitrate,
names=[name for name in (meta.title, meta.album) if name],
)
@staticmethod
def _merge_audio_quality(info: MusicInfo, meta: MetaMusic) -> MusicInfo:
"""将本地文件的实际音频参数合并到远端音乐身份识别结果。"""
for key in ("audio_format", "audio_lossless", "bit_depth", "sample_rate", "bitrate"):
value = getattr(meta, key, None)
if value is not None:
setattr(info, key, value)
return info
@classmethod
def _candidate_identity(cls, info: MusicInfo) -> tuple[str, ...]:
"""构造跨来源稳定的候选去重键。"""

View File

@@ -1022,10 +1022,12 @@ class SearchChain(ChainBase):
) -> Any:
"""根据限定媒体类型构造模糊搜索结果的上下文元数据。"""
if mtype == MediaType.MUSIC:
return MetaMusic(
meta = MetaMusic(
org_string=torrent.title,
title=torrent.title,
)
meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}")
return meta
return MetaInfo(title=torrent.title, subtitle=torrent.description)
def __filter_title_search_torrents(self,
@@ -1383,6 +1385,7 @@ class SearchChain(ChainBase):
for torrent in torrents:
meta = MusicChain.to_meta(mediainfo)
meta.org_string = torrent.title
meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}", overwrite=True)
contexts.append(
Context(
torrent_info=torrent,

View File

@@ -868,6 +868,16 @@ class SubscribeChain(ChainBase):
"resolution") else kwargs.get("resolution"),
'effect': self.__get_default_subscribe_config(mtype, "effect") if not kwargs.get(
"effect") else kwargs.get("effect"),
'audio_quality': self.__get_default_subscribe_config(mtype, "audio_quality") if not kwargs.get(
"audio_quality") else kwargs.get("audio_quality"),
'audio_format': self.__get_default_subscribe_config(mtype, "audio_format") if not kwargs.get(
"audio_format") else kwargs.get("audio_format"),
'min_bitrate': self.__get_default_subscribe_config(mtype, "min_bitrate") if not kwargs.get(
"min_bitrate") else kwargs.get("min_bitrate"),
'min_bit_depth': self.__get_default_subscribe_config(mtype, "min_bit_depth") if not kwargs.get(
"min_bit_depth") else kwargs.get("min_bit_depth"),
'min_sample_rate': self.__get_default_subscribe_config(mtype, "min_sample_rate") if not kwargs.get(
"min_sample_rate") else kwargs.get("min_sample_rate"),
'include': self.__get_default_subscribe_config(mtype, "include") if not kwargs.get(
"include") else kwargs.get("include"),
'exclude': self.__get_default_subscribe_config(mtype, "exclude") if not kwargs.get(
@@ -888,9 +898,8 @@ class SubscribeChain(ChainBase):
"filter_groups") else kwargs.get("filter_groups")
}
if mtype == MediaType.MUSIC:
# 音乐订阅当前只负责首次获取,不复用影视洗版和 IMDB 搜索语义。
# 音乐允许按音质洗版,但没有电视剧整包洗版和 IMDB 搜索语义。
defaults.update({
"best_version": 0,
"best_version_full": 0,
"search_imdbid": 0,
})
@@ -1552,8 +1561,9 @@ class SubscribeChain(ChainBase):
) -> List[Context]:
"""按站点、音乐实体、订阅参数和优先级规则筛选并绑定下载上下文。"""
sites = self.get_sub_sites(subscribe)
rule_groups = subscribe.filter_groups \
or SystemConfigOper().get(SystemConfigKey.SubscribeFilterRuleGroups) or []
default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \
if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups
rule_groups = subscribe.filter_groups or SystemConfigOper().get(default_rule_key) or []
torrent_helper = TorrentHelper()
matched: List[Context] = []
for source_context in contexts or []:
@@ -1577,6 +1587,18 @@ class SubscribeChain(ChainBase):
context = copy.copy(source_context)
meta = MusicChain.to_meta(mediainfo)
meta.org_string = torrent.title
meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}", overwrite=True)
if subscribe.best_version:
# 用户规则组可用格式、码率等内置规则定义洗版顺序;未命中规则
# 优先级时再回退到规范化音质分数,确保零配置也能自动升级。
music_priority = torrent.pri_order or meta.audio_quality_score
if music_priority <= (subscribe.current_priority or 0):
logger.info(
f"{torrent.title} 音质优先级 {music_priority} "
f"未高于当前版本 {subscribe.current_priority or 0}"
)
continue
torrent.pri_order = music_priority
context.meta_info = meta
context.media_info = mediainfo
context.match_source = mediainfo.source or "title"
@@ -1604,6 +1626,23 @@ class SubscribeChain(ChainBase):
source=self.get_subscribe_source_keyword(subscribe),
custom_words=subscribe.custom_words,
)
successful = [
context for context in downloads or []
if context and context.meta_info and context.torrent_info
]
if subscribe.best_version and successful:
best_context = max(successful, key=lambda item: item.torrent_info.pri_order)
best_meta = best_context.meta_info
quality_data = {
"current_priority": best_context.torrent_info.pri_order,
"current_audio_format": best_meta.audio_format,
"current_bitrate": best_meta.bitrate,
"current_bit_depth": best_meta.bit_depth,
"current_sample_rate": best_meta.sample_rate,
}
SubscribeOper().update(subscribe.id, quality_data)
for key, value in quality_data.items():
setattr(subscribe, key, value)
current_subscribe = SubscribeOper().get(subscribe.id)
if current_subscribe:
self.finish_subscribe_or_not(
@@ -1621,8 +1660,9 @@ class SubscribeChain(ChainBase):
mediainfo, _ = target
sites = self.get_sub_sites(subscribe)
rule_groups = subscribe.filter_groups \
or SystemConfigOper().get(SystemConfigKey.SubscribeFilterRuleGroups) or []
default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \
if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups
rule_groups = subscribe.filter_groups or SystemConfigOper().get(default_rule_key) or []
keywords = [subscribe.keyword] if subscribe.keyword else MusicChain.build_site_keywords(mediainfo)
if not keywords:
keywords = [subscribe.name]
@@ -4171,6 +4211,8 @@ class SubscribeChain(ChainBase):
default_subscribe_key = SystemConfigKey.DefaultTvSubscribeConfig.value
if mtype == MediaType.MOVIE:
default_subscribe_key = SystemConfigKey.DefaultMovieSubscribeConfig.value
if mtype == MediaType.MUSIC:
default_subscribe_key = SystemConfigKey.DefaultMusicSubscribeConfig.value
if not default_subscribe_key:
return None
@@ -4199,6 +4241,11 @@ class SubscribeChain(ChainBase):
"quality": subscribe.quality or default_rule.get("quality"),
"resolution": subscribe.resolution or default_rule.get("resolution"),
"effect": subscribe.effect or default_rule.get("effect"),
"audio_quality": getattr(subscribe, "audio_quality", None),
"audio_format": getattr(subscribe, "audio_format", None),
"min_bitrate": getattr(subscribe, "min_bitrate", None),
"min_bit_depth": getattr(subscribe, "min_bit_depth", None),
"min_sample_rate": getattr(subscribe, "min_sample_rate", None),
"tv_size": default_rule.get("tv_size"),
"movie_size": default_rule.get("movie_size"),
"min_seeders": default_rule.get("min_seeders"),

View File

@@ -5,6 +5,13 @@ from typing import List, Dict, Any, Tuple, Optional, Set, Union, Self
from app.core.config import settings
from app.core.meta import MetaBase
from app.core.meta.metamusic import (
audio_quality_score,
audio_quality_tier,
format_audio_quality,
infer_audio_lossless,
normalize_audio_format,
)
from app.core.metainfo import MetaInfo
from app.schemas.types import MediaType
from app.utils.string import StringUtils
@@ -146,6 +153,11 @@ class MusicInfo:
cover_url: str | None = None
lyrics: str | None = None
version: str | None = None
audio_format: str | None = None
audio_lossless: bool | None = None
bit_depth: int | None = None
sample_rate: int | None = None
bitrate: int | None = None
category: str = ""
genres: list[str] = field(default_factory=list)
names: list[str] = field(default_factory=list)
@@ -158,6 +170,27 @@ class MusicInfo:
"""返回兼容现有展示组件的艺术家文本。"""
return " / ".join(self.artists)
@property
def audio_quality(self) -> str | None:
"""返回 hires、lossless 或 lossy 音质等级。"""
return audio_quality_tier(
self.audio_format, self.audio_lossless, self.bit_depth, self.sample_rate, self.bitrate
)
@property
def audio_quality_score(self) -> int:
"""返回音乐订阅洗版使用的音质优先级。"""
return audio_quality_score(
self.audio_format, self.audio_lossless, self.bit_depth, self.sample_rate, self.bitrate
)
@property
def audio_specs(self) -> str | None:
"""返回识别结果和通知使用的格式化音频参数。"""
return format_audio_quality(
self.audio_format, self.audio_lossless, self.bit_depth, self.sample_rate, self.bitrate
)
@property
def tmdb_id(self) -> None:
"""音乐不使用 TMDB ID兼容现有下载历史字段。"""
@@ -255,6 +288,9 @@ class MusicInfo:
"mediaid_prefix": self.source,
"overview": self.overview,
"vote_average": self.vote_average,
"audio_quality": self.audio_quality,
"audio_quality_score": self.audio_quality_score,
"audio_specs": self.audio_specs,
}
)
return payload
@@ -269,6 +305,10 @@ class MusicInfo:
values["genres"] = _music_string_list(values.get("genres"))
values["names"] = _music_string_list(values.get("names"))
values["music_type"] = str(values.get("music_type") or MUSIC_ENTITY_RECORDING)
values["audio_format"] = normalize_audio_format(values.get("audio_format"))
values["audio_lossless"] = infer_audio_lossless(
values.get("audio_format"), values.get("audio_lossless")
)
values["raw_data"] = dict(values.get("raw_data") or {})
for key in (
"year",
@@ -277,6 +317,9 @@ class MusicInfo:
"total_tracks",
"duration",
"listen_count",
"bit_depth",
"sample_rate",
"bitrate",
):
values[key] = _music_optional_int(values.get(key))
return cls(**values)

View File

@@ -1,9 +1,175 @@
import re
from typing import Any, Optional
from app.core.meta.metabase import MetaBase
from app.schemas.types import MediaType
_AUDIO_FORMAT_PATTERN = re.compile(
r"(?<![A-Z])(?P<format>DSD(?:64|128|256|512)?|DSF|DFF|FLAC|ALAC|APE|WAV|WAVE|AIFF?|PCM|"
r"MP3|AAC|M4A|OGG|VORBIS|OPUS|WMA)(?![A-Z])",
re.IGNORECASE,
)
_BIT_DEPTH_PATTERN = re.compile(r"(?<!\d)(?P<value>16|20|24|32)\s*(?:-?bit|bits?|位)(?!\w)", re.IGNORECASE)
_SAMPLE_RATE_PATTERN = re.compile(
r"(?<!\d)(?P<value>44(?:\.1)?|48|88(?:\.2)?|96|176(?:\.4)?|192|352(?:\.8)?|384|705(?:\.6)?|768)"
r"\s*k(?:hz)?(?!\w)",
re.IGNORECASE,
)
_BITRATE_PATTERN = re.compile(
r"(?<!\d)(?P<value>\d{2,4})\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])",
re.IGNORECASE,
)
_LOSSLESS_PATTERN = re.compile(r"(?<!\w)(?:lossless|无损|无损音质)(?!\w)", re.IGNORECASE)
_HIRES_PATTERN = re.compile(r"(?<!\w)(?:hi[ ._-]?res(?:olution)?|高解析|高分辨率音频)(?!\w)", re.IGNORECASE)
_AUDIO_FORMAT_ALIASES = {
"WAVE": "WAV",
"AIF": "AIFF",
"VORBIS": "OGG",
"M4A": "AAC",
"DSF": "DSD",
"DFF": "DSD",
}
_LOSSLESS_AUDIO_FORMATS = frozenset({"DSD", "FLAC", "ALAC", "APE", "WAV", "AIFF", "PCM"})
_LOSSY_AUDIO_FORMATS = frozenset({"MP3", "AAC", "OGG", "OPUS", "WMA"})
def normalize_audio_format(value: Any) -> Optional[str]:
"""将音频格式名称归一为订阅筛选和展示使用的规范值。"""
text = str(value or "").strip().upper()
if not text:
return None
match = _AUDIO_FORMAT_PATTERN.search(text)
if not match:
return text
normalized = match.group("format").upper()
if normalized.startswith("DSD"):
return "DSD"
return _AUDIO_FORMAT_ALIASES.get(normalized, normalized)
def infer_audio_lossless(audio_format: Any, explicit: Optional[bool] = None) -> Optional[bool]:
"""根据格式推断是否无损;显式识别结果优先于格式推断。"""
if explicit is not None:
return bool(explicit)
normalized = normalize_audio_format(audio_format)
if normalized in _LOSSLESS_AUDIO_FORMATS:
return True
if normalized in _LOSSY_AUDIO_FORMATS:
return False
return None
def parse_audio_quality(value: Any) -> dict[str, Any]:
"""从资源标题或描述中提取声明的格式、位深、采样率和码率。"""
text = str(value or "")
format_match = _AUDIO_FORMAT_PATTERN.search(text)
bit_depth_match = _BIT_DEPTH_PATTERN.search(text)
sample_rate_match = _SAMPLE_RATE_PATTERN.search(text)
bitrate_match = _BITRATE_PATTERN.search(text)
audio_format = normalize_audio_format(format_match.group("format")) if format_match else None
bit_depth = int(bit_depth_match.group("value")) if bit_depth_match else None
sample_rate = (
int(float(sample_rate_match.group("value")) * 1000)
if sample_rate_match
else None
)
bitrate = int(bitrate_match.group("value")) * 1000 if bitrate_match else None
explicit_lossless = True if (_LOSSLESS_PATTERN.search(text) or _HIRES_PATTERN.search(text)) else None
return {
"audio_format": audio_format,
"audio_lossless": infer_audio_lossless(audio_format, explicit_lossless),
"bit_depth": bit_depth,
"sample_rate": sample_rate,
"bitrate": bitrate,
}
def audio_quality_tier(
audio_format: Any,
audio_lossless: Optional[bool] = None,
bit_depth: Optional[int] = None,
sample_rate: Optional[int] = None,
bitrate: Optional[int] = None,
) -> Optional[str]:
"""返回 hires、lossless 或 lossy 音质等级,未知参数返回 None。"""
normalized = normalize_audio_format(audio_format)
lossless = infer_audio_lossless(normalized, audio_lossless)
if normalized == "DSD" or (lossless and ((bit_depth or 0) >= 24 or (sample_rate or 0) >= 88200)):
return "hires"
if lossless:
return "lossless"
if lossless is False or normalized or bitrate:
return "lossy"
return None
def audio_quality_score(
audio_format: Any,
audio_lossless: Optional[bool] = None,
bit_depth: Optional[int] = None,
sample_rate: Optional[int] = None,
bitrate: Optional[int] = None,
) -> int:
"""将音乐音质换算为 0 至 100 的稳定洗版优先级。"""
normalized = normalize_audio_format(audio_format)
lossless = infer_audio_lossless(normalized, audio_lossless)
if normalized == "DSD" or (lossless and (bit_depth or 0) >= 24 and (sample_rate or 0) >= 192000):
return 100
if lossless:
score = 86
if (bit_depth or 0) >= 24:
score += 5
elif (bit_depth or 0) >= 16:
score += 2
if (sample_rate or 0) >= 176400:
score += 7
elif (sample_rate or 0) >= 88200:
score += 5
elif sample_rate:
score += 2
return min(score, 99)
if bitrate:
kbps = bitrate // 1000
if kbps >= 320:
return 80
if kbps >= 256:
return 70
if kbps >= 192:
return 60
if kbps >= 128:
return 50
return 40
return 35 if normalized in _LOSSY_AUDIO_FORMATS else 0
def format_audio_quality(
audio_format: Any,
audio_lossless: Optional[bool] = None,
bit_depth: Optional[int] = None,
sample_rate: Optional[int] = None,
bitrate: Optional[int] = None,
) -> Optional[str]:
"""将音频技术参数格式化为适合识别结果和通知展示的紧凑文本。"""
parts: list[str] = []
normalized = normalize_audio_format(audio_format)
if normalized:
parts.append(normalized)
if bit_depth:
parts.append(f"{bit_depth}-bit")
if sample_rate:
rate = sample_rate / 1000
parts.append(f"{rate:g} kHz")
if bitrate:
parts.append(f"{round(bitrate / 1000):,} kbps")
if not parts:
tier = audio_quality_tier(audio_format, audio_lossless, bit_depth, sample_rate, bitrate)
if tier:
parts.append({"hires": "Hi-Res", "lossless": "Lossless", "lossy": "Lossy"}[tier])
return " · ".join(parts) or None
def _optional_int(value: Any) -> Optional[int]:
"""将音频技术参数安全转换为整数,空值与非数字返回 None。"""
if value in (None, ""):
@@ -42,6 +208,7 @@ class MetaMusic(MetaBase):
total_tracks: Optional[int] = None,
version: Optional[str] = None,
audio_format: Optional[str] = None,
audio_lossless: Optional[bool] = None,
bit_depth: Optional[int] = None,
sample_rate: Optional[int] = None,
bitrate: Optional[int] = None,
@@ -64,7 +231,8 @@ class MetaMusic(MetaBase):
self.total_discs = total_discs
self.total_tracks = total_tracks
self.version = version
self.audio_format = audio_format
self.audio_format = normalize_audio_format(audio_format)
self.audio_lossless = infer_audio_lossless(self.audio_format, audio_lossless)
self.bit_depth = bit_depth
self.sample_rate = sample_rate
self.bitrate = bitrate
@@ -93,6 +261,36 @@ class MetaMusic(MetaBase):
"""返回兼容现有展示组件的艺术家文本。"""
return " / ".join(self.artists)
@property
def audio_quality(self) -> Optional[str]:
"""返回 hires、lossless 或 lossy 音质等级。"""
return audio_quality_tier(
self.audio_format, self.audio_lossless, self.bit_depth, self.sample_rate, self.bitrate
)
@property
def audio_quality_score(self) -> int:
"""返回订阅洗版使用的音质优先级。"""
return audio_quality_score(
self.audio_format, self.audio_lossless, self.bit_depth, self.sample_rate, self.bitrate
)
@property
def audio_specs(self) -> Optional[str]:
"""返回识别结果和通知使用的格式化音频参数。"""
return format_audio_quality(
self.audio_format, self.audio_lossless, self.bit_depth, self.sample_rate, self.bitrate
)
def apply_audio_quality(self, value: Any, overwrite: bool = False) -> None:
"""从资源文本补充音质参数,默认保留文件标签读取到的实际值。"""
parsed = parse_audio_quality(value)
for key, parsed_value in parsed.items():
if parsed_value is not None and (overwrite or getattr(self, key, None) is None):
setattr(self, key, parsed_value)
self.audio_format = normalize_audio_format(self.audio_format)
self.audio_lossless = infer_audio_lossless(self.audio_format, self.audio_lossless)
@property
def season(self) -> None:
"""音乐没有季信息,兼容下载与事件链的通用访问。"""
@@ -125,6 +323,10 @@ class MetaMusic(MetaBase):
"total_tracks": self.total_tracks,
"version": self.version,
"audio_format": self.audio_format,
"audio_lossless": self.audio_lossless,
"audio_quality": self.audio_quality,
"audio_quality_score": self.audio_quality_score,
"audio_specs": self.audio_specs,
"bit_depth": self.bit_depth,
"sample_rate": self.sample_rate,
"bitrate": self.bitrate,
@@ -153,6 +355,7 @@ class MetaMusic(MetaBase):
total_tracks=_optional_int(data.get("total_tracks")),
version=data.get("version"),
audio_format=data.get("audio_format"),
audio_lossless=data.get("audio_lossless"),
bit_depth=_optional_int(data.get("bit_depth")),
sample_rate=_optional_int(data.get("sample_rate")),
bitrate=_optional_int(data.get("bitrate")),

View File

@@ -56,6 +56,16 @@ class Subscribe(Base):
resolution = Column(String)
# 特效
effect = Column(String)
# 音乐音质等级hires/lossless/lossy可用正则组合
audio_quality = Column(String)
# 音频格式,可用正则组合
audio_format = Column(String)
# 最低码率bps
min_bitrate = Column(Integer)
# 最低位深bit
min_bit_depth = Column(Integer)
# 最低采样率Hz
min_sample_rate = Column(Integer)
# 总集数
total_episode = Column(Integer)
# 开始集数
@@ -82,6 +92,14 @@ class Subscribe(Base):
best_version_full = Column(Integer, default=0)
# 当前优先级
current_priority = Column(Integer)
# 当前音乐版本格式
current_audio_format = Column(String)
# 当前音乐版本码率bps
current_bitrate = Column(Integer)
# 当前音乐版本位深bit
current_bit_depth = Column(Integer)
# 当前音乐版本采样率Hz
current_sample_rate = Column(Integer)
# 洗版时已下载剧集的优先级状态,格式:{"1": 90, "2": 100}
episode_priority = Column(JSON)
# 保存路径

View File

@@ -55,6 +55,16 @@ class SubscribeHistory(Base):
resolution = Column(String)
# 特效
effect = Column(String)
# 音乐音质等级hires/lossless/lossy可用正则组合
audio_quality = Column(String)
# 音频格式,可用正则组合
audio_format = Column(String)
# 最低码率bps
min_bitrate = Column(Integer)
# 最低位深bit
min_bit_depth = Column(Integer)
# 最低采样率Hz
min_sample_rate = Column(Integer)
# 总集数
total_episode = Column(Integer)
# 开始集数
@@ -69,6 +79,16 @@ class SubscribeHistory(Base):
best_version = Column(Integer, default=0)
# 是否只洗全集整包,开启后电视剧洗版不按单集下载
best_version_full = Column(Integer, default=0)
# 完成时的整体优先级
current_priority = Column(Integer)
# 完成时的音乐格式
current_audio_format = Column(String)
# 完成时的音乐码率bps
current_bitrate = Column(Integer)
# 完成时的音乐位深bit
current_bit_depth = Column(Integer)
# 完成时的音乐采样率Hz
current_sample_rate = Column(Integer)
# 洗版时已下载剧集的优先级状态,格式:{"1": 90, "2": 100}
episode_priority = Column(JSON)
# 保存路径

View File

@@ -58,6 +58,16 @@ class TransferHistory(Base):
music_type = Column(String)
# 专辑预期总曲目数
total_tracks = Column(Integer)
# 实际音频格式
audio_format = Column(String)
# 是否无损音频
audio_lossless = Column(Boolean)
# 实际位深bit
bit_depth = Column(Integer)
# 实际采样率Hz
sample_rate = Column(Integer)
# 实际码率bps
bitrate = Column(Integer)
# Sxx
seasons = Column(String)
# Exx

View File

@@ -252,6 +252,11 @@ class TransferHistoryOper(DbOper):
media_id=mediainfo.to_dict().get("media_id"),
music_type=getattr(mediainfo, "music_type", None),
total_tracks=getattr(mediainfo, "total_tracks", None),
audio_format=getattr(meta, "audio_format", None),
audio_lossless=getattr(meta, "audio_lossless", None),
bit_depth=getattr(meta, "bit_depth", None),
sample_rate=getattr(meta, "sample_rate", None),
bitrate=getattr(meta, "bitrate", None),
seasons=meta.season,
episodes=meta.episode,
image=mediainfo.get_poster_image(),
@@ -289,6 +294,11 @@ class TransferHistoryOper(DbOper):
media_id=mediainfo.to_dict().get("media_id"),
music_type=getattr(mediainfo, "music_type", None),
total_tracks=getattr(mediainfo, "total_tracks", None),
audio_format=getattr(meta, "audio_format", None),
audio_lossless=getattr(meta, "audio_lossless", None),
bit_depth=getattr(meta, "bit_depth", None),
sample_rate=getattr(meta, "sample_rate", None),
bitrate=getattr(meta, "bitrate", None),
seasons=meta.season,
episodes=meta.episode,
image=mediainfo.get_poster_image(),
@@ -309,6 +319,11 @@ class TransferHistoryOper(DbOper):
anilistid=meta.anilistid,
media_source=meta.media_source,
media_id=meta.media_id,
audio_format=getattr(meta, "audio_format", None),
audio_lossless=getattr(meta, "audio_lossless", None),
bit_depth=getattr(meta, "bit_depth", None),
sample_rate=getattr(meta, "sample_rate", None),
bitrate=getattr(meta, "bitrate", None),
src=fileitem.path,
src_storage=fileitem.storage,
src_fileitem=fileitem.model_dump(),

View File

@@ -123,6 +123,18 @@ class TemplateContextBuilder:
"duration": context.get("duration") or mediainfo.duration,
"isrc": context.get("isrc") or mediainfo.isrc,
"version": context.get("version") or mediainfo.version,
"audio_format": context.get("audio_format") or mediainfo.audio_format,
"audio_lossless": context.get("audio_lossless")
if context.get("audio_lossless") is not None else mediainfo.audio_lossless,
"audio_quality": context.get("audio_quality") or mediainfo.audio_quality,
"audio_specs": context.get("audio_specs") or mediainfo.audio_specs,
"bit_depth": context.get("bit_depth") or mediainfo.bit_depth,
"sample_rate": context.get("sample_rate") or mediainfo.sample_rate,
"sample_rate_khz": context.get("sample_rate_khz")
or (f"{mediainfo.sample_rate / 1000:g}" if mediainfo.sample_rate else None),
"bitrate": context.get("bitrate") or mediainfo.bitrate,
"bitrate_kbps": context.get("bitrate_kbps")
or (round(mediainfo.bitrate / 1000) if mediainfo.bitrate else None),
"category": mediainfo.category,
"poster": mediainfo.get_poster_image(),
"backdrop": mediainfo.get_backdrop_image(),
@@ -225,9 +237,14 @@ class TemplateContextBuilder:
"total_discs": meta.total_discs,
"total_tracks": meta.total_tracks,
"audio_format": meta.audio_format,
"audio_lossless": meta.audio_lossless,
"audio_quality": meta.audio_quality,
"audio_specs": meta.audio_specs,
"bit_depth": meta.bit_depth,
"sample_rate": meta.sample_rate,
"sample_rate_khz": f"{meta.sample_rate / 1000:g}" if meta.sample_rate else None,
"bitrate": meta.bitrate,
"bitrate_kbps": round(meta.bitrate / 1000) if meta.bitrate else None,
"duration": meta.duration,
"isrc": meta.isrc,
"version": meta.version,

View File

@@ -11,6 +11,7 @@ from app.core.cache import TTLCache, FileCache
from app.core.config import settings
from app.core.context import Context, TorrentInfo, MediaInfo
from app.core.meta import MetaBase
from app.core.meta.metamusic import audio_quality_tier, normalize_audio_format, parse_audio_quality
from app.core.metainfo import MetaInfo
from app.db.site_oper import SiteOper
from app.db.systemconfig_oper import SystemConfigOper
@@ -502,7 +503,7 @@ class TorrentHelper:
@staticmethod
def filter_torrent(torrent_info: TorrentInfo,
filter_params: Dict[str, str]) -> bool:
filter_params: Dict[str, Any]) -> bool:
"""
检查种子是否匹配订阅过滤规则
"""
@@ -547,6 +548,39 @@ class TorrentHelper:
logger.info(f"{torrent_info.title} 不匹配特效规则 {effect}")
return False
# 音乐音质。技术参数从标题、副标题和标签统一解析,避免只靠用户正则筛选。
audio_filters = {
key: filter_params.get(key)
for key in ("audio_quality", "audio_format", "min_bitrate", "min_bit_depth", "min_sample_rate")
if filter_params.get(key) not in (None, "")
}
if audio_filters:
specs = parse_audio_quality(content)
tier = audio_quality_tier(**specs)
audio_quality = audio_filters.get("audio_quality")
quality_pattern = "hires|lossless" \
if str(audio_quality).casefold() == "lossless" else audio_quality
if audio_quality and (not tier or not _filter_pattern_search(quality_pattern, tier)):
logger.info(f"{torrent_info.title} 不匹配音乐音质规则 {audio_quality}")
return False
audio_format = audio_filters.get("audio_format")
normalized_format = normalize_audio_format(specs.get("audio_format"))
if audio_format and (
not normalized_format or not _filter_pattern_search(audio_format, normalized_format)
):
logger.info(f"{torrent_info.title} 不匹配音频格式规则 {audio_format}")
return False
for key, spec_key, label in (
("min_bitrate", "bitrate", "码率"),
("min_bit_depth", "bit_depth", "位深"),
("min_sample_rate", "sample_rate", "采样率"),
):
minimum = audio_filters.get(key)
actual = specs.get(spec_key)
if minimum is not None and (actual is None or int(actual) < int(minimum)):
logger.info(f"{torrent_info.title} 不满足最低{label} {minimum}")
return False
# 大小
size_range = filter_params.get("size")
if size_range:

View File

@@ -128,4 +128,25 @@ BUILTIN_RULE_SET: Dict[str, dict] = {
"include": [r"3D"],
"exclude": [],
},
# Hi-Res 无损音频
"HIRES": {
"include": [r"(?i)\b(?:Hi[ ._-]?Res(?:olution)?|DSD(?:64|128|256|512)?)\b|高解析|(?:24|32)\s*(?:-?bit|位)"],
"exclude": [],
},
# 无损音频
"LOSSLESS": {
"include": [r"(?i)\b(?:Lossless|FLAC|ALAC|APE|WAV|WAVE|AIFF?|PCM|DSD|DSF|DFF)\b|无损"],
"exclude": [],
},
"FLAC": {"include": [r"(?i)(?<![A-Z0-9])FLAC(?![A-Z0-9])"], "exclude": []},
"ALAC": {"include": [r"(?i)(?<![A-Z0-9])ALAC(?![A-Z0-9])"], "exclude": []},
"APE": {"include": [r"(?i)(?<![A-Z0-9])APE(?![A-Z0-9])"], "exclude": []},
"WAV": {"include": [r"(?i)(?<![A-Z0-9])WAV(?:E)?(?![A-Z0-9])"], "exclude": []},
"DSD": {"include": [r"(?i)(?<![A-Z0-9])(?:DSD(?:64|128|256|512)?|DSF|DFF)(?![A-Z0-9])"], "exclude": []},
"MP3": {"include": [r"(?i)(?<![A-Z0-9])MP3(?![A-Z0-9])"], "exclude": []},
"AAC": {"include": [r"(?i)(?<![A-Z0-9])(?:AAC|M4A)(?![A-Z0-9])"], "exclude": []},
"OPUS": {"include": [r"(?i)(?<![A-Z0-9])OPUS(?![A-Z0-9])"], "exclude": []},
"BITRATE320": {"include": [r"(?i)(?<!\d)320\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])"], "exclude": []},
"BITRATE256": {"include": [r"(?i)(?<!\d)256\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])"], "exclude": []},
"BITRATE192": {"include": [r"(?i)(?<!\d)192\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])"], "exclude": []},
}

View File

@@ -109,6 +109,16 @@ class TransferHistory(BaseModel):
music_type: Optional[str] = None
# 专辑预期总曲目数
total_tracks: Optional[int] = None
# 实际音频格式
audio_format: Optional[str] = None
# 是否无损音频
audio_lossless: Optional[bool] = None
# 实际位深bit
bit_depth: Optional[int] = None
# 实际采样率Hz
sample_rate: Optional[int] = None
# 实际码率bps
bitrate: Optional[int] = None
# 季Sxx
seasons: Optional[str] = None
# 集Exx

View File

@@ -20,6 +20,10 @@ class MusicMeta(BaseModel):
total_tracks: Optional[int] = None
version: Optional[str] = None
audio_format: Optional[str] = None
audio_lossless: Optional[bool] = None
audio_quality: Optional[Literal["hires", "lossless", "lossy"]] = None
audio_quality_score: int = 0
audio_specs: Optional[str] = None
bit_depth: Optional[int] = None
sample_rate: Optional[int] = None
bitrate: Optional[int] = None
@@ -55,6 +59,14 @@ class MusicInfo(BaseModel):
cover_url: Optional[str] = None
lyrics: Optional[str] = None
version: Optional[str] = None
audio_format: Optional[str] = None
audio_lossless: Optional[bool] = None
audio_quality: Optional[Literal["hires", "lossless", "lossy"]] = None
audio_quality_score: int = 0
audio_specs: Optional[str] = None
bit_depth: Optional[int] = None
sample_rate: Optional[int] = None
bitrate: Optional[int] = None
category: Optional[str] = ""
genres: list[str] = Field(default_factory=list)
names: list[str] = Field(default_factory=list)

View File

@@ -48,6 +48,7 @@ class Subscribe(BaseModel):
PUBLIC_WRITE_EXCLUDED_FIELDS: ClassVar[frozenset[str]] = frozenset({
"id", "poster", "backdrop", "vote", "description", "lack_episode", "completed_episode",
"note", "state", "last_update", "username", "current_priority", "episode_priority", "date",
"current_audio_format", "current_bitrate", "current_bit_depth", "current_sample_rate",
})
id: Optional[int] = None
@@ -92,6 +93,16 @@ class Subscribe(BaseModel):
resolution: Optional[str] = None
# 特效
effect: Optional[str] = None
# 音乐音质等级,可用 | 组合 hires/lossless/lossy
audio_quality: Optional[str] = None
# 音频格式正则,如 FLAC|ALAC
audio_format: Optional[str] = None
# 最低码率bps
min_bitrate: Optional[int] = None
# 最低位深bit
min_bit_depth: Optional[int] = None
# 最低采样率Hz
min_sample_rate: Optional[int] = None
# 总集数
total_episode: Optional[int] = 0
# 开始集数
@@ -118,6 +129,14 @@ class Subscribe(BaseModel):
best_version_full: Optional[int] = None
# 当前优先级
current_priority: Optional[int] = None
# 当前音乐版本格式
current_audio_format: Optional[str] = None
# 当前音乐版本码率bps
current_bitrate: Optional[int] = None
# 当前音乐版本位深bit
current_bit_depth: Optional[int] = None
# 当前音乐版本采样率Hz
current_sample_rate: Optional[int] = None
# 洗版时已下载剧集的优先级状态
episode_priority: Optional[Dict[str, int]] = None
# 保存路径
@@ -222,6 +241,16 @@ class SubscribeShare(BaseModel):
resolution: Optional[str] = None
# 特效
effect: Optional[str] = None
# 音乐音质等级
audio_quality: Optional[str] = None
# 音频格式
audio_format: Optional[str] = None
# 最低码率bps
min_bitrate: Optional[int] = None
# 最低位深bit
min_bit_depth: Optional[int] = None
# 最低采样率Hz
min_sample_rate: Optional[int] = None
# 总集数
total_episode: Optional[int] = 0
# 时间

View File

@@ -273,6 +273,8 @@ class SystemConfigKey(Enum):
DefaultMovieSubscribeConfig = "DefaultMovieSubscribeConfig"
# 默认电视剧订阅规则
DefaultTvSubscribeConfig = "DefaultTvSubscribeConfig"
# 默认音乐订阅规则
DefaultMusicSubscribeConfig = "DefaultMusicSubscribeConfig"
# 用户站点认证参数
UserSiteAuthParams = "UserSiteAuthParams"
# Follow订阅分享者

View File

@@ -0,0 +1,157 @@
"""2.2.18
增加音乐音质订阅条件、洗版状态和整理历史参数
Revision ID: e8b1c4d7a2f9
Revises: d4f6a8c2e1b7
Create Date: 2026-08-10
"""
from alembic import op
import sqlalchemy as sa
revision = "e8b1c4d7a2f9"
down_revision = "d4f6a8c2e1b7"
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 _add_columns(table_name: str, columns: list[sa.Column]) -> None:
"""为指定数据表幂等增加字段。"""
for column in columns:
if not _has_column(table_name, column.name):
op.add_column(table_name, column)
def upgrade() -> None:
"""增加音乐音质筛选、洗版快照和整理历史字段。"""
def subscribe_filter_columns() -> list[sa.Column]:
"""构造可分别绑定到订阅表和历史表的筛选字段。"""
return [
sa.Column("audio_quality", sa.String(), nullable=True),
sa.Column("audio_format", sa.String(), nullable=True),
sa.Column("min_bitrate", sa.Integer(), nullable=True),
sa.Column("min_bit_depth", sa.Integer(), nullable=True),
sa.Column("min_sample_rate", sa.Integer(), nullable=True),
]
_add_columns("subscribe", [*subscribe_filter_columns(),
sa.Column("current_audio_format", sa.String(), nullable=True),
sa.Column("current_bitrate", sa.Integer(), nullable=True),
sa.Column("current_bit_depth", sa.Integer(), nullable=True),
sa.Column("current_sample_rate", sa.Integer(), nullable=True)])
_add_columns("subscribehistory", [*subscribe_filter_columns(),
sa.Column("current_priority", sa.Integer(), nullable=True),
sa.Column("current_audio_format", sa.String(), nullable=True),
sa.Column("current_bitrate", sa.Integer(), nullable=True),
sa.Column("current_bit_depth", sa.Integer(), nullable=True),
sa.Column("current_sample_rate", sa.Integer(), nullable=True)])
_add_columns("transferhistory", [
sa.Column("audio_format", sa.String(), nullable=True),
sa.Column("audio_lossless", sa.Boolean(), nullable=True),
sa.Column("bit_depth", sa.Integer(), nullable=True),
sa.Column("sample_rate", sa.Integer(), nullable=True),
sa.Column("bitrate", sa.Integer(), nullable=True),
])
# 只升级系统旧默认模板;用户编辑过的模板保持原样。
from app.db.systemconfig_oper import SystemConfigOper
from app.schemas.types import SystemConfigKey
legacy_organize = """
{
'title': '{{ title_year }}'
'{% if season_episode %} {{ season_episode }}{% endif %} 已入库',
'text': '{% if vote_average %}评分:{{ vote_average }}{% endif %}'
'类型:{{ type }}'
'{% if category %},类别:{{ category }}{% endif %}'
'{% if resource_term %},质量:{{ resource_term }}{% endif %}'
'{{ file_count }}个文件,大小:{{ total_size }}'
'{% if err_msg %},以下文件处理失败:{{ err_msg }}{% endif %}'
}"""
legacy_download = """
{
'title': '{{ title_year }}'
'{% if download_episodes %} {{ season_fmt }} {{ download_episodes }}{% else %}{{ season_episode }}{% endif %} 开始下载',
'text': '{% if site_name %}站点:{{ site_name }}{% endif %}'
'{% if resource_term %}\\n质量{{ resource_term }}{% endif %}'
'{% if size %}\\n大小{{ size }}{% endif %}'
'{% if torrent_title %}\\n种子{{ torrent_title }}{% endif %}'
'{% if pubdate %}\\n发布时间{{ pubdate }}{% endif %}'
'{% if freedate %}\\n免费时间{{ freedate }}{% endif %}'
'{% if seeders %}\\n做种数{{ seeders }}{% endif %}'
'{% if volume_factor %}\\n促销{{ volume_factor }}{% endif %}'
'{% if hit_and_run %}\\nHit&Run{{ hit_and_run }}{% endif %}'
'{% if labels %}\\n标签{{ labels }}{% endif %}'
'{% if description %}\\n描述{{ description }}{% endif %}'
}"""
music_organize = """
{
'title': '{{ title_year }}{% if track_number %} #{{ track_number }}{% endif %} 已入库',
'text': '类型:{{ type }}{% if category %},类别:{{ category }}{% endif %}'
'{% if type == "音乐" and artist %}\\n艺术家{{ artist }}{% endif %}'
'{% if type == "音乐" and album %}\\n专辑{{ album }}{% endif %}'
'{% if type == "音乐" and audio_specs %}\\n音质{{ audio_specs }}{% endif %}'
'{% if resource_term %},质量:{{ resource_term }}{% endif %}'
',共{{ file_count }}个文件,大小:{{ total_size }}'
'{% if err_msg %},以下文件处理失败:{{ err_msg }}{% endif %}'
}"""
music_download = """
{
'title': '{{ title_year }}{% if track_number %} #{{ track_number }}{% endif %}'
'{% if download_episodes %} {{ season_fmt }} {{ download_episodes }}{% else %}{{ season_episode }}{% endif %} 开始下载',
'text': '{% if site_name %}站点:{{ site_name }}{% endif %}'
'{% if type == "音乐" and artist %}\\n艺术家{{ artist }}{% endif %}'
'{% if type == "音乐" and album %}\\n专辑{{ album }}{% endif %}'
'{% if type == "音乐" and audio_specs %}\\n音质{{ audio_specs }}{% endif %}'
'{% if resource_term %}\\n质量{{ resource_term }}{% endif %}'
'{% if size %}\\n大小{{ size }}{% endif %}'
'{% if torrent_title %}\\n种子{{ torrent_title }}{% endif %}'
'{% if pubdate %}\\n发布时间{{ pubdate }}{% endif %}'
'{% if freedate %}\\n免费时间{{ freedate }}{% endif %}'
'{% if seeders %}\\n做种数{{ seeders }}{% endif %}'
'{% if volume_factor %}\\n促销{{ volume_factor }}{% endif %}'
'{% if hit_and_run %}\\nHit&Run{{ hit_and_run }}{% endif %}'
'{% if labels %}\\n标签{{ labels }}{% endif %}'
'{% if description %}\\n描述{{ description }}{% endif %}'
}"""
config_oper = SystemConfigOper()
templates = dict(config_oper.get(SystemConfigKey.NotificationTemplates) or {})
changed = False
for key, legacy, replacement in (
("organizeSuccess", legacy_organize, music_organize),
("downloadAdded", legacy_download, music_download),
):
if str(templates.get(key) or "").strip() == legacy.strip():
templates[key] = replacement
changed = True
if changed:
config_oper.set(SystemConfigKey.NotificationTemplates, templates)
def downgrade() -> None:
"""移除音乐音质相关字段。"""
table_columns = {
"subscribe": [
"current_sample_rate", "current_bit_depth", "current_bitrate", "current_audio_format",
"min_sample_rate", "min_bit_depth", "min_bitrate", "audio_format", "audio_quality",
],
"subscribehistory": [
"current_sample_rate", "current_bit_depth", "current_bitrate", "current_audio_format",
"current_priority", "min_sample_rate", "min_bit_depth", "min_bitrate", "audio_format",
"audio_quality",
],
"transferhistory": ["bitrate", "sample_rate", "bit_depth", "audio_lossless", "audio_format"],
}
for table_name, columns in table_columns.items():
for column_name in columns:
if _has_column(table_name, column_name):
op.drop_column(table_name, column_name)

View File

@@ -190,6 +190,8 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
音乐元数据使用 `MusicMeta` / `MusicInfo` 独立模型。`music_type=recording` 表示单曲,`album` 表示包含多首曲目的完整专辑,`artist` 仅用于浏览;稳定身份分别使用对应的 `musicbrainz:<mbid>`。单曲和专辑可进入搜索、订阅、下载、整理、刮削和已配置音乐媒体服务器的入库检查,艺术家不能作为订阅或下载目标。
音乐识别结果同时提供 `audio_format``audio_lossless``audio_quality``bit_depth``sample_rate``bitrate``audio_specs``audio_quality_score`。本地文件识别读取实际音频流参数,站点资源识别从标题和描述提取声明参数;码率、采样率的存储单位分别为 bps 和 Hz。
| 方法 | 路径 | 说明 |
| :--- | :--- | :--- |
| GET | `/api/v1/media/search` | 当 `type=music``source=musicbrainz` 时按歌曲、专辑或歌手关键词搜索音乐元数据,参数:`title``type``count` |
@@ -203,6 +205,8 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
专辑下载与订阅按“整包”处理:下载层会读取种子文件清单并以专辑 `total_tracks` 校验独立音频文件数量;未确认完整覆盖时不会把专辑订阅销订,也不会把部分曲目报告为完整专辑已入库。音乐刮削遵循 `music` 的标签、封面和歌词策略,歌词通过带有界 TTL/LRU 缓存的 LRCLIB 模块保存为同名 `.lrc``.txt` 旁挂文件。
音乐订阅可使用 `audio_quality=hires|lossless|lossy`(支持正则组合)、`audio_format``min_bitrate``min_bit_depth``min_sample_rate` 过滤资源。`best_version=1` 开启音质洗版,系统按格式、无损属性、位深、采样率和码率换算 0-100 优先级,只下载高于 `current_priority` 的候选DSD 或 24-bit/192 kHz 无损资源达到终态 100。内置规则 `HIRES``LOSSLESS``FLAC``ALAC``APE``WAV``DSD``MP3``AAC``OPUS``BITRATE320``BITRATE256``BITRATE192` 可用于自定义过滤规则组。
#### 下载
| 方法 | 路径 | 说明 |
@@ -288,7 +292,7 @@ TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized`
媒体相关 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")` 会按策略写音频标签、封面和歌词,并返回歌词新增、已存在、未匹配和失败数量。
Agent 音乐流程与影视共用同一采集管线,但实体边界不同:单曲通过 `music_type=recording` 按一个文件处理;专辑通过 `music_type=album` 类似电视剧整季包,按一个目录/资源处理并校验总曲目数;艺术家不是采集目标。`add_subscribe` / `update_subscribe` 支持音乐音质筛选字段和 `best_version` 音质洗版;`query_subscribes` 会返回筛选条件及当前音质快照。`scrape_metadata(media_type="music")` 会按策略写音频标签、封面和歌词,并返回歌词新增、已存在、未匹配和失败数量。
`get_search_results` 可使用 `title_pattern` 对种子标题执行正则筛选,也可使用 `content_pattern` 联合匹配种子标题、简介和标签。`title_pattern` 保持仅匹配标题的兼容语义;需要在结果中查看种子简介时,传入 `include_description=true`。两种正则参数与站点、分辨率等结构化筛选条件同时传入时按 AND 关系组合。

View File

@@ -106,12 +106,18 @@ Key columns: `id`, `path`, `type`, `title`, `year`, `tmdbid`, `imdbid`, `doubani
Key columns: `id`, `downloader`, `download_hash`, `fullpath`, `savepath`, `filepath`, `torrentname`, `state`
### transferhistory
Music rows persist actual `audio_format`, `audio_lossless`, `bit_depth`, `sample_rate`, and `bitrate` values read during organization. Bitrate uses bps and sample rate uses Hz.
Key columns: `id`, `src`, `dest`, `mode`, `type`, `category`, `title`, `year`, `tmdbid`, `seasons`, `episodes`, `download_hash`, `status`, `errmsg`, `date`
### subscribe
Music filters use `audio_quality`, `audio_format`, `min_bitrate`, `min_bit_depth`, and `min_sample_rate`. Quality upgrades reuse `current_priority` and persist the current exact values in `current_audio_format`, `current_bitrate`, `current_bit_depth`, and `current_sample_rate`.
Key columns: `id`, `name`, `year`, `type`, `tmdbid`, `doubanid`, `season`, `total_episode`, `start_episode`, `lack_episode`, `state`, `filter`, `include`, `exclude`, `quality`, `resolution`, `sites`, `best_version`, `best_version_full`, `date`, `username`
### subscribehistory
Completed music subscriptions retain both audio filters and the final current-quality snapshot for auditing.
Key columns: `id`, `name`, `year`, `type`, `tmdbid`, `doubanid`, `season`, `total_episode`, `start_episode`, `date`, `username`
### user

View File

@@ -157,6 +157,11 @@ Subscribe to a specific season:
Subscribe starting from a specific episode:
`moviepilot tool run add_subscribe title="..." year="2024" media_type="tv" tmdb_id=12345 season=1 start_episode=13`
Subscribe to a complete lossless album and keep upgrading its audio quality:
`moviepilot tool run add_subscribe title="..." media_type="music" music_type="album" media_source="musicbrainz" media_id="<release-group-id>" audio_quality="hires|lossless" audio_format="DSD|FLAC|ALAC" min_bit_depth=24 best_version=1`
Audio bitrate and sample-rate values use bps and Hz. For example, pass `min_bitrate=320000` and `min_sample_rate=96000`.
### Manage Downloads
List download tasks and get hash for further operations:

View File

@@ -3,6 +3,12 @@ from types import SimpleNamespace
from unittest.mock import Mock
from app.core.context import MusicInfo
from app.core.meta.metamusic import (
audio_quality_score,
audio_quality_tier,
format_audio_quality,
parse_audio_quality,
)
from app.helper.audio import AudioMetadataHelper
@@ -38,6 +44,62 @@ def test_read_audio_metadata_maps_easy_tags(monkeypatch):
assert meta.total_tracks == 13
assert meta.duration == 369
assert meta.audio_format == "FLAC"
assert meta.audio_lossless is True
assert meta.audio_quality == "lossless"
assert meta.audio_specs == "FLAC · 16-bit · 44.1 kHz · 1,411 kbps"
def test_parse_declared_hires_audio_quality_from_resource_title():
"""站点资源标题中的格式、位深和采样率应形成可筛选的统一音质参数。"""
specs = parse_audio_quality("周杰伦 - 叶惠美 FLAC 24bit 96kHz Hi-Res")
assert specs == {
"audio_format": "FLAC",
"audio_lossless": True,
"bit_depth": 24,
"sample_rate": 96000,
"bitrate": None,
}
assert audio_quality_tier(**specs) == "hires"
assert audio_quality_score(**specs) == 96
assert format_audio_quality(**specs) == "FLAC · 24-bit · 96 kHz"
def test_audio_quality_score_orders_lossy_lossless_and_terminal_hires():
"""音乐洗版分数必须稳定满足有损、无损、顶级 Hi-Res 的递增关系。"""
mp3_score = audio_quality_score("MP3", bitrate=320000)
flac_score = audio_quality_score("FLAC", bit_depth=16, sample_rate=44100)
hires_score = audio_quality_score("FLAC", bit_depth=24, sample_rate=192000)
assert 0 < mp3_score < flac_score < hires_score
assert hires_score == 100
def test_music_info_serialization_exposes_derived_audio_quality():
"""音乐 REST 序列化应同时返回原始技术参数和规范化音质展示字段。"""
payload = MusicInfo(
title="晴天",
audio_format="FLAC",
bit_depth=24,
sample_rate=96_000,
bitrate=2_304_000,
).to_dict()
assert payload["audio_quality"] == "hires"
assert payload["audio_quality_score"] == 96
assert payload["audio_specs"] == "FLAC · 24-bit · 96 kHz · 2,304 kbps"
def test_parse_compact_audio_quality_tokens_without_false_sample_bitrate():
"""紧凑资源命名中的 FLAC24bit 和 320K 应可识别96kHz 不得误判为码率。"""
lossless = parse_audio_quality("Album.FLAC24bit.96kHz")
lossy = parse_audio_quality("Album.MP3.320K")
assert lossless["audio_format"] == "FLAC"
assert lossless["bit_depth"] == 24
assert lossless["sample_rate"] == 96000
assert lossless["bitrate"] is None
assert lossy["bitrate"] == 320000
def test_read_audio_metadata_falls_back_to_filename(monkeypatch):

View File

@@ -45,6 +45,11 @@ def _subscribe(**overrides) -> SimpleNamespace:
quality=None,
resolution=None,
effect=None,
audio_quality=None,
audio_format=None,
min_bitrate=None,
min_bit_depth=None,
min_sample_rate=None,
include=None,
exclude=None,
username="admin",
@@ -53,6 +58,12 @@ def _subscribe(**overrides) -> SimpleNamespace:
custom_words=None,
media_category=None,
best_version=0,
best_version_full=0,
current_priority=None,
current_audio_format=None,
current_bitrate=None,
current_bit_depth=None,
current_sample_rate=None,
state="R",
note=None,
description=None,
@@ -111,6 +122,113 @@ def test_music_subscribe_reuses_search_download_and_finish_flow():
assert matched_context.media_info is target
assert isinstance(matched_context.meta_info, MetaMusic)
assert matched_context.meta_info.org_string == "周杰伦 - 晴天 FLAC"
assert matched_context.meta_info.audio_format == "FLAC"
assert matched_context.meta_info.audio_lossless is True
chain.finish_subscribe_or_not.assert_called_once()
def test_music_subscribe_filters_declared_bitrate_and_format():
"""音乐订阅应按规范化格式和最低码率过滤站点资源。"""
subscribe = _subscribe(audio_format="MP3", min_bitrate=320000)
contexts = [
Context(torrent_info=TorrentInfo(
title="周杰伦 - 晴天 MP3 192kbps", category=MediaType.MUSIC.value,
)),
Context(torrent_info=TorrentInfo(
title="周杰伦 - 晴天 MP3 320kbps", category=MediaType.MUSIC.value,
)),
]
chain = SubscribeChain()
chain.filter_torrents = Mock(side_effect=lambda **kwargs: kwargs["torrent_list"])
matched = chain._filter_music_subscribe_contexts(subscribe, _music_info(), contexts)
assert len(matched) == 1
assert matched[0].meta_info.bitrate == 320000
def test_music_best_version_only_accepts_higher_audio_score():
"""音乐洗版只能接收高于当前版本的候选,并把音质分数写入下载优先级。"""
subscribe = _subscribe(best_version=1, current_priority=90)
contexts = [
Context(torrent_info=TorrentInfo(
title="周杰伦 - 晴天 FLAC 16bit 44.1kHz", category=MediaType.MUSIC.value,
)),
Context(torrent_info=TorrentInfo(
title="周杰伦 - 晴天 FLAC 24bit 96kHz", category=MediaType.MUSIC.value,
)),
]
chain = SubscribeChain()
chain.filter_torrents = Mock(side_effect=lambda **kwargs: kwargs["torrent_list"])
matched = chain._filter_music_subscribe_contexts(subscribe, _music_info(), contexts)
assert len(matched) == 1
assert matched[0].meta_info.audio_quality_score == 96
assert matched[0].torrent_info.pri_order == 96
def test_music_best_version_preserves_configured_format_priority():
"""音乐洗版应优先采用用户规则组给出的格式顺序,而非覆盖为自动音质分数。"""
subscribe = _subscribe(best_version=1, current_priority=90)
context = Context(torrent_info=TorrentInfo(
title="周杰伦 - 晴天 MP3 320kbps",
category=MediaType.MUSIC.value,
))
chain = SubscribeChain()
def apply_rule_priority(**kwargs):
"""模拟音乐格式规则组把当前候选排到最高优先级。"""
kwargs["torrent_list"][0].pri_order = 100
return kwargs["torrent_list"]
chain.filter_torrents = Mock(side_effect=apply_rule_priority)
matched = chain._filter_music_subscribe_contexts(
subscribe,
_music_info(),
[context],
)
assert matched[0].meta_info.audio_quality_score == 80
assert matched[0].torrent_info.pri_order == 100
def test_music_best_version_persists_downloaded_rule_priority():
"""音乐洗版成功后应按实际采用的规则优先级和音频参数更新当前版本。"""
subscribe = _subscribe(best_version=1, current_priority=90)
meta = MetaMusic(title="晴天")
meta.apply_audio_quality("MP3 320kbps")
downloaded = Context(
torrent_info=TorrentInfo(
title="周杰伦 - 晴天 MP3 320kbps",
category=MediaType.MUSIC.value,
pri_order=100,
),
meta_info=meta,
)
download_chain = Mock()
download_chain.batch_download.return_value = ([downloaded], None)
subscribe_oper = Mock()
subscribe_oper.get.return_value = subscribe
chain = SubscribeChain()
chain.finish_subscribe_or_not = Mock()
with patch("app.chain.subscribe.DownloadChain", return_value=download_chain), \
patch("app.chain.subscribe.SubscribeOper", return_value=subscribe_oper):
chain._download_music_subscribe(subscribe, _music_info(), [downloaded])
subscribe_oper.update.assert_called_once_with(
subscribe.id,
{
"current_priority": 100,
"current_audio_format": "MP3",
"current_bitrate": 320_000,
"current_bit_depth": None,
"current_sample_rate": None,
},
)
assert subscribe.current_priority == 100
chain.finish_subscribe_or_not.assert_called_once()

View File

@@ -12,6 +12,7 @@ import threading
from app.core.context import MediaInfo
from app.core.metainfo import MetaInfo
from app.core.meta import MetaMusic
from app.helper.message import TemplateContextBuilder
from app.schemas.types import MediaType
from app.schemas.tmdb import TmdbEpisode
@@ -138,3 +139,24 @@ def test_build_preserves_special_season_context() -> None:
assert context["season"] == "0"
assert context["season_fmt"] == "S00"
assert context["season_year"] == "2024"
def test_build_exposes_music_audio_specs_for_notifications() -> None:
"""下载和整理通知上下文应包含格式化音质及可独立引用的技术参数。"""
meta = MetaMusic(
title="晴天",
artists=["周杰伦"],
album="叶惠美",
track_number=3,
audio_format="FLAC",
bit_depth=24,
sample_rate=96000,
bitrate=2304000,
)
context = TemplateContextBuilder().build(meta=meta)
assert context["audio_quality"] == "hires"
assert context["audio_specs"] == "FLAC · 24-bit · 96 kHz · 2,304 kbps"
assert context["bitrate_kbps"] == 2304
assert context["sample_rate_khz"] == "96"

View File

@@ -109,6 +109,31 @@ def test_builtin_cnsub_rule_ignores_trailing_file_size_unit():
assert explicit_gb_subtitle.pri_order == 100
def test_builtin_music_rules_assign_format_and_bitrate_priority():
"""内置音乐规则应允许格式和码率共同参与订阅洗版优先级。"""
module = _build_filter_module(
rule_string="FLAC > BITRATE320",
rule_set=BUILTIN_RULE_SET,
)
lossless = TorrentInfo(
title="Artist Album FLAC 24bit 96kHz",
description="",
)
lossy = TorrentInfo(
title="Artist Album MP3 320kbps",
description="",
)
filtered = module.filter_torrents(
rule_groups=["test"],
torrent_list=[lossless, lossy],
)
assert filtered == [lossless, lossy]
assert lossless.pri_order == 100
assert lossy.pri_order == 99
def test_filter_torrents_keeps_lazy_priority_level_parsing():
"""
命中高优先级规则后不应解析低优先级坏规则。

View File

@@ -112,6 +112,72 @@ def test_transferhistory_music_migration_is_idempotent(monkeypatch) -> None:
assert {"music_type", "total_tracks"}.issubset(columns)
def test_music_audio_quality_migration_is_idempotent(monkeypatch) -> None:
"""音乐音质字段迁移应可重复执行且不覆盖自定义通知模板。"""
migration = importlib.import_module(
"database.versions.e8b1c4d7a2f9_2_2_18"
)
engine = sa.create_engine("sqlite://")
metadata = sa.MetaData()
for table_name in ("subscribe", "subscribehistory", "transferhistory"):
sa.Table(
table_name,
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
)
config_oper = Mock()
config_oper.get.return_value = {
"organizeSuccess": "custom organize template",
"downloadAdded": "custom download template",
}
monkeypatch.setattr(
"app.db.systemconfig_oper.SystemConfigOper",
lambda: config_oper,
)
with engine.begin() as connection:
metadata.create_all(connection)
context = MigrationContext.configure(connection)
monkeypatch.setattr(migration, "op", Operations(context))
migration.upgrade()
migration.upgrade()
inspector = sa.inspect(connection)
subscribe_columns = {
column["name"] for column in inspector.get_columns("subscribe")
}
history_columns = {
column["name"]
for column in inspector.get_columns("subscribehistory")
}
transfer_columns = {
column["name"]
for column in inspector.get_columns("transferhistory")
}
assert {
"audio_quality",
"audio_format",
"min_bitrate",
"min_bit_depth",
"min_sample_rate",
"current_audio_format",
"current_bitrate",
"current_bit_depth",
"current_sample_rate",
}.issubset(subscribe_columns)
assert {"current_priority", "current_audio_format"}.issubset(history_columns)
assert {
"audio_format",
"audio_lossless",
"bit_depth",
"sample_rate",
"bitrate",
}.issubset(transfer_columns)
config_oper.set.assert_not_called()
def test_transfer_history_preserves_album_entity_context() -> None:
"""整理成功记录应保存整专实体和预期曲目数供 Agent 重试。"""
oper = object.__new__(TransferHistoryOper)
@@ -126,6 +192,7 @@ def test_transfer_history_preserves_album_entity_context() -> None:
total_tracks=11,
)
meta = MetaMusic(title="叶惠美", artists=["周杰伦"], total_tracks=11)
meta.apply_audio_quality("FLAC Lossless 24bit 96kHz 2304kbps")
oper.add_success(
fileitem=FileItem(
@@ -148,3 +215,8 @@ def test_transfer_history_preserves_album_entity_context() -> None:
call = oper.add_force.call_args
assert call.kwargs["music_type"] == "album"
assert call.kwargs["total_tracks"] == 11
assert call.kwargs["audio_format"] == "FLAC"
assert call.kwargs["audio_lossless"] is True
assert call.kwargs["bit_depth"] == 24
assert call.kwargs["sample_rate"] == 96_000
assert call.kwargs["bitrate"] == 2_304_000