From 7d50e360eda4e9e8f620ea7701ac848e3b36fd66 Mon Sep 17 00:00:00 2001 From: zthxxx Date: Thu, 31 Aug 2023 23:48:07 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20bangumi=20search=20?= =?UTF-8?q?API=20=E4=B8=AD=E7=9A=84=E6=B5=81=E5=BC=8F=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=8Cwebui=20=E5=AF=B9=E5=BA=94=E6=8E=A5=E5=8F=A3=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E6=96=B9=E6=B3=95=E6=94=B9=E9=80=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StreamingResponse 换成 EventSourceResponse,即 Server Send Event Source 方式发送数据 - webui 中 search 接口改成 rxjs Observable 方式, 这次来不及改到 vue 里,但是写了接口 usage example; - 顺手补了一些 vscode 通用的开发配置, 补了之前配置文件解析漏了一个可用环境变量的 host 字段 --- .vscode/extensions.json | 18 ++++++ .vscode/launch.json | 17 +++++ .vscode/settings.json | 6 ++ backend/requirements.txt | 3 +- backend/src/module/api/search.py | 12 ++-- backend/src/module/models/config.py | 6 +- backend/src/module/searcher/searcher.py | 14 ++--- webui/package.json | 3 + webui/pnpm-lock.yaml | 26 +++++++- webui/src/api/search.ts | 83 ++++++++++++++++++++++--- webui/types/bangumi.ts | 3 + webui/types/dts/html.d.ts | 9 +++ 12 files changed, 177 insertions(+), 23 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 webui/types/dts/html.d.ts diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..b3db48e8 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,18 @@ +{ + "recommendations": [ + // https://marketplace.visualstudio.com/items?itemName=antfu.unocss + "antfu.unocss", + // https://marketplace.visualstudio.com/items?itemName=formulahendry.auto-rename-tag + "formulahendry.auto-rename-tag", + // https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker + "streetsidesoftware.code-spell-checker", + // https://marketplace.visualstudio.com/items?itemName=naumovs.color-highlight + "naumovs.color-highlight", + // https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance + "ms-python.vscode-pylance", + // https://marketplace.visualstudio.com/items?itemName=ms-python.python + "ms-python.python", + // https://marketplace.visualstudio.com/items?itemName=vue.volar + "vue.volar" + ] +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..3accd58d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Dev Backend", + "type": "python", + "request": "launch", + "cwd": "${workspaceFolder}/backend/src", + "program": "main.py", + "console": "integratedTerminal", + "justMyCode": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 28838c61..5d6b2455 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,4 +10,10 @@ "editor.wordWrap": "off", }, "python.venvPath": "./backend/venv", + "cSpell.words": [ + "Bangumi", + "fastapi", + "mikan", + "starlette" + ], } diff --git a/backend/requirements.txt b/backend/requirements.txt index 783fe560..c1d2730f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -23,4 +23,5 @@ python-jose==3.3.0 passlib==1.7.4 bcrypt==4.0.1 python-multipart==0.0.6 -sqlmodel +sqlmodel==0.0.8 +sse-starlette==1.6.5 diff --git a/backend/src/module/api/search.py b/backend/src/module/api/search.py index 570e944b..0ed0c28e 100644 --- a/backend/src/module/api/search.py +++ b/backend/src/module/api/search.py @@ -1,29 +1,31 @@ from fastapi import APIRouter, Query, Depends -from fastapi.responses import StreamingResponse +from sse_starlette.sse import EventSourceResponse from module.searcher import SearchTorrent, SEARCH_CONFIG from module.security.api import get_current_user, UNAUTHORIZED -from module.models import Torrent +from module.models import Bangumi router = APIRouter(prefix="/search", tags=["search"]) -@router.get("/", response_model=list[Torrent]) +@router.get("/", response_model=EventSourceResponse[Bangumi]) async def search_torrents( site: str = "mikan", keywords: str = Query(None), current_user=Depends(get_current_user), ): + """ + Server Send Event for per Bangumi item + """ if not current_user: raise UNAUTHORIZED if not keywords: return [] keywords = keywords.split(" ") with SearchTorrent() as st: - return StreamingResponse( + return EventSourceResponse( content=st.analyse_keyword(keywords=keywords, site=site), - media_type="application/json", ) diff --git a/backend/src/module/models/config.py b/backend/src/module/models/config.py index 9a14001e..df15546a 100644 --- a/backend/src/module/models/config.py +++ b/backend/src/module/models/config.py @@ -10,7 +10,7 @@ class Program(BaseModel): class Downloader(BaseModel): type: str = Field("qbittorrent", description="Downloader type") - host: str = Field("172.17.0.1:8080", description="Downloader host") + host_: str = Field("172.17.0.1:8080", alias="host", description="Downloader host") username_: str = Field("admin", alias="username", description="Downloader username") password_: str = Field( "adminadmin", alias="password", description="Downloader password" @@ -18,6 +18,10 @@ class Downloader(BaseModel): path: str = Field("/downloads/Bangumi", description="Downloader path") ssl: bool = Field(False, description="Downloader ssl") + @property + def host(self): + return expandvars(self.host_) + @property def username(self): return expandvars(self.username_) diff --git a/backend/src/module/searcher/searcher.py b/backend/src/module/searcher/searcher.py index 363e97f4..78948efc 100644 --- a/backend/src/module/searcher/searcher.py +++ b/backend/src/module/searcher/searcher.py @@ -1,4 +1,5 @@ import json +from typing import TypeAlias from module.models import Bangumi, Torrent, RSSItem from module.network import RequestContent @@ -15,6 +16,7 @@ SEARCH_KEY = [ "dpi", ] +BangumiJSON: TypeAlias = str class SearchTorrent(RequestContent, RSSAnalyser): def search_torrents( @@ -23,18 +25,14 @@ class SearchTorrent(RequestContent, RSSAnalyser): torrents = self.get_torrents(rss_item.url, limit=limit) return torrents - def analyse_keyword(self, keywords: list[str], site: str = "mikan"): + def analyse_keyword(self, keywords: list[str], site: str = "mikan") -> BangumiJSON: rss_item = search_url(site, keywords) torrents = self.search_torrents(rss_item) - # Generate a list of json - yield "[" - for idx, torrent in enumerate(torrents): + # yield for EventSourceResponse (Server Send) + for torrent in torrents: bangumi = self.torrent_to_data(torrent=torrent, rss=rss_item) if bangumi: - yield json.dumps(bangumi.dict()) - if idx != len(torrents) - 1: - yield "," - yield "]" + yield json.dumps(bangumi.dict(), separators=(',', ':')) def search_season(self, data: Bangumi): keywords = [getattr(data, key) for key in SEARCH_KEY if getattr(data, key)] diff --git a/webui/package.json b/webui/package.json index 0c5b929e..513cb9e6 100644 --- a/webui/package.json +++ b/webui/package.json @@ -24,6 +24,7 @@ "lodash": "^4.17.21", "naive-ui": "^2.34.4", "pinia": "^2.1.3", + "rxjs": "^7.8.1", "vue": "^3.3.4", "vue-i18n": "^9.2.2", "vue-inline-svg": "^3.1.2", @@ -42,9 +43,11 @@ "@storybook/vue3-vite": "^7.0.12", "@types/lodash": "^4.14.194", "@types/node": "^18.16.14", + "@unocss/preset-attributify": "^0.55.3", "@unocss/preset-rem-to-px": "^0.51.13", "@unocss/reset": "^0.51.13", "@vitejs/plugin-vue": "^4.2.0", + "@vue/runtime-dom": "^3.3.4", "eslint": "^8.41.0", "eslint-config-prettier": "^8.8.0", "eslint-plugin-storybook": "^0.6.12", diff --git a/webui/pnpm-lock.yaml b/webui/pnpm-lock.yaml index bfdf406c..86f7a2c8 100644 --- a/webui/pnpm-lock.yaml +++ b/webui/pnpm-lock.yaml @@ -23,6 +23,9 @@ dependencies: pinia: specifier: ^2.1.3 version: 2.1.3(typescript@4.9.5)(vue@3.3.4) + rxjs: + specifier: ^7.8.1 + version: 7.8.1 vue: specifier: ^3.3.4 version: 3.3.4 @@ -73,6 +76,9 @@ devDependencies: '@types/node': specifier: ^18.16.14 version: 18.16.14 + '@unocss/preset-attributify': + specifier: ^0.55.3 + version: 0.55.3 '@unocss/preset-rem-to-px': specifier: ^0.51.13 version: 0.51.13 @@ -82,6 +88,9 @@ devDependencies: '@vitejs/plugin-vue': specifier: ^4.2.0 version: 4.2.3(vite@4.3.5)(vue@3.3.4) + '@vue/runtime-dom': + specifier: ^3.3.4 + version: 3.3.4 eslint: specifier: ^8.41.0 version: 8.41.0 @@ -3549,6 +3558,10 @@ packages: resolution: {integrity: sha512-SclWkqY2c+p5+PiqrbQkhJNEExPdeo71/aGFye10tpBkgPJWd5xC7dhg5F8M4VPNBtuNCrvBWyqNnunMyuz/WQ==} dev: true + /@unocss/core@0.55.3: + resolution: {integrity: sha512-2hV9QlE/iOM4DHQ7i6L8sMC1t5/OVAz6AfGHjetTXcgbNfDCsHWqE8jhLZ1y2DeUvKwJvj2A09sYbYQ8E27+Gg==} + dev: true + /@unocss/extractor-arbitrary-variants@0.51.13: resolution: {integrity: sha512-lF7p0ea/MeNf4IsjzNhRNYP8u+f1h5JjhTzcvFpQo/vpBvuM5ZCyqp4mkXxYnLNLFfTLsc+MxXaU34IXxpw1QA==} dependencies: @@ -3582,6 +3595,12 @@ packages: '@unocss/core': 0.51.13 dev: true + /@unocss/preset-attributify@0.55.3: + resolution: {integrity: sha512-h3t6hPIk8pll3LubIIIsgRigvJivK3PX308Pi9Q0IUdw0vFq4S80iLQ1N0kRchQtgOaAIGffo9ux+TCbyunP3A==} + dependencies: + '@unocss/core': 0.55.3 + dev: true + /@unocss/preset-icons@0.51.13: resolution: {integrity: sha512-iL9s1NUVeWe3WSh5LHn7vy+veCAag9AFA50IfNlHuAARhuI8JtrMQA8dOXrWrzM0zWBMB+BVIkVaMVrF257n+Q==} dependencies: @@ -8466,6 +8485,12 @@ packages: queue-microtask: 1.2.3 dev: true + /rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + dependencies: + tslib: 2.5.2 + dev: false + /safe-buffer@5.1.1: resolution: {integrity: sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==} dev: true @@ -9104,7 +9129,6 @@ packages: /tslib@2.5.2: resolution: {integrity: sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==} - dev: true /tsutils@3.21.0(typescript@4.9.5): resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} diff --git a/webui/src/api/search.ts b/webui/src/api/search.ts index 71878c34..c86172cf 100644 --- a/webui/src/api/search.ts +++ b/webui/src/api/search.ts @@ -1,11 +1,80 @@ +import { + Observable, +} from 'rxjs'; + +import type { BangumiRule } from '#/bangumi'; + export const apiSearch = { - async get(keyword: string, site = 'mikan') { - const { data } = await axios.get('api/v1/search', { - params: { - site, - keyword, - }, + /** + * 番剧搜索接口是 Server Send 流式数据,每条是一个 Bangumi JSON 字符串, + * 使用接口方式需要订阅使用 + * + * Usage Example: + * + * ```ts + * import { + * Subject, + * tap, + * map, + * switchMap, + * debounceTime, + * } from 'rxjs'; + * + * + * const input$ = new Subject(); + * const onInput = (e: Event) => input$.next(e.target); + * + * // vue: + * + * const bangumiInfo$ = apiSearch.get('魔女之旅'); + * + * // vue: start loading animation + * + * input$.pipe( + * debounceTime(1000), + * tap((input: string) => { + * console.log('input', input) + * // clear Search Result List + * }), + * + * // switchMap 把输入 keyword 查询为 bangumiInfo$ 流,多次输入停用前一次查询 + * switchMap((input: string) => apiSearch(input, site)), + * + * tap((bangumi: BangumiRule) => console.log(bangumi)), + * tap((bangumi: BangumiRule) => { + * console.log('bangumi', bangumi) + * // set bangumi info to Search Result List + * }), + * ).subscribe({ + * complete() { + * // end of stream, stop loading animation + * }, + * }) + * ``` + */ + get(keyword: string, site = 'mikan'): Observable { + const bangumiInfo$ = new Observable(observer => { + const eventSource = new EventSource( + `api/v1/search?site=${site}&keyword=${encodeURIComponent(keyword)}`, + { withCredentials: true }, + ); + + eventSource.onmessage = ev => { + try { + const data: BangumiRule = JSON.parse(ev.data); + observer.next(data); + } catch (error) { + observer.error(error); + } + }; + + eventSource.onerror = ev => observer.error(ev); + + return () => { + eventSource.close(); + }; }); - return data!; + + return bangumiInfo$; }, }; diff --git a/webui/types/bangumi.ts b/webui/types/bangumi.ts index 881464ce..0bb31e4e 100644 --- a/webui/types/bangumi.ts +++ b/webui/types/bangumi.ts @@ -1,3 +1,6 @@ +/** + * @type `Bangumi` in backend/src/module/models/bangumi.py + */ export interface BangumiRule { added: boolean; deleted: boolean; diff --git a/webui/types/dts/html.d.ts b/webui/types/dts/html.d.ts new file mode 100644 index 00000000..ffc45d4e --- /dev/null +++ b/webui/types/dts/html.d.ts @@ -0,0 +1,9 @@ +/** + * https://unocss.dev/presets/attributify#vue-3 + */ + +import type { AttributifyAttributes } from '@unocss/preset-attributify' + +declare module '@vue/runtime-dom' { + interface HTMLAttributes extends AttributifyAttributes {} +} \ No newline at end of file