Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
420 changes: 420 additions & 0 deletions docs/superpowers/specs/2026-05-17-network-proxy-settings-design.md

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion src/atv_player/api.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from __future__ import annotations

import logging
from collections.abc import Callable
from typing import Any

import httpx

from atv_player.models import HistoryRecord
from atv_player.network_proxy import ProxyDecider, build_httpx_kwargs_for_url


class ApiError(RuntimeError):
Expand All @@ -26,15 +28,19 @@ def __init__(
token: str = "",
vod_token: str = "",
transport: httpx.BaseTransport | None = None,
proxy_decider: ProxyDecider | None = None,
client_factory: Callable[..., httpx.Client] = httpx.Client,
) -> None:
headers = {"Authorization": token} if token else {}
self._vod_token = vod_token
self._client = httpx.Client(
client_kwargs: dict[str, Any] = dict(
base_url=base_url.rstrip("/"),
headers=headers,
transport=transport,
timeout=30.0,
)
client_kwargs.update(build_httpx_kwargs_for_url(proxy_decider, base_url))
self._client = client_factory(**client_kwargs)

def set_token(self, token: str) -> None:
if token:
Expand Down
102 changes: 91 additions & 11 deletions src/atv_player/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from collections.abc import Mapping
from dataclasses import replace
import gc
import httpx
import inspect
import threading
import time
Expand Down Expand Up @@ -63,15 +64,19 @@
from atv_player.metadata.providers.tmdb import TMDBProvider, infer_tmdb_media_type
from atv_player.metadata.providers.tmdb_client import TMDBClient
from atv_player.models import AppConfig, LiveEpgConfig, PlayItem, VodItem
from atv_player.network_proxy import ProxyConfig, ProxyDecider, build_httpx_kwargs_for_url
from atv_player.paths import app_cache_dir, app_data_dir
from atv_player.live_source_repository import LiveSourceRepository
from atv_player.plugins import SpiderPluginLoader, SpiderPluginManager
from atv_player.plugins.compat.base.spider import set_proxy_decider_loader as set_spider_proxy_decider_loader
from atv_player.plugins.repository import SpiderPluginRepository
from atv_player.playback_parsers import BuiltInPlaybackParserService
from atv_player.player.m3u8_ad_filter import M3U8AdFilter
from atv_player.proxy.server import LocalHlsProxyServer
from atv_player.yt_dlp_service import YtdlpPlaybackService
from atv_player.storage import SettingsRepository
from atv_player.time_utils import is_refresh_stale
from atv_player.ui.poster_loader import set_proxy_decider_loader
from atv_player.ui.login_window import LoginWindow
from atv_player.ui.main_window import MainWindow, load_direct_parse_detail
from atv_player.ui.icon_cache import load_icon
Expand Down Expand Up @@ -298,17 +303,31 @@ def __init__(self, repo: SettingsRepository) -> None:
self.login_window: LoginWindow | None = None
self.main_window: MainWindow | None = None
self._api_client: ApiClient | None = None
self._m3u8_ad_filter = M3U8AdFilter()
self._playback_parser_service = BuiltInPlaybackParserService()
self._yt_dlp_service = YtdlpPlaybackService()
self._danmaku_service = create_default_danmaku_service()
set_proxy_decider_loader(self._build_proxy_decider)
set_spider_proxy_decider_loader(self._build_proxy_decider)
self._m3u8_ad_filter = M3U8AdFilter(
proxy_server=LocalHlsProxyServer(
get=self._proxy_http_get(),
stream=self._proxy_http_stream(),
),
get=self._proxy_http_get(),
)
self._playback_parser_service = BuiltInPlaybackParserService(
get=self._proxy_http_get(),
post=self._proxy_http_post(),
)
self._yt_dlp_service = YtdlpPlaybackService(proxy_decider=self._build_proxy_decider())
self._danmaku_service = create_default_danmaku_service(
get=self._proxy_http_get(),
post=self._proxy_http_post(),
)
if hasattr(repo, "database_path"):
self._live_source_repository = LiveSourceRepository(repo.database_path)
self._live_epg_repository = LiveEpgRepository(repo.database_path)
self._plugin_repository = SpiderPluginRepository(repo.database_path)
self._playback_history_repository = LocalPlaybackHistoryRepository(repo.database_path)
cache_dir = app_cache_dir() / "plugins"
self._plugin_loader = SpiderPluginLoader(cache_dir)
self._plugin_loader = SpiderPluginLoader(cache_dir, get=self._proxy_http_get())
self._plugin_manager = SpiderPluginManager(
self._plugin_repository,
self._plugin_loader,
Expand Down Expand Up @@ -348,6 +367,40 @@ def _close_api_client(self) -> None:
close_client()
self._api_client = None

def _build_proxy_decider(self) -> ProxyDecider:
config = self.repo.load_config()
return ProxyDecider(
ProxyConfig(
mode=config.network_proxy_mode,
proxy_url=config.network_proxy_url,
bypass_rules=list(config.network_proxy_bypass_rules),
)
)

def _proxy_http_get(self):
def run(url: str, **kwargs):
request_kwargs = dict(kwargs)
request_kwargs.update(build_httpx_kwargs_for_url(self._build_proxy_decider(), url))
return httpx.get(url, **request_kwargs)

return run

def _proxy_http_post(self):
def run(url: str, **kwargs):
request_kwargs = dict(kwargs)
request_kwargs.update(build_httpx_kwargs_for_url(self._build_proxy_decider(), url))
return httpx.post(url, **request_kwargs)

return run

def _proxy_http_stream(self):
def run(method: str, url: str, **kwargs):
request_kwargs = dict(kwargs)
request_kwargs.update(build_httpx_kwargs_for_url(self._build_proxy_decider(), url))
return httpx.stream(method, url, **request_kwargs)

return run

def start(self) -> QWidget:
config = self.repo.load_config()
logger.info("App start view=%s", decide_start_view(config))
Expand All @@ -356,6 +409,7 @@ def start(self) -> QWidget:
config.base_url,
token=config.token,
vod_token=config.vod_token,
proxy_decider=self._build_proxy_decider(),
)
try:
self._ensure_vod_token(self._api_client)
Expand All @@ -375,6 +429,7 @@ def _build_api_client(self) -> ApiClient:
config.base_url,
token=config.token,
vod_token=config.vod_token,
proxy_decider=self._build_proxy_decider(),
)
self._ensure_vod_token(api_client)
return api_client
Expand Down Expand Up @@ -441,19 +496,37 @@ def _build_metadata_providers(
raw_detail=None,
) -> list[object]:
providers: list[object] = []
proxy_decider = self._build_proxy_decider()
if source_kind == "plugin":
plugin_payload = self._build_plugin_metadata_payload(raw_detail)
if plugin_payload is not None:
providers.append(CustomPluginProvider(plugin_payload))
providers.append(BangumiMetadataProvider(BangumiClient(access_token=config.metadata_bangumi_access_token)))
providers.append(
BangumiMetadataProvider(
BangumiClient(
access_token=config.metadata_bangumi_access_token,
proxy_decider=proxy_decider,
)
)
)
providers.append(BilibiliMetadataProvider())
providers.append(IqiyiMetadataProvider())
providers.append(TencentMetadataProvider())
if str(config.metadata_douban_cookie or "").strip():
local_douban_client = LocalDoubanClient(cookie=config.metadata_douban_cookie)
local_douban_client = LocalDoubanClient(
cookie=config.metadata_douban_cookie,
proxy_decider=proxy_decider,
)
providers.append(OfficialDoubanProvider(local_douban_client))
if str(config.metadata_tmdb_api_key or "").strip():
providers.append(TMDBProvider(TMDBClient(api_key=config.metadata_tmdb_api_key)))
providers.append(
TMDBProvider(
TMDBClient(
api_key=config.metadata_tmdb_api_key,
proxy_decider=proxy_decider,
)
)
)
providers.append(LocalDoubanProvider(api_client))
return providers

