mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 05:17:23 +08:00
Merge pull request #6900 from cwd0204/feat/download-save-path-alias
This commit is contained in:
@@ -18,7 +18,10 @@ OPERATION_DESCRIPTIONS = {
|
||||
"download.clients": "List enabled downloader instance names and provider types without credentials.",
|
||||
"download.history.list": "Page MoviePilot download-history records in reverse chronological order.",
|
||||
"download.history.delete": "Delete one MoviePilot download-history record.",
|
||||
"download.paths": "List configured downloader save-path URIs that may be passed to download.add.",
|
||||
"download.paths": (
|
||||
"List configured downloader save-path URIs that may be passed to download.add. "
|
||||
"Each item's non-empty name is also accepted as save_path."
|
||||
),
|
||||
"download.tasks.active": "List currently downloading MoviePilot tasks with their canonical media context.",
|
||||
"filter.builtin": "List built-in torrent filter rules.",
|
||||
"filter.custom": "List user-defined torrent filter rules.",
|
||||
@@ -434,7 +437,10 @@ FIELD_DESCRIPTIONS = {
|
||||
"rule_id": "Stable custom filter-rule ID.",
|
||||
"rule_string": "Ordered filter-rule expression stored in the group.",
|
||||
"sample_rate": "Recorded audio sample rate in hertz.",
|
||||
"save_path": "Configured downloader-side save path for the download or subscription.",
|
||||
"save_path": (
|
||||
"Configured downloader-side save path, or the exact name (alias) of a configured download "
|
||||
"directory, for the download or subscription."
|
||||
),
|
||||
"scrape": "Generate metadata and images after manual transfer.",
|
||||
"search_interval": "Scheduled search interval in whole hours (1-8760); null uses the system interval.",
|
||||
"last_search": "Read-only UTC timestamp of the most recent subscription search attempt.",
|
||||
|
||||
@@ -435,7 +435,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Configured downloader-side save path for the download or subscription.",
|
||||
"description": "Configured downloader-side save path, or the exact name (alias) of a configured download directory, for the download or subscription.",
|
||||
"title": "Save Path"
|
||||
},
|
||||
"torrent_in": {
|
||||
@@ -489,7 +489,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Configured downloader-side save path for the download or subscription.",
|
||||
"description": "Configured downloader-side save path, or the exact name (alias) of a configured download directory, for the download or subscription.",
|
||||
"title": "Save Path"
|
||||
},
|
||||
"torrent_in": {
|
||||
@@ -4295,7 +4295,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Configured downloader-side save path for the download or subscription.",
|
||||
"description": "Configured downloader-side save path, or the exact name (alias) of a configured download directory, for the download or subscription.",
|
||||
"title": "Save Path"
|
||||
},
|
||||
"search_imdbid": {
|
||||
@@ -7506,7 +7506,7 @@
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "List configured downloader save-path URIs that may be passed to download.add. Method: GET. Path: /api/v1/download/paths. Effect: safe_read. Collection response: data remains a list; omit both page and count to preserve the legacy complete result. Successful gateway output adds collection.result_count and the exact collection.total_count. For a count or summary, send page=1 and count=1, then read collection.total_count; do not query the database merely because item data is truncated.",
|
||||
"description": "List configured downloader save-path URIs that may be passed to download.add. Each item's non-empty name is also accepted as save_path. Method: GET. Path: /api/v1/download/paths. Effect: safe_read. Collection response: data remains a list; omit both page and count to preserve the legacy complete result. Successful gateway output adds collection.result_count and the exact collection.total_count. For a count or summary, send page=1 and count=1, then read collection.total_count; do not query the database merely because item data is truncated.",
|
||||
"properties": {
|
||||
"operation_id": {
|
||||
"const": "download.paths",
|
||||
@@ -7515,7 +7515,7 @@
|
||||
},
|
||||
"query": {
|
||||
"additionalProperties": false,
|
||||
"description": "Filters and control values for download.paths. List configured downloader save-path URIs that may be passed to download.add. Use only the named fields below.",
|
||||
"description": "Filters and control values for download.paths. List configured downloader save-path URIs that may be passed to download.add. Each item's non-empty name is also accepted as save_path. Use only the named fields below.",
|
||||
"properties": {
|
||||
"count": {
|
||||
"anyOf": [
|
||||
|
||||
@@ -325,7 +325,7 @@ def add(
|
||||
music_type: Annotated[MusicTargetEntityType | None, Body()] = None,
|
||||
allow_unrecognized: Annotated[bool, Body()] = False,
|
||||
downloader: Annotated[str | None, Body()] = None,
|
||||
# 保存路径, 支持<storage>:<path>, 如rclone:/MP, smb:/server/share/Movies等
|
||||
# 保存路径, 支持<storage>:<path>(如rclone:/MP, smb:/server/share/Movies)或已配置下载目录的名称(别名)
|
||||
save_path: Annotated[str | None, Body()] = None,
|
||||
current_user: ApiPrincipal = Depends(get_current_active_user),
|
||||
) -> Any:
|
||||
@@ -500,7 +500,7 @@ async def clients(_: _SchemaTokenPayload = Depends(verify_token), page: Compatib
|
||||
@router.get("/paths", summary="查询可用下载路径", response_model=List[_SchemaDownloadDirectory])
|
||||
def paths(_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any:
|
||||
"""
|
||||
查询可直接用于下载接口 save_path 参数的下载路径
|
||||
查询可直接用于下载接口 save_path 参数的下载路径;返回项的非空 name 也可直接作为 save_path 提交
|
||||
"""
|
||||
return [
|
||||
_SchemaDownloadDirectory(
|
||||
|
||||
@@ -766,20 +766,54 @@ def _normalize_download_root(dir_info: _SchemaTransferDirectoryConf) -> Optional
|
||||
return None
|
||||
|
||||
|
||||
def validate_download_save_path(save_path: str) -> str:
|
||||
def _has_storage_prefix(value: str) -> bool:
|
||||
"""
|
||||
校验用户传入的下载保存目录,/download/paths 暴露的下载目录配置是允许写入的公共合同。
|
||||
判断输入是否带 <storage>: 前缀。
|
||||
"""
|
||||
return any(value.startswith(f"{item.value}:") for item in StorageSchema)
|
||||
|
||||
:param save_path: 下载保存目录,支持本地 /path、远端 <storage>:/path 和旧版订阅中的无前缀远程路径
|
||||
:return: 可直接传给下载接口的规范化保存目录
|
||||
|
||||
def _looks_like_download_path(value: str) -> bool:
|
||||
"""
|
||||
value = str(save_path or "").strip()
|
||||
has_storage_prefix = any(value.startswith(f"{item.value}:") for item in StorageSchema)
|
||||
判断输入是否带路径特征;只有不带特征的字符串才按「目录别名不存在」报错。
|
||||
"""
|
||||
if "/" in value or WINDOWS_DRIVE_PREFIX_PATTERN.match(value):
|
||||
return True
|
||||
return _has_storage_prefix(value)
|
||||
|
||||
|
||||
def _resolve_download_dir_alias(
|
||||
value: str,
|
||||
download_dirs: List[_SchemaTransferDirectoryConf],
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
按目录名称(别名)精确匹配下载目录,返回该目录根的 save_path。
|
||||
|
||||
调用方保证 value 已去空白且非空;重名目录沿用 get_download_dirs 的 priority 顺序取首个。
|
||||
"""
|
||||
for dir_info in download_dirs:
|
||||
if (dir_info.name or "").strip() != value:
|
||||
continue
|
||||
root = _normalize_download_root(dir_info)
|
||||
if root:
|
||||
storage, _, root_path = root
|
||||
return _download_path_uri(storage, root_path)
|
||||
return None
|
||||
|
||||
|
||||
def _validate_download_save_path_value(
|
||||
value: str,
|
||||
download_dirs: List[_SchemaTransferDirectoryConf],
|
||||
) -> str:
|
||||
"""
|
||||
按路径规则校验已去空白的保存目录,不涉及别名。
|
||||
"""
|
||||
has_storage_prefix = _has_storage_prefix(value)
|
||||
storage, raw_path = _split_file_uri(value)
|
||||
target_style, target_path = _normalize_download_path(raw_path, storage)
|
||||
|
||||
download_roots = []
|
||||
for dir_info in DirectoryHelper().get_download_dirs():
|
||||
for dir_info in download_dirs:
|
||||
root = _normalize_download_root(dir_info)
|
||||
if root:
|
||||
download_roots.append(root)
|
||||
@@ -803,3 +837,29 @@ def validate_download_save_path(save_path: str) -> str:
|
||||
return _download_path_uri(root_storage, target_path)
|
||||
|
||||
raise ValueError("保存路径不在允许的下载目录范围内")
|
||||
|
||||
|
||||
def validate_download_save_path(save_path: str) -> str:
|
||||
"""
|
||||
校验用户传入的下载保存目录,/download/paths 暴露的下载目录配置是允许写入的公共合同。
|
||||
|
||||
路径优先:先按路径规则校验;失败后再用目录名称(别名)精确匹配,命中返回该目录根路径,
|
||||
结果与直接传入该目录根路径的校验结果一致。空值不参与别名匹配。
|
||||
|
||||
:param save_path: 下载保存目录,支持本地 /path、远端 <storage>:/path、旧版订阅中的无前缀远程路径,
|
||||
以及已配置下载目录的名称(别名)
|
||||
:return: 可直接传给下载接口的规范化保存目录
|
||||
"""
|
||||
value = str(save_path or "").strip()
|
||||
download_dirs = DirectoryHelper().get_download_dirs()
|
||||
try:
|
||||
return _validate_download_save_path_value(value, download_dirs)
|
||||
except ValueError as path_error:
|
||||
if not value:
|
||||
raise
|
||||
alias_uri = _resolve_download_dir_alias(value, download_dirs)
|
||||
if alias_uri:
|
||||
return alias_uri
|
||||
if _looks_like_download_path(value):
|
||||
raise
|
||||
raise ValueError(f"未找到名为「{value}」的下载目录") from path_error
|
||||
|
||||
@@ -23,7 +23,7 @@ class DownloadDirectory(BaseModel):
|
||||
下载目录
|
||||
"""
|
||||
|
||||
name: Optional[str] = Field(default=None, description="目录名称")
|
||||
name: Optional[str] = Field(default=None, description="目录名称(别名),可直接作为 save_path 提交")
|
||||
storage: Optional[str] = Field(default="local", description="存储类型")
|
||||
download_path: Optional[str] = Field(default=None, description="配置的下载目录")
|
||||
save_path: Optional[str] = Field(default=None, description="可直接传给下载接口 save_path 的路径")
|
||||
|
||||
@@ -716,15 +716,15 @@ AMLL 使用无需鉴权的原生搜索与获取接口,先尝试 ISRC,再核
|
||||
| 方法 | 路径 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| GET | `/api/v1/download/` | 查询正在下载的任务,参数:`name`;关联下载历史时返回媒体类型、来源站点 `site_name`,以及 `media.poster` 海报和 `media.backdrop` 背景图;兼容字段 `media.image` 与 `media.poster` 相同 |
|
||||
| POST | `/api/v1/download/` | 添加含媒体信息的下载任务,请求体包含媒体信息和种子信息 |
|
||||
| POST | `/api/v1/download/add` | 添加不含媒体信息的下载任务,请求体包含 `torrent_in`,可选且必须成对提供 `media_source` + `media_id`,并支持 `music_type`、`downloader`、`save_path`;影视或音乐识别失败时统一响应 `data.requires_confirmation=true`,用户确认后可用 `allow_unrecognized=true` 重试本次下载 |
|
||||
| POST | `/api/v1/download/subtitle` | 下载字幕到识别出的媒体下载目录,请求体包含 `subtitle_in`,并必须提供 `media_source` + `media_id`;可选 `save_path` |
|
||||
| POST | `/api/v1/download/` | 添加含媒体信息的下载任务,请求体包含媒体信息和种子信息;可选 `downloader`、`save_path`,`save_path` 可为路径,或与已配置下载目录名称完全一致的别名 |
|
||||
| POST | `/api/v1/download/add` | 添加不含媒体信息的下载任务,请求体包含 `torrent_in`,可选且必须成对提供 `media_source` + `media_id`,并支持 `music_type`、`downloader`、`save_path`(路径,或与已配置下载目录名称完全一致的别名);影视或音乐识别失败时统一响应 `data.requires_confirmation=true`,用户确认后可用 `allow_unrecognized=true` 重试本次下载 |
|
||||
| POST | `/api/v1/download/subtitle` | 下载字幕到识别出的媒体下载目录,请求体包含 `subtitle_in`,并必须提供 `media_source` + `media_id`;可选 `save_path`(路径,或与已配置下载目录名称完全一致的别名) |
|
||||
| GET | `/api/v1/download/start/{hashString}` | 恢复下载任务,参数:`name` |
|
||||
| GET | `/api/v1/download/stop/{hashString}` | 暂停下载任务,参数:`name` |
|
||||
| PATCH | `/api/v1/download/{hashString}` | 高级更新下载任务,可修改限速、标签、Tracker、保存目录和下载器分类 |
|
||||
| POST | `/api/v1/download/{hashString}/classify-source` | `recognize` 模式重新识别媒体并按当前生效分类计算保存位置,`manual` 模式使用明确目标目录;`execute=false` 只预览,`execute=true` 由下载器移动任务数据;可传当前策略中已启用的 `media_category` 路径覆盖自动分类 |
|
||||
| GET | `/api/v1/download/clients` | 查询可用下载器 |
|
||||
| GET | `/api/v1/download/paths` | 查询可用于下载接口 `save_path` 参数的下载路径 |
|
||||
| GET | `/api/v1/download/paths` | 查询可用于下载接口 `save_path` 参数的下载路径,返回项的非空 `name` 也可直接作为 `save_path` 提交 |
|
||||
| DELETE | `/api/v1/download/{hashString}` | 删除下载任务,参数:`name` |
|
||||
|
||||
资源目录重新分类只接受仍存在于下载器且具有可恢复媒体类型的下载历史任务;识别模式可复用历史中的媒体来源和同来源媒体 ID,也可在请求中指定来源、媒体 ID 或当前策略中已启用且媒体类型匹配的 `media_category`。
|
||||
@@ -809,6 +809,8 @@ TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized`
|
||||
非空字符串不自动裁剪,数值 `0` 保留。此规则不扩展到名称、类型、季号、总集数等字段,
|
||||
媒体身份仍要求 `media_source` 与 `media_id` 成对提交。
|
||||
|
||||
订阅的 `save_path` 同样接受与已配置下载目录名称完全一致的别名;该值按原样保存,到触发下载时才解析为目录根路径,目录被重命名后会以「未找到名为…的下载目录」失败。
|
||||
|
||||
### 单条订阅搜索周期
|
||||
|
||||
`POST /api/v1/subscribe/` 和 `PUT /api/v1/subscribe/` 支持 `search_interval`:
|
||||
|
||||
@@ -9,14 +9,14 @@ Download submission, configured clients, paths, active tasks, and download histo
|
||||
Purpose: Submit one torrent to MoviePilot's normal download workflow.
|
||||
- `path_params`: none
|
||||
- `query`: none
|
||||
- `body`: `allow_unrecognized` (boolean; default `False`): Allow a download when MoviePilot cannot resolve a canonical media identity.; `downloader` (string|null): Configured downloader instance name.; `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `music_type` (string(recording,album)|null): Music identity level: recording, album, or artist where supported.; `save_path` (string|null): Configured downloader-side save path for the download or subscription.; `torrent_in*` (TorrentInfo): Complete torrent candidate returned by search.results or search.torrents.
|
||||
- `body`: `allow_unrecognized` (boolean; default `False`): Allow a download when MoviePilot cannot resolve a canonical media identity.; `downloader` (string|null): Configured downloader instance name.; `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `music_type` (string(recording,album)|null): Music identity level: recording, album, or artist where supported.; `save_path` (string|null): Configured downloader-side save path, or the exact name (alias) of a configured download directory, for the download or subscription.; `torrent_in*` (TorrentInfo): Complete torrent candidate returned by search.results or search.torrents.
|
||||
|
||||
### `download.artist_collection`
|
||||
`POST /api/v1/download/artist-collection`; policy effect: `external_side_effect`.
|
||||
Purpose: Submit one artist-wide music torrent to MoviePilot's collection download workflow.
|
||||
- `path_params`: none
|
||||
- `query`: none
|
||||
- `body`: `artist_id*` (string; minimum length `1`): Source-native artist ID returned by music search or an album detail response.; `artist_name*` (string; minimum length `1`): Artist name preserved as the identity label for an artist-wide music collection.; `downloader` (string|null): Configured downloader instance name.; `media_source*` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.; `save_path` (string|null): Configured downloader-side save path for the download or subscription.; `torrent_in*` (TorrentInfo): Complete torrent candidate returned by search.results or search.torrents.
|
||||
- `body`: `artist_id*` (string; minimum length `1`): Source-native artist ID returned by music search or an album detail response.; `artist_name*` (string; minimum length `1`): Artist name preserved as the identity label for an artist-wide music collection.; `downloader` (string|null): Configured downloader instance name.; `media_source*` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.; `save_path` (string|null): Configured downloader-side save path, or the exact name (alias) of a configured download directory, for the download or subscription.; `torrent_in*` (TorrentInfo): Complete torrent candidate returned by search.results or search.torrents.
|
||||
|
||||
### `download.clients`
|
||||
`GET /api/v1/download/clients`; policy effect: `safe_read`.
|
||||
@@ -43,7 +43,7 @@ Purpose: Page MoviePilot download-history records in reverse chronological order
|
||||
|
||||
### `download.paths`
|
||||
`GET /api/v1/download/paths`; policy effect: `safe_read`.
|
||||
Purpose: List configured downloader save-path URIs that may be passed to download.add.
|
||||
Purpose: List configured downloader save-path URIs that may be passed to download.add. Each item's non-empty name is also accepted as save_path.
|
||||
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total. For counts or summaries, send `page=1,count=1`, read `collection.total_count`, and do not fall back to a database query because the item preview was truncated.
|
||||
- `path_params`: none
|
||||
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
|
||||
|
||||
File diff suppressed because one or more lines are too long
141
tests/test_download_save_path_alias.py
Normal file
141
tests/test_download_save_path_alias.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""下载保存目录支持目录别名:路径优先、别名兜底、错误文案与链路集成。"""
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.directory import DirectoryHelper, validate_download_save_path
|
||||
from app.chain.download.subtitle import DownloadSubtitleOwner
|
||||
from app.domain.context import MediaInfo
|
||||
from app.schemas.system import TransferDirectoryConf
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
def _download_dirs() -> list[TransferDirectoryConf]:
|
||||
"""按 priority 排好序的下载目录配置,覆盖本地、远端、重名、路径型别名和空名称("" 与 None)。"""
|
||||
return [
|
||||
TransferDirectoryConf(name="Movies", priority=0, storage="local", download_path="/downloads/en"),
|
||||
TransferDirectoryConf(name="可心影视库", priority=1, storage="local", download_path="/downloads/movies"),
|
||||
TransferDirectoryConf(name="动漫远程库", priority=2, storage="rclone", download_path="/media/anime"),
|
||||
TransferDirectoryConf(name="/downloads/movies", priority=3, storage="local", download_path="/downloads/other"),
|
||||
TransferDirectoryConf(name="/legacy/movies", priority=4, storage="local", download_path="/downloads/legacy"),
|
||||
TransferDirectoryConf(name="重名目录", priority=5, storage="local", download_path="/downloads/dup-a"),
|
||||
TransferDirectoryConf(name="重名目录", priority=6, storage="local", download_path="/downloads/dup-b"),
|
||||
TransferDirectoryConf(name="", priority=7, storage="local", download_path="/downloads/unnamed"),
|
||||
TransferDirectoryConf(name=None, priority=8, storage="local", download_path="/downloads/noname"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def download_dirs():
|
||||
"""用固定目录配置替换 DirectoryHelper.get_download_dirs。"""
|
||||
with patch.object(DirectoryHelper, "get_download_dirs", return_value=_download_dirs()):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("download_dirs")
|
||||
def test_alias_resolves_to_local_root():
|
||||
"""本地目录别名解析为该目录根路径。"""
|
||||
assert validate_download_save_path("可心影视库") == "/downloads/movies"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("download_dirs")
|
||||
def test_alias_resolves_to_remote_root_uri():
|
||||
"""远端目录别名解析为带存储前缀的根 URI,与 /download/paths 的 save_path 一致。"""
|
||||
assert validate_download_save_path("动漫远程库") == "rclone:/media/anime"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("download_dirs")
|
||||
def test_configured_path_wins_over_same_named_alias():
|
||||
"""输入既是合法配置路径又是另一目录的别名时,路径语义优先。"""
|
||||
assert validate_download_save_path("/downloads/movies") == "/downloads/movies"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("download_dirs")
|
||||
def test_path_like_alias_resolves_when_path_is_not_configured():
|
||||
"""路径形态的别名在路径校验失败后仍按别名解析。"""
|
||||
assert validate_download_save_path("/legacy/movies") == "/downloads/legacy"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("download_dirs")
|
||||
def test_duplicate_alias_uses_priority_order():
|
||||
"""重名目录沿用 get_download_dirs 的 priority 顺序取第一个。"""
|
||||
assert validate_download_save_path("重名目录") == "/downloads/dup-a"
|
||||
|
||||
|
||||
def test_alias_skips_invalid_directory_roots():
|
||||
"""同名目录中根路径无效的配置被跳过;只剩无效配置时按别名不存在报错。"""
|
||||
invalid = TransferDirectoryConf(name="X", priority=1, storage="local", download_path="relative/path")
|
||||
valid = TransferDirectoryConf(name="X", priority=2, storage="local", download_path="/downloads/x")
|
||||
with patch.object(DirectoryHelper, "get_download_dirs", return_value=[invalid, valid]):
|
||||
assert validate_download_save_path("X") == "/downloads/x"
|
||||
with patch.object(DirectoryHelper, "get_download_dirs", return_value=[invalid]):
|
||||
with pytest.raises(ValueError, match="未找到名为「X」的下载目录"):
|
||||
validate_download_save_path("X")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("download_dirs")
|
||||
def test_alias_is_trimmed_and_case_sensitive():
|
||||
"""别名匹配裁掉首尾空白,但不忽略大小写。"""
|
||||
assert validate_download_save_path(" Movies ") == "/downloads/en"
|
||||
with pytest.raises(ValueError, match="未找到名为「movies」的下载目录"):
|
||||
validate_download_save_path("movies")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("download_dirs")
|
||||
def test_unknown_alias_reports_missing_directory():
|
||||
"""不像路径的未知输入给出明确的目录不存在文案。"""
|
||||
with pytest.raises(ValueError, match="未找到名为「不存在的目录」的下载目录"):
|
||||
validate_download_save_path("不存在的目录")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("download_dirs")
|
||||
@pytest.mark.parametrize("value", ["", " ", None])
|
||||
def test_blank_value_keeps_empty_path_error(value):
|
||||
"""空值不参与别名匹配,即使存在 name 为空的目录配置。"""
|
||||
with pytest.raises(ValueError, match="保存路径不能为空"):
|
||||
validate_download_save_path(value)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("download_dirs")
|
||||
@pytest.mark.parametrize(
|
||||
("value", "message"),
|
||||
[
|
||||
("foo/bar", "保存路径必须是绝对路径"),
|
||||
("/downloads/../etc", "保存路径不能包含上级目录"),
|
||||
("/not/configured", "保存路径不在允许的下载目录范围内"),
|
||||
("rclone:/nowhere", "保存路径不在允许的下载目录范围内"),
|
||||
("rclone:anime", "保存路径必须是绝对路径"),
|
||||
("C:Movies", "保存路径必须是 Windows 绝对路径"),
|
||||
],
|
||||
)
|
||||
def test_path_like_input_keeps_original_error(value, message):
|
||||
"""带路径特征的输入在别名也不命中时保留原始路径校验文案。"""
|
||||
with pytest.raises(ValueError, match=message):
|
||||
validate_download_save_path(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("storage", "root", "expected_storage"),
|
||||
[("local", "/downloads/tv", "local"), ("rclone", "/media/tv", "rclone"), ("local", "D:/Downloads", "local")],
|
||||
)
|
||||
def test_resolve_media_download_dir_appends_type_folder_for_alias(storage, root, expected_storage):
|
||||
"""别名命中后链路按该目录的类型子目录规则追加路径。"""
|
||||
dirs = [
|
||||
TransferDirectoryConf(
|
||||
name="剧集库", priority=1, storage=storage, download_path=root, download_type_folder=True,
|
||||
),
|
||||
]
|
||||
media = MediaInfo(type=MediaType.TV, title="测试剧集")
|
||||
with patch.object(DirectoryHelper, "get_download_dirs", return_value=dirs):
|
||||
result = DownloadSubtitleOwner._resolve_media_download_dir(media, save_path="剧集库")
|
||||
assert result == (expected_storage, Path(root) / MediaType.TV.value, "")
|
||||
|
||||
|
||||
def test_resolve_media_download_dir_surfaces_unknown_alias_error():
|
||||
"""未知别名的错误文案沿链路返回给调用方。"""
|
||||
dirs = [TransferDirectoryConf(name="剧集库", priority=1, storage="local", download_path="/downloads/tv")]
|
||||
media = MediaInfo(type=MediaType.TV, title="测试剧集")
|
||||
with patch.object(DirectoryHelper, "get_download_dirs", return_value=dirs):
|
||||
result = DownloadSubtitleOwner._resolve_media_download_dir(media, save_path="不存在的库")
|
||||
assert result == (None, None, "未找到名为「不存在的库」的下载目录")
|
||||
Reference in New Issue
Block a user