mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-15 19:14:01 +08:00
feat(music): add audio quality workflow
This commit is contained in:
@@ -119,6 +119,10 @@ SYSTEMCONFIG_SETTING_METADATA = {
|
||||
"group": "subscribe_defaults",
|
||||
"label": "默认电视剧订阅规则",
|
||||
},
|
||||
SystemConfigKey.DefaultMusicSubscribeConfig.value: {
|
||||
"group": "subscribe_defaults",
|
||||
"label": "默认音乐订阅规则",
|
||||
},
|
||||
SystemConfigKey.UserInstalledPlugins.value: {
|
||||
"group": "plugins",
|
||||
"label": "已安装插件列表",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -70,6 +70,7 @@ _PUBLIC_SYSTEM_CONFIG_KEYS = {
|
||||
SystemConfigKey.EpisodeFormatRuleTable,
|
||||
SystemConfigKey.DefaultMovieSubscribeConfig,
|
||||
SystemConfigKey.DefaultTvSubscribeConfig,
|
||||
SystemConfigKey.DefaultMusicSubscribeConfig,
|
||||
SystemConfigKey.FollowSubscribers,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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, ...]:
|
||||
"""构造跨来源稳定的候选去重键。"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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)
|
||||
# 保存路径
|
||||
|
||||
@@ -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)
|
||||
# 保存路径
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": []},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
# 时间
|
||||
|
||||
@@ -273,6 +273,8 @@ class SystemConfigKey(Enum):
|
||||
DefaultMovieSubscribeConfig = "DefaultMovieSubscribeConfig"
|
||||
# 默认电视剧订阅规则
|
||||
DefaultTvSubscribeConfig = "DefaultTvSubscribeConfig"
|
||||
# 默认音乐订阅规则
|
||||
DefaultMusicSubscribeConfig = "DefaultMusicSubscribeConfig"
|
||||
# 用户站点认证参数
|
||||
UserSiteAuthParams = "UserSiteAuthParams"
|
||||
# Follow订阅分享者
|
||||
|
||||
157
database/versions/e8b1c4d7a2f9_2_2_18.py
Normal file
157
database/versions/e8b1c4d7a2f9_2_2_18.py
Normal 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)
|
||||
@@ -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 关系组合。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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():
|
||||
"""
|
||||
命中高优先级规则后不应解析低优先级坏规则。
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user