fix: 修复 bangumi search API 中的流式接口,webui 对应接口定义方法改造

- StreamingResponse 换成 EventSourceResponse,即 Server Send Event Source 方式发送数据
- webui 中 search 接口改成 rxjs Observable 方式,
  这次来不及改到 vue 里,但是写了接口 usage example;
- 顺手补了一些 vscode 通用的开发配置,
  补了之前配置文件解析漏了一个可用环境变量的 host 字段
This commit is contained in:
zthxxx
2023-08-31 23:48:07 +08:00
parent 16bbf6f866
commit 7d50e360ed
12 changed files with 177 additions and 23 deletions

18
.vscode/extensions.json vendored Normal file
View File

@@ -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"
]
}

17
.vscode/launch.json vendored Normal file
View File

@@ -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
}
]
}

View File

@@ -10,4 +10,10 @@
"editor.wordWrap": "off",
},
"python.venvPath": "./backend/venv",
"cSpell.words": [
"Bangumi",
"fastapi",
"mikan",
"starlette"
],
}

View File

@@ -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

View File

@@ -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",
)

View File

@@ -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_)

View File

@@ -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)]

View File

@@ -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",

26
webui/pnpm-lock.yaml generated
View File

@@ -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==}

View File

@@ -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<string>();
* const onInput = (e: Event) => input$.next(e.target);
*
* // vue: <input @input="onInput">
*
* 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<BangumiRule> {
const bangumiInfo$ = new Observable<BangumiRule>(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$;
},
};

View File

@@ -1,3 +1,6 @@
/**
* @type `Bangumi` in backend/src/module/models/bangumi.py
*/
export interface BangumiRule {
added: boolean;
deleted: boolean;

9
webui/types/dts/html.d.ts vendored Normal file
View File

@@ -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 {}
}