Expand Down Expand Up @@ -789,7 +862,10 @@ def factory(*, request=None, source_kind: str = "", source_key: str = "", vod=No
query = MetadataContext(vod=vod, source_kind=source_kind).to_query()
if infer_tmdb_media_type(query) == "movie":
return None
tmdb_client = TMDBClient(api_key=config.metadata_tmdb_api_key)
tmdb_client = TMDBClient(
api_key=config.metadata_tmdb_api_key,
proxy_decider=self._build_proxy_decider(),
)

def enhance(session) -> list | None:
session_vod = getattr(session, "vod", None) or vod
Expand Down Expand Up @@ -953,7 +1029,7 @@ def _show_login(self, error_message: str = "") -> LoginWindow:
self._close_api_client()
login_controller = LoginController(
self.repo,
lambda base_url: ApiClient(base_url),
lambda base_url: ApiClient(base_url, proxy_decider=self._build_proxy_decider()),
)
self.login_window = LoginWindow(login_controller)
if error_message and hasattr(self.login_window, "set_error_message"):
Expand Down Expand Up @@ -1145,7 +1221,11 @@ def plugin_loader_task():
drive_detail_loader=drive_detail_loader,
offline_download_detail_loader=offline_download_detail_loader,
direct_parse_detail_loader=load_direct_parse_detail,
direct_parse_danmaku_loader=load_direct_parse_danmaku,
direct_parse_danmaku_loader=lambda url: load_direct_parse_danmaku(
url,
get=self._proxy_http_get(),
proxy_decider=self._build_proxy_decider(),
),
direct_parse_playback_history_loader=None
if self._playback_history_repository is None
else lambda vod_id: self._playback_history_repository.get_history("direct_parse", vod_id),
Expand Down
11 changes: 8 additions & 3 deletions src/atv_player/danmaku/direct_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,24 @@
from atv_player.danmaku.cache import load_cached_danmaku_xml, save_cached_danmaku_xml
from atv_player.danmaku.models import DanmakuSourceGroup, DanmakuSourceOption, DanmakuSourceSearchResult
from atv_player.models import PlayItem
from atv_player.network_proxy import ProxyDecider, build_httpx_kwargs_for_url

_DIRECT_PARSE_DANMAKU_API = "https://dmku.hls.one/"
_DIRECT_PARSE_PROVIDER = "direct_parse"
_DIRECT_PARSE_PROVIDER_LABEL = "全局解析"


def load_direct_parse_danmaku(url: str) -> dict[str, Any]:
response = httpx.get(
def load_direct_parse_danmaku(
url: str,
get=httpx.get,
proxy_decider: ProxyDecider | None = None,
) -> dict[str, Any]:
response = get(
_DIRECT_PARSE_DANMAKU_API,
params={"ac": "dm", "url": url},
timeout=10.0,
follow_redirects=True,
**build_httpx_kwargs_for_url(proxy_decider, _DIRECT_PARSE_DANMAKU_API),
)
response.raise_for_status()
payload = response.json()
Expand Down Expand Up @@ -165,4 +171,3 @@ def _danmaku_color(self, value: object) -> int:
return int(text, 16)
except ValueError:
return 16777215

13 changes: 7 additions & 6 deletions src/atv_player/danmaku/service.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from dataclasses import replace
import httpx
import logging
import re

Expand Down Expand Up @@ -728,12 +729,12 @@ def resolve_danmu(self, page_url: str, option: DanmakuSourceOption | None = None
raise ProviderNotSupportedError(f"不支持的弹幕来源: {page_url}")


def create_default_danmaku_service() -> DanmakuService:
def create_default_danmaku_service(get=httpx.get, post=httpx.post) -> DanmakuService:
providers = {
"tencent": TencentDanmakuProvider(),
"youku": YoukuDanmakuProvider(),
"bilibili": BilibiliDanmakuProvider(),
"iqiyi": IqiyiDanmakuProvider(),
"mgtv": MgtvDanmakuProvider(),
"tencent": TencentDanmakuProvider(get=get, post=post),
"youku": YoukuDanmakuProvider(get=get, post=post),
"bilibili": BilibiliDanmakuProvider(get=get),
"iqiyi": IqiyiDanmakuProvider(get=get),
"mgtv": MgtvDanmakuProvider(get=get),
}
return DanmakuService(providers, provider_order=["tencent", "youku", "bilibili", "iqiyi", "mgtv"])
19 changes: 17 additions & 2 deletions src/atv_player/metadata/providers/bangumi_client.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
from __future__ import annotations

from collections.abc import Callable
from typing import Any

import httpx

from atv_player.network_proxy import ProxyDecider, build_httpx_kwargs_for_url


class BangumiClient:
_BASE_URL = "https://api.bgm.tv"
_USER_AGENT = "ATVPlayer/1.0 (metadata integration)"

def __init__(self, access_token: str = "", transport: httpx.BaseTransport | None = None) -> None:
def __init__(
self,
access_token: str = "",
transport: httpx.BaseTransport | None = None,
proxy_decider: ProxyDecider | None = None,
client_factory: Callable[..., httpx.Client] = httpx.Client,
) -> None:
self._access_token = str(access_token or "").strip()
self._client = httpx.Client(base_url=self._BASE_URL, transport=transport, timeout=10.0)
client_kwargs: dict[str, Any] = dict(
base_url=self._BASE_URL,
transport=transport,
timeout=10.0,
)
client_kwargs.update(build_httpx_kwargs_for_url(proxy_decider, self._BASE_URL))
self._client = client_factory(**client_kwargs)

def _headers(self) -> dict[str, str]:
headers = {"User-Agent": self._USER_AGENT}
Expand Down
9 changes: 8 additions & 1 deletion src/atv_player/metadata/providers/local_douban_client.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
from __future__ import annotations

from collections.abc import Callable
import json
import re
from html import unescape

import httpx

from atv_player.network_proxy import ProxyDecider, build_httpx_kwargs_for_url


class DoubanBlockedError(RuntimeError):
pass
Expand All @@ -23,13 +26,17 @@ def __init__(
self,
cookie: str = "",
transport: httpx.BaseTransport | None = None,
proxy_decider: ProxyDecider | None = None,
client_factory: Callable[..., httpx.Client] = httpx.Client,
) -> None:
self._cookie = cookie.strip()
self._client = httpx.Client(
client_kwargs = dict(
transport=transport,
timeout=15.0,
follow_redirects=True,
)
client_kwargs.update(build_httpx_kwargs_for_url(proxy_decider, self._SEARCH_URL))
self._client = client_factory(**client_kwargs)

def _headers(self) -> dict[str, str]:
headers = {
Expand Down
9 changes: 8 additions & 1 deletion src/atv_player/metadata/providers/tmdb_client.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from __future__ import annotations

from collections.abc import Callable
from typing import Any

import httpx

from atv_player.network_proxy import ProxyDecider, build_httpx_kwargs_for_url


class TMDBClient:
_BASE_URL = "https://api.themoviedb.org/3"
Expand All @@ -12,13 +15,17 @@ def __init__(
self,
api_key: str,
transport: httpx.BaseTransport | None = None,
proxy_decider: ProxyDecider | None = None,
client_factory: Callable[..., httpx.Client] = httpx.Client,
) -> None:
self._api_key = str(api_key or "").strip()
self._client = httpx.Client(
client_kwargs: dict[str, Any] = dict(
base_url=self._BASE_URL,
transport=transport,
timeout=20.0,
)
client_kwargs.update(build_httpx_kwargs_for_url(proxy_decider, self._BASE_URL))
self._client = client_factory(**client_kwargs)
self._image_config: dict[str, Any] | None = None

def _request(self, path: str, **params: object) -> dict[str, Any]:
Expand Down
Loading
Loading