diff --git a/.gitignore b/.gitignore index 03fa91492..2128983eb 100644 --- a/.gitignore +++ b/.gitignore @@ -167,7 +167,6 @@ cython_debug/ /backend/src/module/run_debug.sh /backend/src/module/debug_run.sh -/backend/src/module/__version__.py /backend/src/data/ /src/module/conf/config_dev.ini diff --git a/Dockerfile b/Dockerfile index 9d73b6b1e..548c68a6d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,4 @@ -# syntax=docker/dockerfile:1 - -FROM ghcr.io/astral-sh/uv:0.5-python3.13-alpine AS builder +FROM ghcr.io/astral-sh/uv:0.5-python3.13-alpine AS builder WORKDIR /app ENV UV_COMPILE_BYTECODE=1 @@ -14,7 +12,7 @@ RUN uv sync --frozen --no-dev COPY backend/src ./src -FROM python:3.13-alpine AS runtime +FROM ghcr.io/astral-sh/uv:0.5-python3.13-alpine AS runtime RUN apk add --no-cache \ bash \ @@ -34,6 +32,10 @@ WORKDIR /app # Copy venv and source from builder COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/src . + +# Copy pre-built frontend from host +COPY webui/dist ./dist + COPY --chmod=755 entrypoint.sh /entrypoint.sh # Add user @@ -43,7 +45,7 @@ RUN mkdir -p /home/ab && \ ENV PATH="/app/.venv/bin:$PATH" -EXPOSE 7892 +EXPOSE 37892 VOLUME ["/app/config", "/app/data"] ENTRYPOINT ["tini", "-g", "--", "/entrypoint.sh"] diff --git a/backend/src/module/__version__.py b/backend/src/module/__version__.py new file mode 100644 index 000000000..2fa3f3df2 --- /dev/null +++ b/backend/src/module/__version__.py @@ -0,0 +1 @@ +VERSION = "3.2.6-amy" diff --git a/backend/src/module/api/notification.py b/backend/src/module/api/notification.py index 21af55c98..8a39fad01 100644 --- a/backend/src/module/api/notification.py +++ b/backend/src/module/api/notification.py @@ -33,7 +33,8 @@ class TestProviderConfigRequest(BaseModel): user_key: Optional[str] = Field(None, description="User key") api_token: Optional[str] = Field(None, description="API token") template: Optional[str] = Field(None, description="Custom template") - url: Optional[str] = Field(None, description="URL for generic webhook") + url: Optional[str] = Field(None, description="URL for generic webhook/onebot") + message_type: Optional[str] = Field(None, description="Message type for onebot: private or group") class TestResponse(BaseModel): @@ -105,6 +106,7 @@ async def test_provider_config(request: TestProviderConfigRequest): api_token=request.api_token or "", template=request.template, url=request.url or "", + message_type=request.message_type or "private", ) success, message = await NotificationManager.test_provider_config(config) diff --git a/backend/src/module/api/program.py b/backend/src/module/api/program.py index e3cb07b93..deb832d86 100644 --- a/backend/src/module/api/program.py +++ b/backend/src/module/api/program.py @@ -1,6 +1,7 @@ import logging import os import signal +import sys from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import JSONResponse @@ -68,18 +69,14 @@ async def stop(): @router.get("/status", response_model=dict, dependencies=[Depends(get_current_user)]) async def program_status(): - if not program.is_running: - return { - "status": False, - "version": VERSION, - "first_run": program.first_run, - } - else: - return { - "status": True, - "version": VERSION, - "first_run": program.first_run, - } + is_linux = sys.platform == "linux" + base = { + "status": program.is_running, + "version": VERSION, + "first_run": program.first_run, + "platform": "linux" if is_linux else "windows", + } + return base @router.get( diff --git a/backend/src/module/conf/const.py b/backend/src/module/conf/const.py index 69f25181f..8b1b6af59 100644 --- a/backend/src/module/conf/const.py +++ b/backend/src/module/conf/const.py @@ -7,7 +7,7 @@ "program": { "rss_time": 900, "rename_time": 60, - "webui_port": 7892, + "webui_port": 37892, }, "downloader": { "type": "qbittorrent", diff --git a/backend/src/module/core/program.py b/backend/src/module/core/program.py index 64b1acf5e..a2f4a4e3f 100644 --- a/backend/src/module/core/program.py +++ b/backend/src/module/core/program.py @@ -18,17 +18,22 @@ logger = logging.getLogger(__name__) figlet = r""" - _ ____ _ - /\ | | | _ \ (_) - / \ _ _| |_ ___ | |_) | __ _ _ __ __ _ _ _ _ __ ___ _ - / /\ \| | | | __/ _ \| _ < / _` | '_ \ / _` | | | | '_ ` _ \| | - / ____ \ |_| | || (_) | |_) | (_| | | | | (_| | |_| | | | | | | | -/_/ \_\__,_|\__\___/|____/ \__,_|_| |_|\__, |\__,_|_| |_| |_|_| - __/ | - |___/ -""" - +$$$$$$\ $$\ $$\ $$\ $$\ +$$ __$$\ $$$\ $$$ |\$$\ $$ | +$$ / $$ |$$$$\ $$$$ | \$$\ $$ / +$$$$$$$$ |$$\$$\$$ $$ | \$$$$ / +$$ __$$ |$$ \$$$ $$ | \$$ / +$$ | $$ |$$ |\$ /$$ | $$ | +$$ | $$ |$$ | \_/ $$ | $$ | +\__| \__|\__| \__| \__| + ___ __ __ _ + / | __ __/ /_____ / /_ ____ _____ ____ ___ ______ ___ (_) + / /| |/ / / / __/ __ \/ __ \/ __ `/ __ \/ __ `/ / / / __ `__ \/ / + / ___ / /_/ / /_/ /_/ / /_/ / /_/ / / / / /_/ / /_/ / / / / / / / +/_/ |_\__,_/\__/\____/_.___/\__,_/_/ /_/\__, /\__,_/_/ /_/ /_/_/ + /____/ +""" class Program(RenameThread, RSSThread, OffsetScanThread, CalendarRefreshThread): def __init__(self): super().__init__() @@ -39,10 +44,10 @@ def __start_info(): for line in figlet.splitlines(): logger.info(line.strip("\n")) logger.info( - f"Version {VERSION} Author: EstrellaXD Twitter: https://twitter.com/Estrella_Pan" + f"Version {VERSION} Author: AMYdd00 | Amy 自建修复版" ) - logger.info("GitHub: https://github.com/EstrellaXD/Auto_Bangumi/") - logger.info("Starting AutoBangumi...") + logger.info("GitHub: https://github.com/AMYdd00/Auto_Bangumi/") + logger.info("Starting...") async def startup(self): # Prevent duplicate startup due to nested router lifespan events diff --git a/backend/src/module/database/bangumi.py b/backend/src/module/database/bangumi.py index 076491223..a83df0ea0 100644 --- a/backend/src/module/database/bangumi.py +++ b/backend/src/module/database/bangumi.py @@ -1,4 +1,4 @@ -import json +import json import logging import re import time @@ -428,6 +428,10 @@ def match_list(self, torrent_list: list, rss_link: str) -> list: # Build compiled regex pattern for fast substring matching # Sort by length descending so longer (more specific) matches are found first + # If no titles are available to match, return all as unmatched + if not title_index: + return torrent_list + sorted_titles = sorted(title_index.keys(), key=len, reverse=True) # Escape special regex characters and join with alternation pattern = "|".join(re.escape(title) for title in sorted_titles) diff --git a/backend/src/module/models/config.py b/backend/src/module/models/config.py index b0f4a59b2..3cf76f5a5 100644 --- a/backend/src/module/models/config.py +++ b/backend/src/module/models/config.py @@ -120,7 +120,10 @@ class NotificationProvider(BaseModel): None, description="Custom template for webhook provider" ) url_: Optional[str] = Field( - None, alias="url", description="URL for generic webhook provider" + None, alias="url", description="URL for generic webhook/onebot provider" + ) + message_type: Optional[str] = Field( + "private", description="Message type for onebot: 'private' or 'group'" ) @property diff --git a/backend/src/module/network/request_contents.py b/backend/src/module/network/request_contents.py index e271947e7..a6f6e9b7f 100644 --- a/backend/src/module/network/request_contents.py +++ b/backend/src/module/network/request_contents.py @@ -25,8 +25,10 @@ async def get_torrents( torrents: list[Torrent] = [] if _filter is None: _filter = "|".join(settings.rss_parser.filter) + if not _filter: + _filter = None for _title, torrent_url, homepage in parsed_items: - if re.search(_filter, _title) is None: + if _filter is None or re.search(_filter, _title) is None: torrents.append( Torrent(name=_title, url=torrent_url, homepage=homepage) ) diff --git a/backend/src/module/notification/providers/__init__.py b/backend/src/module/notification/providers/__init__.py index 6dd3b6e85..f37f78fa9 100644 --- a/backend/src/module/notification/providers/__init__.py +++ b/backend/src/module/notification/providers/__init__.py @@ -10,6 +10,7 @@ from module.notification.providers.gotify import GotifyProvider from module.notification.providers.pushover import PushoverProvider from module.notification.providers.webhook import WebhookProvider +from module.notification.providers.onebot import OneBotProvider if TYPE_CHECKING: from module.notification.base import NotificationProvider @@ -25,6 +26,7 @@ "gotify": GotifyProvider, "pushover": PushoverProvider, "webhook": WebhookProvider, + "onebot": OneBotProvider, } __all__ = [ @@ -37,4 +39,5 @@ "GotifyProvider", "PushoverProvider", "WebhookProvider", + "OneBotProvider", ] diff --git a/backend/src/module/notification/providers/onebot.py b/backend/src/module/notification/providers/onebot.py new file mode 100644 index 000000000..0f27907f7 --- /dev/null +++ b/backend/src/module/notification/providers/onebot.py @@ -0,0 +1,236 @@ +"""OneBot v11 notification provider. + +OneBot v11 is a standard for QQ bot APIs. This provider sends +notifications via the OneBot v11 HTTP API. + +Documentation: https://github.com/botuniverse/onebot-11 +""" + +import base64 +import json +import logging +import os +from typing import TYPE_CHECKING + +from module.models.bangumi import Notification +from module.notification.base import NotificationProvider + +if TYPE_CHECKING: + from module.models.config import NotificationProvider as ProviderConfig + +logger = logging.getLogger(__name__) + + +class OneBotProvider(NotificationProvider): + """OneBot v11 HTTP API notification provider. + + Sends anime update notifications through a OneBot v11-compatible + QQ bot using the HTTP API. + + Config fields used: + - url: Base URL of the OneBot HTTP API (e.g. http://localhost:5700) + - token: Optional Authorization access_token + - chat_id: Target user_id (private) or group_id (group) + - message_type: "private" for private messages, "group" for group messages + """ + + def __init__(self, config: "ProviderConfig"): + super().__init__() + self.base_url = config.url.rstrip("/") + self.token = config.token or "" + self.chat_id = config.chat_id or "" + self.message_type = config.message_type or "private" + + # Build API endpoints + self.private_msg_url = f"{self.base_url}/send_private_msg" + self.group_msg_url = f"{self.base_url}/send_group_msg" + + # Build JSON headers (OneBot API expects application/json) + self.json_headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + if self.token: + self.json_headers["Authorization"] = f"Bearer {self.token}" + + async def _post_json(self, url: str, data: dict) -> object: + """Send a JSON POST request using the shared httpx client. + + OneBot API requires proper application/json content type, + which the inherited post_data() does not provide (it sends + form-encoded data). This method uses the underlying httpx + client directly with json= parameter. + + Args: + url: The URL to send the request to. + data: The JSON-serializable data to send. + + Returns: + The httpx response object, or None on failure. + """ + try: + req = await self._client.post( + url=url, + json=data, + headers=self.json_headers, + ) + req.raise_for_status() + return req + except Exception as e: + logger.warning(f"[OneBot] Request failed: {e}") + return None + + def _get_image_file(self, poster_path: str) -> str | None: + """Convert poster_path to a OneBot-compatible image file reference. + + Handles three cases: + 1. Remote URL (contains ://) - use as-is + 2. Local file path - read and convert to base64 + 3. Invalid/missing - return None + + Args: + poster_path: The poster path or URL from the database. + + Returns: + A OneBot-compatible file string (URL or base64), or None. + """ + if not poster_path or poster_path in ("", "https://mikanani.me"): + return None + + # If it's a remote URL, use it directly + if "://" in poster_path: + return poster_path + + # Otherwise, try to read it as a local file + # The path is relative to the data directory (e.g. "posters/xxx.jpg") + local_path = os.path.join("data", poster_path.lstrip("/")) + if os.path.exists(local_path): + try: + with open(local_path, "rb") as f: + img_data = f.read() + img_b64 = base64.b64encode(img_data).decode("ascii") + # Determine MIME type from extension + ext = os.path.splitext(local_path)[1].lower() + mime_map = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + } + mime = mime_map.get(ext, "image/jpeg") + return f"base64://{img_b64}" + except Exception as e: + logger.warning(f"[OneBot] Failed to read local image {local_path}: {e}") + return None + else: + logger.warning(f"[OneBot] Local image not found: {local_path}") + return None + + def _build_payload( + self, text: str, poster_path: str = None + ) -> str | list: + """Build the message payload for OneBot API. + + For plain text (no poster), sends a simple string. + When a poster image is available, sends a message segment array + with both image and text. + + Args: + text: The text message content. + poster_path: Optional URL or local path to a poster image. + + Returns: + A string (plain text) or list (message segments). + """ + image_file = self._get_image_file(poster_path) if poster_path else None + if image_file: + return [ + {"type": "image", "data": {"file": image_file}}, + {"type": "text", "data": {"text": text}}, + ] + return text + + async def send(self, notification: Notification) -> bool: + """Send notification via OneBot v11. + + Args: + notification: The notification data. + + Returns: + True if the message was sent successfully. + """ + text = self._format_message(notification) + message = self._build_payload(text, notification.poster_path) + + if self.message_type == "group": + payload = { + "group_id": int(self.chat_id), + "message": message, + } + url = self.group_msg_url + else: + payload = { + "user_id": int(self.chat_id), + "message": message, + } + url = self.private_msg_url + + resp = await self._post_json(url, payload) + logger.debug("OneBot notification: %s", resp.status_code if resp else None) + + if resp and resp.status_code == 200: + try: + result = resp.json() + if result.get("status") == "ok" or result.get("retcode") == 0: + return True + else: + logger.warning("OneBot API returned error: %s", result) + return False + except (json.JSONDecodeError, AttributeError): + return True + + return resp is not None and resp.status_code == 200 + + async def test(self) -> tuple[bool, str]: + """Test the OneBot configuration by sending a test message. + + Returns: + A tuple of (success, message). + """ + text = "AutoBangumi 通知测试成功!\nNotification test successful!" + + if self.message_type == "group": + payload = { + "group_id": int(self.chat_id), + "message": text, + } + url = self.group_msg_url + else: + payload = { + "user_id": int(self.chat_id), + "message": text, + } + url = self.private_msg_url + + try: + resp = await self._post_json(url, payload) + if resp and resp.status_code == 200: + try: + result = resp.json() + if result.get("status") == "ok" or result.get("retcode") == 0: + return True, "OneBot test message sent successfully" + else: + error_msg = ( + result.get("msg") + or result.get("wording") + or "unknown error" + ) + return False, f"OneBot API error: {error_msg}" + except (json.JSONDecodeError, AttributeError): + return True, "OneBot test message sent successfully" + else: + status = resp.status_code if resp else "No response" + return False, f"OneBot API returned status {status}" + except Exception as e: + return False, f"OneBot test failed: {e}" diff --git a/backend/src/test/test_notification.py b/backend/src/test/test_notification.py index a9e005b14..289070bb0 100644 --- a/backend/src/test/test_notification.py +++ b/backend/src/test/test_notification.py @@ -15,6 +15,7 @@ GotifyProvider, PushoverProvider, WebhookProvider, + OneBotProvider, ) @@ -295,6 +296,100 @@ async def test_send(self, provider): assert result is True + + +class TestOneBotProvider: + @pytest.fixture + def provider(self): + config = ProviderConfig( + type="onebot", + enabled=True, + url="http://localhost:5700", + token="test_token", + chat_id="123456789", + message_type="private", + ) + return OneBotProvider(config) + + async def test_send_private_message(self, provider): + """Sends private message via OneBot API.""" + notify = Notification( + official_title="Test Anime", season=1, episode=5, poster_path="https://example.com/poster.jpg" + ) + + with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post: + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"status": "ok", "retcode": 0} + mock_post.return_value = mock_response + result = await provider.send(notify) + + assert result is True + call_args = mock_post.call_args[0] + assert "send_private_msg" in call_args[0] + assert "user_id" in call_args[1] + + async def test_send_group_message(self, provider): + """Sends group message via OneBot API.""" + provider.message_type = "group" + notify = Notification( + official_title="Test Anime", season=1, episode=5 + ) + + with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post: + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"status": "ok", "retcode": 0} + mock_post.return_value = mock_response + result = await provider.send(notify) + + assert result is True + call_args = mock_post.call_args[0] + assert "send_group_msg" in call_args[0] + assert "group_id" in call_args[1] + + async def test_send_api_error(self, provider): + """Handles OneBot API error response.""" + notify = Notification(official_title="Test Anime", season=1, episode=5) + + with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post: + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"status": "failed", "retcode": 100, "msg": "bad request"} + mock_post.return_value = mock_response + result = await provider.send(notify) + + assert result is False + + async def test_send_http_error(self, provider): + """Handles HTTP error.""" + notify = Notification(official_title="Test Anime", season=1, episode=5) + + with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post: + mock_post.return_value = MagicMock(status_code=401) + result = await provider.send(notify) + + assert result is False + + async def test_test_success(self, provider): + """Test method sends test message successfully.""" + with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post: + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"status": "ok", "retcode": 0} + mock_post.return_value = mock_response + success, message = await provider.test() + + assert success is True + assert "successfully" in message.lower() + + async def test_test_failure(self, provider): + """Test method handles API error.""" + with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post: + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"status": "failed", "retcode": 100, "wording": "invalid target"} + mock_post.return_value = mock_response + success, message = await provider.test() + + assert success is False + assert "invalid target" in message.lower() + # --------------------------------------------------------------------------- # Config Migration # --------------------------------------------------------------------------- @@ -330,3 +425,4 @@ def test_new_config_no_migration(self): assert len(new_config.providers) == 1 assert new_config.providers[0].type == "discord" + diff --git a/webui/src/api/notification.ts b/webui/src/api/notification.ts index e1d6d5f90..b0fe3b219 100644 --- a/webui/src/api/notification.ts +++ b/webui/src/api/notification.ts @@ -1,4 +1,4 @@ -import type { NotificationProviderConfig, NotificationType } from '#/config'; +import type { NotificationProviderConfig, NotificationType } from '#/config'; import type { TupleToUnion } from '#/utils'; export interface TestProviderRequest { @@ -17,6 +17,7 @@ export interface TestProviderConfigRequest { api_token?: string; template?: string; url?: string; + message_type?: string; } export interface TestResponse { @@ -49,3 +50,4 @@ export const apiNotification = { return { data }; }, }; + diff --git a/webui/src/api/program.ts b/webui/src/api/program.ts index eea8e1bb8..ff0e5d907 100644 --- a/webui/src/api/program.ts +++ b/webui/src/api/program.ts @@ -29,7 +29,7 @@ export const apiProgram = { * 状态 */ async status() { - const { data } = await axios.get<{ status: boolean; version: string }>( + const { data } = await axios.get<{ status: boolean; version: string; platform: string; first_run: boolean }>( 'api/v1/status' ); diff --git a/webui/src/components/setting/config-download.vue b/webui/src/components/setting/config-download.vue index 0267cb44c..decfc9999 100644 --- a/webui/src/components/setting/config-download.vue +++ b/webui/src/components/setting/config-download.vue @@ -1,4 +1,4 @@ -