Compare commits

...

4 Commits

6 changed files with 114 additions and 11 deletions

View File

@@ -11,6 +11,13 @@ on:
# 允许手动触发
workflow_dispatch:
permissions:
contents: read
concurrency:
group: unit-tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
pytest:
runs-on: ubuntu-latest

View File

@@ -2785,9 +2785,16 @@ class SubscribeChain(ChainBase):
# 更新剧集列表、开始集数、总集数
if not episode_list:
# 整季缺失
episodes = []
start_episode = start_episode or start
total_episode = total_episode or total
original_start = start if start is not None else 1
# 空集列表会被下载链解释为整季下载;当订阅开始集裁掉季初范围时,需要转成显式集数。
if start_episode and total_episode and start_episode > original_start:
episodes = list(range(start_episode, total_episode + 1))
if not episodes:
return True, {}
else:
episodes = []
else:
# 部分缺失
if not start_episode \

View File

@@ -9,10 +9,10 @@ import sys
import threading
from asyncio import AbstractEventLoop
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Type
from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_origin, get_args
from urllib.parse import quote, urlencode, urlparse
from dotenv import set_key
from dotenv import set_key, unset_key
from pydantic import BaseModel, Field, ConfigDict, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -690,6 +690,18 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
if isinstance(value, str):
value = value.strip()
# 处理 Optional 类型:当值为空字符串且类型允许 None 时,转为 None
# 兼容 typing.Union (Python 3.9) 与 types.UnionType (Python 3.10+ PEP 604)
origin = get_origin(expected_type)
is_union = origin is Union or getattr(origin, "__name__", None) == "UnionType"
if (
is_union
and type(None) in get_args(expected_type)
and isinstance(value, str)
and not value
):
return default, str(default) != str(original_value)
try:
if expected_type is bool:
if isinstance(value, bool):
@@ -812,13 +824,19 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
logger.warning(message)
return False, message
else:
# 当值为 None 时,从 env 文件中删除该键,恢复为默认值
if converted_value is None:
unset_key(
dotenv_path=SystemUtils.get_env_path(),
key_to_unset=field_name,
)
logger.info(f"配置项 '{field_name}' 已清空,从 'app.env' 中移除")
return True, message
# 如果是列表、字典或集合类型将其转换为JSON字符串
if isinstance(converted_value, (list, dict, set)):
value_to_write = json.dumps(converted_value)
else:
value_to_write = (
str(converted_value) if converted_value is not None else ""
)
value_to_write = str(converted_value)
set_key(
dotenv_path=SystemUtils.get_env_path(),
@@ -967,7 +985,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
@property
def PROXY(self):
if self.PROXY_HOST:
if self.PROXY_HOST and self.PROXY_HOST.strip():
return {
"http": self.PROXY_HOST,
"https": self.PROXY_HOST,
@@ -1009,7 +1027,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
@property
def PROXY_SERVER(self):
if self.PROXY_HOST:
if self.PROXY_HOST and self.PROXY_HOST.strip():
try:
parsed = urlparse(self.PROXY_HOST)
if not parsed.scheme:

View File

@@ -92,6 +92,9 @@ def _get_shared_async_transport(
会话级状态由调用方在外层 AsyncClient(transport=...) 实例化时单独配置,
每次调用用完即销毁,因此天然无 jar 累积串扰。
"""
# 规范化代理:拒绝空字符串等非法值,防止 httpx 抛出 Unknown scheme for proxy URL
if proxy is not None and (not proxy or not proxy.strip()):
proxy = None
try:
loop = asyncio.get_running_loop()
except RuntimeError:
@@ -899,12 +902,17 @@ class AsyncRequestUtils:
# 如果已经是字符串格式,直接返回
if isinstance(proxies, str):
return proxies
return proxies.strip() or None
# 如果是字典格式提取http或https代理
if isinstance(proxies, dict):
# 优先使用https代理如果没有则使用http代理
proxy_url = proxies.get("https") or proxies.get("http")
# 先各自 strip避免空白字符串阻断裂合取或回退到 http 代理
https_proxy = proxies.get("https")
http_proxy = proxies.get("http")
https_proxy = https_proxy.strip() if isinstance(https_proxy, str) else None
http_proxy = http_proxy.strip() if isinstance(http_proxy, str) else None
proxy_url = https_proxy or http_proxy
if proxy_url:
return proxy_url

View File

@@ -456,6 +456,69 @@ class SubscribeChainTest(TestCase):
self.assertEqual(SubscribeChain.get_best_version_current_priority(subscribe), 100)
self.assertTrue(SubscribeChain.is_best_version_complete(subscribe))
def test_get_subscribe_no_exists_expands_whole_missing_when_custom_start_skips_existing_range(self):
"""自定义开始集跳过季初集数时,缺失整季需要转成显式目标集。"""
no_exists = {
"media-key": {
1: SimpleNamespace(season=1, episodes=[], total_episode=48, start_episode=1)
}
}
exist_flag, result = SubscribeChain._SubscribeChain__get_subscribe_no_exits(
subscribe_name="主角 S01",
no_exists=no_exists,
mediakey="media-key",
begin_season=1,
total_episode=48,
start_episode=44,
)
self.assertFalse(exist_flag)
self.assertEqual(result["media-key"][1].episodes, [44, 45, 46, 47, 48])
self.assertEqual(result["media-key"][1].start_episode, 44)
self.assertEqual(result["media-key"][1].total_episode, 48)
def test_get_subscribe_no_exists_keeps_whole_missing_when_custom_start_matches_original_start(self):
"""自定义开始集没有缩小范围时,仍保留空集列表表示整季缺失。"""
no_exists = {
"media-key": {
1: SimpleNamespace(season=1, episodes=[], total_episode=48, start_episode=1)
}
}
exist_flag, result = SubscribeChain._SubscribeChain__get_subscribe_no_exits(
subscribe_name="主角 S01",
no_exists=no_exists,
mediakey="media-key",
begin_season=1,
total_episode=48,
start_episode=1,
)
self.assertFalse(exist_flag)
self.assertEqual(result["media-key"][1].episodes, [])
self.assertEqual(result["media-key"][1].start_episode, 1)
self.assertEqual(result["media-key"][1].total_episode, 48)
def test_best_version_full_pack_first_keeps_whole_missing_for_custom_start_episode(self):
"""分集洗版优先全集时,空集列表仍表示下载链按整季资源处理。"""
subscribe = self._build_subscribe(
best_version=1,
best_version_full=0,
start_episode=44,
total_episode=48,
episode_priority={str(episode): 80 for episode in range(44, 49)},
)
result = SubscribeChain._SubscribeChain__build_full_pack_first_no_exists(
subscribe=subscribe,
mediakey="media-key",
)
self.assertEqual(result["media-key"][1].episodes, [])
self.assertEqual(result["media-key"][1].start_episode, 44)
self.assertEqual(result["media-key"][1].total_episode, 48)
def test_is_episode_range_covered_matches_pending_episodes(self):
subscribe = self._build_subscribe(
total_episode=12,

View File

@@ -1,2 +1,2 @@
APP_VERSION = 'v2.13.5'
APP_VERSION = 'v2.13.5-1'
FRONTEND_VERSION = 'v2.13.5'