wnm168 commited on
Commit
cdd5c14
·
verified ·
1 Parent(s): 19ef80b

Upload 6 files

Browse files
main.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import asyncio
3
+ import json
4
+ import logging
5
+
6
+ import httpx
7
+
8
+ from pikpakapi import PikPakApi
9
+
10
+ from typing import Union
11
+ from fastapi import (
12
+ FastAPI,
13
+ Depends,
14
+ Request,
15
+ Body,
16
+ Response,
17
+ HTTPException,
18
+ status,
19
+ Request,
20
+ )
21
+ from fastapi.responses import StreamingResponse, HTMLResponse, JSONResponse
22
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
23
+ from fastapi.templating import Jinja2Templates
24
+ from fastapi.middleware.cors import CORSMiddleware
25
+ from pydantic import BaseModel, Extra
26
+
27
+
28
+ class PostRequest(BaseModel):
29
+ class Config:
30
+ extra = Extra.allow
31
+
32
+
33
+ security = HTTPBearer()
34
+ # SECRET_TOKEN = "SECRET_TOKEN"
35
+ SECRET_TOKEN = os.getenv("SECRET_TOKEN")
36
+ if SECRET_TOKEN is None:
37
+ raise ValueError("请在环境变量中设置SECRET_TOKEN,确保安全!")
38
+
39
+ THUNDERX_USERNAME = os.getenv("THUNDERX_USERNAME")
40
+ if THUNDERX_USERNAME is None:
41
+ raise ValueError("请在环境变量中设置THUNDERX_USERNAME,用户名【邮箱】用来登陆!")
42
+
43
+
44
+ THUNDERX_PASSWORD = os.getenv("THUNDERX_PASSWORD")
45
+ if THUNDERX_PASSWORD is None:
46
+ raise ValueError("请在环境变量中设置THUNDERX_PASSWORD,密码用来登陆!")
47
+
48
+ PROXY_URL = os.getenv("PROXY_URL")
49
+
50
+
51
+ async def verify_token(
52
+ request: Request, credentials: HTTPAuthorizationCredentials = Depends(security)
53
+ ):
54
+ # 验证Bearer格式
55
+ if credentials.scheme != "Bearer":
56
+ raise HTTPException(
57
+ status_code=status.HTTP_401_UNAUTHORIZED,
58
+ detail="Invalid authentication scheme",
59
+ )
60
+
61
+ # 验证令牌内容
62
+ if credentials.credentials != SECRET_TOKEN:
63
+ raise HTTPException(
64
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token"
65
+ )
66
+
67
+
68
+ app = FastAPI(dependencies=[Depends(verify_token)])
69
+ app.add_middleware(
70
+ CORSMiddleware,
71
+ allow_origins=["*"],
72
+ allow_credentials=True,
73
+ allow_methods=["*"],
74
+ allow_headers=["*"],
75
+ )
76
+
77
+ templates = Jinja2Templates(
78
+ directory="templates", variable_start_string="{[", variable_end_string="]}"
79
+ )
80
+
81
+ THUNDERX_CLIENT = None
82
+
83
+
84
+ async def log_token(THUNDERX_CLIENT, extra_data):
85
+ logging.info(f"Token: {THUNDERX_CLIENT.encoded_token}, Extra Data: {extra_data}")
86
+
87
+
88
+ @app.on_event("startup")
89
+ async def init_client():
90
+ global THUNDERX_CLIENT
91
+ if not os.path.exists("thunderx.txt"):
92
+ THUNDERX_CLIENT = PikPakApi(
93
+ username=THUNDERX_USERNAME,
94
+ password=THUNDERX_PASSWORD,
95
+ httpx_client_args=None,
96
+ token_refresh_callback=log_token,
97
+ token_refresh_callback_kwargs={"extra_data": "test"},
98
+ )
99
+ await THUNDERX_CLIENT.login()
100
+ await THUNDERX_CLIENT.refresh_access_token()
101
+ with open("thunderx.json", "w") as f:
102
+ f.write(json.dumps(THUNDERX_CLIENT.to_dict(), indent=4))
103
+ else:
104
+ with open("thunderx.txt", "r") as f:
105
+ data = json.load(f)
106
+ THUNDERX_CLIENT = PikPakApi.from_dict(data)
107
+ # await client.refresh_access_token()
108
+ print(json.dumps(THUNDERX_CLIENT.get_user_info(), indent=4))
109
+
110
+ print(
111
+ json.dumps(
112
+ await THUNDERX_CLIENT.events(),
113
+ indent=4,
114
+ )
115
+ )
116
+
117
+
118
+ @app.get("/", response_class=HTMLResponse)
119
+ async def home(request: Request):
120
+ return templates.TemplateResponse("index.html", {"request": request})
121
+
122
+
123
+ @app.get("/files")
124
+ async def get_files(kw: str = ""):
125
+ return await THUNDERX_CLIENT.file_list()
126
+
127
+
128
+ @app.get("/userinfo")
129
+ async def userinfo():
130
+ return THUNDERX_CLIENT.get_user_info()
pikpakapi/PikpakException.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ class PikpakException(Exception):
2
+ def __init__(self, message):
3
+ super().__init__(message)
4
+
5
+
6
+ class PikpakRetryException(PikpakException):
7
+ pass
pikpakapi/__init__.py ADDED
@@ -0,0 +1,1062 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import binascii
3
+ import inspect
4
+ import json
5
+ import logging
6
+ import re
7
+ from base64 import b64decode, b64encode
8
+ from hashlib import md5
9
+ from types import NoneType
10
+ from typing import Any, Dict, List, Optional, Callable, Coroutine
11
+ from urllib.parse import urljoin
12
+
13
+ import httpx
14
+
15
+ from .PikpakException import PikpakException, PikpakRetryException
16
+ from .enums import DownloadStatus
17
+ from .utils import (
18
+ CLIENT_ID,
19
+ CLIENT_SECRET,
20
+ CLIENT_VERSION,
21
+ PACKAG_ENAME,
22
+ build_custom_user_agent,
23
+ captcha_sign,
24
+ get_timestamp,
25
+ )
26
+
27
+
28
+ class PikPakApi:
29
+ """
30
+ PikPakApi class
31
+
32
+ Attributes:
33
+ PIKPAK_API_HOST: str - PikPak API host
34
+ PIKPAK_USER_HOST: str - PikPak user API host
35
+
36
+ username: str - username of the user
37
+ password: str - password of the user
38
+ encoded_token: str - encoded token of the user with access and refresh tokens
39
+ access_token: str - access token of the user , expire in 7200
40
+ refresh_token: str - refresh token of the user
41
+ user_id: str - user id of the user
42
+ token_refresh_callback: Callable[[PikPakApi, **Any], Coroutine[Any, Any, None]] - async callback function to be called after token refresh
43
+ token_refresh_callback_kwargs: Dict[str, Any] - custom arguments to be passed to the token refresh callback
44
+ """
45
+
46
+ PIKPAK_API_HOST = "api-pan.xunleix.com"
47
+ PIKPAK_USER_HOST = "xluser-ssl.xunleix.com"
48
+
49
+ def __init__(
50
+ self,
51
+ username: Optional[str] = None,
52
+ password: Optional[str] = None,
53
+ encoded_token: Optional[str] = None,
54
+ httpx_client_args: Optional[Dict[str, Any]] = None,
55
+ device_id: Optional[str] = None,
56
+ request_max_retries: int = 3,
57
+ request_initial_backoff: float = 3.0,
58
+ token_refresh_callback: Optional[Callable] = None,
59
+ token_refresh_callback_kwargs: Optional[Dict[str, Any]] = None,
60
+ ):
61
+ """
62
+ username: str - username of the user
63
+ password: str - password of the user
64
+ encoded_token: str - encoded token of the user with access and refresh token
65
+ httpx_client_args: dict - extra arguments for httpx.AsyncClient (https://www.python-httpx.org/api/#asyncclient)
66
+ device_id: str - device id to identify the device
67
+ request_max_retries: int - maximum number of retries for requests
68
+ request_initial_backoff: float - initial backoff time for retries
69
+ token_refresh_callback: Callable[[PikPakApi, **Any], Coroutine[Any, Any, None]] - async callback function to be called after token refresh
70
+ token_refresh_callback_kwargs: Dict[str, Any] - custom arguments to be passed to the token refresh callback
71
+ """
72
+
73
+ self.username = username
74
+ self.password = password
75
+ self.encoded_token = encoded_token
76
+ self.max_retries = request_max_retries
77
+ self.initial_backoff = request_initial_backoff
78
+ self.token_refresh_callback = token_refresh_callback
79
+ self.token_refresh_callback_kwargs = token_refresh_callback_kwargs or {}
80
+
81
+ self.access_token = None
82
+ self.refresh_token = None
83
+ self.user_id = None
84
+
85
+ self.data_response = None
86
+
87
+ # device_id is used to identify the device, if not provided, a random device_id will be generated, 32 characters
88
+ self.device_id = (
89
+ device_id
90
+ if device_id
91
+ else md5(f"{self.username}{self.password}".encode()).hexdigest()
92
+ )
93
+ self.captcha_token = None
94
+
95
+ httpx_client_args = httpx_client_args or {"timeout": 10}
96
+ self.httpx_client = httpx.AsyncClient(**httpx_client_args)
97
+
98
+ self._path_id_cache: Dict[str, Any] = {}
99
+
100
+ self.user_agent: Optional[str] = None
101
+
102
+ if self.encoded_token:
103
+ self.decode_token()
104
+ elif self.username and self.password:
105
+ pass
106
+ else:
107
+ raise PikpakException("username and password or encoded_token is required")
108
+
109
+ @classmethod
110
+ def from_dict(cls, data: Dict[str, Any]) -> "PikPakApi":
111
+ """
112
+ Create PikPakApi object from a dictionary
113
+ """
114
+ params = inspect.signature(cls).parameters
115
+ filtered_data = {key: data[key] for key in params if key in data}
116
+ client = cls(
117
+ **filtered_data,
118
+ )
119
+ client.__dict__.update(data)
120
+ return client
121
+
122
+ def to_dict(self) -> Dict[str, Any]:
123
+ """
124
+ Returns the PikPakApi object as a dictionary
125
+ """
126
+ data = self.__dict__.copy()
127
+ # remove can't be serialized attributes
128
+ keys_to_delete = [
129
+ k
130
+ for k, v in data.items()
131
+ if not type(v) in [str, int, float, bool, list, dict, NoneType]
132
+ ]
133
+ for k in keys_to_delete:
134
+ del data[k]
135
+ return data
136
+
137
+ def build_custom_user_agent(self) -> str:
138
+
139
+ self.user_agent = build_custom_user_agent(
140
+ device_id=self.device_id,
141
+ user_id=self.user_id if self.user_id else "",
142
+ )
143
+ return self.user_agent
144
+
145
+ def get_headers(self, access_token: Optional[str] = None) -> Dict[str, str]:
146
+ """
147
+ Returns the headers to use for the requests.
148
+ """
149
+ headers = {
150
+ "User-Agent": (
151
+ self.build_custom_user_agent()
152
+ if self.captcha_token
153
+ else "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
154
+ ),
155
+ "Content-Type": "application/json; charset=utf-8",
156
+ }
157
+
158
+ if self.access_token:
159
+ headers["Authorization"] = f"Bearer {self.access_token}"
160
+ if access_token:
161
+ headers["Authorization"] = f"Bearer {access_token}"
162
+ if self.captcha_token:
163
+ headers["X-Captcha-Token"] = self.captcha_token
164
+ if self.device_id:
165
+ headers["X-Device-Id"] = self.device_id
166
+ return headers
167
+
168
+ async def _make_request(
169
+ self,
170
+ method: str,
171
+ url: str,
172
+ data: Optional[Dict[str, Any]] = None,
173
+ params: Optional[Dict[str, Any]] = None,
174
+ headers: Optional[Dict[str, str]] = None,
175
+ ) -> Dict[str, Any]:
176
+ last_error = None
177
+
178
+ # url = "{proxy}{target}".format(proxy="https://pikpak.tjsky.top/", target=url)
179
+ # print(url)
180
+
181
+ for attempt in range(self.max_retries):
182
+ try:
183
+ response = await self._send_request(method, url, data, params, headers)
184
+ return await self._handle_response(response)
185
+ except PikpakRetryException as error:
186
+ logging.info(f"Retry attempt {attempt + 1}/{self.max_retries}")
187
+ last_error = error
188
+ except PikpakException:
189
+ raise
190
+ except httpx.HTTPError as error:
191
+ logging.error(
192
+ f"HTTP Error on attempt {attempt + 1}/{self.max_retries}: {str(error)}"
193
+ )
194
+ last_error = error
195
+ except Exception as error:
196
+ logging.error(
197
+ f"Unexpected error on attempt {attempt + 1}/{self.max_retries}: {str(error)}"
198
+ )
199
+ last_error = error
200
+
201
+ await asyncio.sleep(self.initial_backoff * (2**attempt))
202
+
203
+ # If we've exhausted all retries, raise an exception with the last error
204
+ raise PikpakException(f"Max retries reached. Last error: {str(last_error)}")
205
+
206
+ async def _send_request(self, method, url, data, params, headers):
207
+ req_headers = headers or self.get_headers()
208
+ return await self.httpx_client.request(
209
+ method,
210
+ url,
211
+ json=data,
212
+ params=params,
213
+ headers=req_headers,
214
+ )
215
+
216
+ async def _handle_response(self, response) -> Dict[str, Any]:
217
+ try:
218
+ json_data = response.json()
219
+ except ValueError:
220
+ if response.status_code == 200:
221
+ return {}
222
+ raise PikpakRetryException("Empty JSON data")
223
+
224
+ self.data_response = json_data
225
+
226
+ if not json_data:
227
+ if response.status_code == 200:
228
+ return {}
229
+ raise PikpakRetryException("Empty JSON data")
230
+
231
+ if "error" not in json_data:
232
+ return json_data
233
+
234
+ if "captcha_token" in json_data:
235
+ self.captcha_token = json_data["captcha_token"]
236
+
237
+ if json_data["error"] == "invalid_account_or_password":
238
+ raise PikpakException("Invalid username or password")
239
+
240
+ if json_data.get("error_code") == 16:
241
+ await self.refresh_access_token()
242
+ raise PikpakRetryException("Token refreshed, please retry")
243
+
244
+ raise PikpakException(json_data.get("error_description", "Unknown Error"))
245
+
246
+ async def _request_get(
247
+ self,
248
+ url: str,
249
+ params: dict = None,
250
+ ):
251
+ return await self._make_request("get", url, params=params)
252
+
253
+ async def _request_post(
254
+ self,
255
+ url: str,
256
+ data: dict = None,
257
+ headers: dict = None,
258
+ ):
259
+ return await self._make_request("post", url, data=data, headers=headers)
260
+
261
+ async def _request_patch(
262
+ self,
263
+ url: str,
264
+ data: dict = None,
265
+ ):
266
+ return await self._make_request("patch", url, data=data)
267
+
268
+ async def _request_delete(
269
+ self,
270
+ url: str,
271
+ params: dict = None,
272
+ data: dict = None,
273
+ ):
274
+ return await self._make_request("delete", url, params=params, data=data)
275
+
276
+ def decode_token(self):
277
+ """Decodes the encoded token to update access and refresh tokens."""
278
+ try:
279
+ decoded_data = json.loads(b64decode(self.encoded_token).decode())
280
+ except (binascii.Error, json.JSONDecodeError):
281
+ raise PikpakException("Invalid encoded token")
282
+ if not decoded_data.get("access_token") or not decoded_data.get(
283
+ "refresh_token"
284
+ ):
285
+ raise PikpakException("Invalid encoded token")
286
+ self.access_token = decoded_data.get("access_token")
287
+ self.refresh_token = decoded_data.get("refresh_token")
288
+
289
+ def encode_token(self):
290
+ """Encodes the access and refresh tokens into a single string."""
291
+ token_data = {
292
+ "access_token": self.access_token,
293
+ "refresh_token": self.refresh_token,
294
+ }
295
+ self.encoded_token = b64encode(json.dumps(token_data).encode()).decode()
296
+
297
+ async def captcha_init(self, action: str, meta: dict = None) -> Dict[str, Any]:
298
+ url = f"https://{PikPakApi.PIKPAK_USER_HOST}/v1/shield/captcha/init"
299
+ if not meta:
300
+ t = f"{get_timestamp()}"
301
+ meta = {
302
+ "captcha_sign": captcha_sign(self.device_id, t),
303
+ "client_version": CLIENT_VERSION,
304
+ "package_name": PACKAG_ENAME,
305
+ "user_id": self.user_id,
306
+ "timestamp": t,
307
+ }
308
+ params = {
309
+ "client_id": CLIENT_ID,
310
+ "action": action,
311
+ "device_id": self.device_id,
312
+ "meta": meta,
313
+ }
314
+ return await self._request_post(url, data=params)
315
+
316
+ async def login(self) -> None:
317
+ """
318
+ Login to PikPak
319
+ """
320
+ login_url = f"https://{PikPakApi.PIKPAK_USER_HOST}/v1/auth/signin"
321
+ metas = {}
322
+ if not self.username or not self.password:
323
+ raise PikpakException("username and password are required")
324
+ if re.match(r"\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*", self.username):
325
+ metas["email"] = self.username
326
+ elif re.match(r"\d{11,18}", self.username):
327
+ metas["phone_number"] = self.username
328
+ else:
329
+ metas["username"] = self.username
330
+ result = await self.captcha_init(
331
+ action=f"POST:{login_url}",
332
+ meta=metas,
333
+ )
334
+ captcha_token = result.get("captcha_token", "")
335
+ if not captcha_token:
336
+ raise PikpakException("captcha_token get failed")
337
+
338
+ login_data = {
339
+ "client_id": CLIENT_ID,
340
+ "client_secret": CLIENT_SECRET,
341
+ "password": self.password,
342
+ "username": self.username,
343
+ "captcha_token": captcha_token,
344
+ }
345
+ user_info = await self._request_post(
346
+ login_url,
347
+ login_data,
348
+ {
349
+ "Content-Type": "application/x-www-form-urlencoded",
350
+ },
351
+ )
352
+ self.access_token = user_info["access_token"]
353
+ self.refresh_token = user_info["refresh_token"]
354
+ self.user_id = user_info["sub"]
355
+ self.encode_token()
356
+
357
+ async def refresh_access_token(self) -> None:
358
+ """
359
+ Refresh access token
360
+ """
361
+ refresh_url = f"https://{self.PIKPAK_USER_HOST}/v1/auth/token"
362
+ refresh_data = {
363
+ "client_id": CLIENT_ID,
364
+ "refresh_token": self.refresh_token,
365
+ "grant_type": "refresh_token",
366
+ }
367
+ user_info = await self._request_post(refresh_url, refresh_data)
368
+
369
+ self.access_token = user_info["access_token"]
370
+ self.refresh_token = user_info["refresh_token"]
371
+ self.user_id = user_info["sub"]
372
+ self.encode_token()
373
+ if self.token_refresh_callback:
374
+ await self.token_refresh_callback(
375
+ self, **self.token_refresh_callback_kwargs
376
+ )
377
+
378
+ def get_user_info(self) -> Dict[str, Optional[str]]:
379
+ """
380
+ Get user info
381
+ """
382
+ return {
383
+ "username": self.username,
384
+ "user_id": self.user_id,
385
+ "access_token": self.access_token,
386
+ "refresh_token": self.refresh_token,
387
+ "encoded_token": self.encoded_token,
388
+ }
389
+
390
+ async def create_folder(
391
+ self, name: str = "新建文件夹", parent_id: Optional[str] = None
392
+ ) -> Dict[str, Any]:
393
+ """
394
+ name: str - 文件夹名称
395
+ parent_id: str - 父文件夹id, 默认创建到根目录
396
+
397
+ 创建文件夹
398
+ """
399
+ url = f"https://{self.PIKPAK_API_HOST}/drive/v1/files"
400
+ data = {
401
+ "kind": "drive#folder",
402
+ "name": name,
403
+ "parent_id": parent_id,
404
+ }
405
+ captcha_result = await self.captcha_init(f"GET:/drive/v1/files")
406
+ self.captcha_token = captcha_result.get("captcha_token")
407
+ result = await self._request_post(url, data)
408
+ return result
409
+
410
+ async def delete_to_trash(self, ids: List[str]) -> Dict[str, Any]:
411
+ """
412
+ ids: List[str] - 文件夹、文件id列表
413
+
414
+ 将文件夹、文件移动到回收站
415
+ """
416
+ url = f"https://{self.PIKPAK_API_HOST}/drive/v1/files:batchTrash"
417
+ data = {
418
+ "ids": ids,
419
+ }
420
+ captcha_result = await self.captcha_init(f"GET:/drive/v1/files:batchTrash")
421
+ self.captcha_token = captcha_result.get("captcha_token")
422
+ result = await self._request_post(url, data)
423
+ return result
424
+
425
+ async def untrash(self, ids: List[str]) -> Dict[str, Any]:
426
+ """
427
+ ids: List[str] - 文件夹、文件id列表
428
+
429
+ 将文件夹、文件移出回收站
430
+ """
431
+ url = f"https://{self.PIKPAK_API_HOST}/drive/v1/files:batchUntrash"
432
+ data = {
433
+ "ids": ids,
434
+ }
435
+ captcha_result = await self.captcha_init(f"GET:/drive/v1/files:batchUntrash")
436
+ self.captcha_token = captcha_result.get("captcha_token")
437
+ result = await self._request_post(url, data)
438
+ return result
439
+
440
+ async def delete_forever(self, ids: List[str]) -> Dict[str, Any]:
441
+ """
442
+ ids: List[str] - 文件夹、文件id列表
443
+
444
+ 永远删除文件夹、文件, 慎用
445
+ """
446
+ url = f"https://{self.PIKPAK_API_HOST}/drive/v1/files:batchDelete"
447
+ data = {
448
+ "ids": ids,
449
+ }
450
+ captcha_result = await self.captcha_init(f"GET:/drive/v1/files:batchDelete")
451
+ self.captcha_token = captcha_result.get("captcha_token")
452
+ result = await self._request_post(url, data)
453
+ return result
454
+
455
+ async def offline_download(
456
+ self, file_url: str, parent_id: Optional[str] = None, name: Optional[str] = None
457
+ ) -> Dict[str, Any]:
458
+ """
459
+ file_url: str - 文件链接
460
+ parent_id: str - 父文件夹id, 不传默认存储到 My Pack
461
+ name: str - 文件名, 不传默认为文件链接的文件名
462
+
463
+ 离线下载磁力链
464
+ """
465
+ download_url = f"https://{self.PIKPAK_API_HOST}/drive/v1/files"
466
+ download_data = {
467
+ "kind": "drive#file",
468
+ "name": name,
469
+ "upload_type": "UPLOAD_TYPE_URL",
470
+ "url": {"url": file_url},
471
+ "folder_type": "DOWNLOAD" if not parent_id else "",
472
+ "parent_id": parent_id,
473
+ }
474
+ captcha_result = await self.captcha_init(f"GET:/drive/v1/files")
475
+ self.captcha_token = captcha_result.get("captcha_token")
476
+ result = await self._request_post(download_url, download_data)
477
+ return result
478
+
479
+ async def offline_list(
480
+ self,
481
+ size: int = 10000,
482
+ next_page_token: Optional[str] = None,
483
+ phase: Optional[List[str]] = None,
484
+ ) -> Dict[str, Any]:
485
+ """
486
+ size: int - 每次请求的数量
487
+ next_page_token: str - 下一页的page token
488
+ phase: List[str] - Offline download task status, default is ["PHASE_TYPE_RUNNING", "PHASE_TYPE_ERROR"]
489
+ supported values: PHASE_TYPE_RUNNING, PHASE_TYPE_ERROR, PHASE_TYPE_COMPLETE, PHASE_TYPE_PENDING
490
+
491
+ 获取离线下载列表
492
+ """
493
+ if phase is None:
494
+ phase = ["PHASE_TYPE_RUNNING", "PHASE_TYPE_ERROR"]
495
+ list_url = f"https://{self.PIKPAK_API_HOST}/drive/v1/tasks"
496
+ list_data = {
497
+ "type": "offline",
498
+ "thumbnail_size": "SIZE_SMALL",
499
+ "limit": size,
500
+ "page_token": next_page_token,
501
+ "filters": json.dumps({"phase": {"in": ",".join(phase)}}),
502
+ "with": "reference_resource",
503
+ }
504
+ captcha_result = await self.captcha_init("GET:/drive/v1/tasks")
505
+ self.captcha_token = captcha_result.get("captcha_token")
506
+ result = await self._request_get(list_url, list_data)
507
+ return result
508
+
509
+ async def offline_file_info(self, file_id: str) -> Dict[str, Any]:
510
+ """
511
+ file_id: str - 离线下载文件id
512
+
513
+ 离线下载文件信息
514
+ """
515
+ captcha_result = await self.captcha_init(f"GET:/drive/v1/files/{file_id}")
516
+ self.captcha_token = captcha_result.get("captcha_token")
517
+ url = f"https://{self.PIKPAK_API_HOST}/drive/v1/files/{file_id}"
518
+ result = await self._request_get(url, {"thumbnail_size": "SIZE_LARGE"})
519
+ return result
520
+
521
+ async def file_list(
522
+ self,
523
+ size: int = 100,
524
+ parent_id: Optional[str] = None,
525
+ next_page_token: Optional[str] = None,
526
+ additional_filters: Optional[Dict[str, Any]] = None,
527
+ ) -> Dict[str, Any]:
528
+ """
529
+ size: int - 每次请求的数量
530
+ parent_id: str - 父文件夹id, 默认列出根目录
531
+ next_page_token: str - 下一页的page token
532
+ additional_filters: Dict[str, Any] - 额外的过滤条件
533
+
534
+ 获取文件列表,可以获得文件下载链接
535
+ """
536
+ default_filters = {
537
+ "trashed": {"eq": False},
538
+ "phase": {"eq": "PHASE_TYPE_COMPLETE"},
539
+ }
540
+ if additional_filters:
541
+ default_filters.update(additional_filters)
542
+ list_url = f"https://{self.PIKPAK_API_HOST}/drive/v1/files"
543
+ list_data = {
544
+ "parent_id": parent_id,
545
+ "thumbnail_size": "SIZE_MEDIUM",
546
+ "limit": size,
547
+ "with_audit": "true",
548
+ "page_token": next_page_token,
549
+ "filters": json.dumps(default_filters),
550
+ }
551
+
552
+ captcha_result = await self.captcha_init("GET:/drive/v1/files")
553
+ self.captcha_token = captcha_result.get("captcha_token")
554
+ result = await self._request_get(list_url, list_data)
555
+ return result
556
+
557
+ async def events(
558
+ self, size: int = 100, next_page_token: Optional[str] = None
559
+ ) -> Dict[str, Any]:
560
+ """
561
+ size: int - 每次请求的数量
562
+ next_page_token: str - 下一页的page token
563
+
564
+ 获取最近添加事件列表
565
+ """
566
+ list_url = f"https://{self.PIKPAK_API_HOST}/drive/v1/events"
567
+ list_data = {
568
+ "thumbnail_size": "SIZE_MEDIUM",
569
+ "limit": size,
570
+ "next_page_token": next_page_token,
571
+ }
572
+ captcha_result = await self.captcha_init("GET:/drive/v1/files")
573
+ self.captcha_token = captcha_result.get("captcha_token")
574
+ result = await self._request_get(list_url, list_data)
575
+ return result
576
+
577
+ async def offline_task_retry(self, task_id: str) -> Dict[str, Any]:
578
+ """
579
+ task_id: str - 离线下载任务id
580
+
581
+ 重试离线下载任务
582
+ """
583
+ list_url = f"https://{self.PIKPAK_API_HOST}/drive/v1/task"
584
+ list_data = {
585
+ "type": "offline",
586
+ "create_type": "RETRY",
587
+ "id": task_id,
588
+ }
589
+ try:
590
+ captcha_result = await self.captcha_init("GET:/drive/v1/task")
591
+ self.captcha_token = captcha_result.get("captcha_token")
592
+ result = await self._request_post(list_url, list_data)
593
+ return result
594
+ except Exception as e:
595
+ raise PikpakException(f"重试离线下载任务失败: {task_id}. {e}")
596
+
597
+ async def delete_tasks(
598
+ self, task_ids: List[str], delete_files: bool = False
599
+ ) -> None:
600
+ """
601
+ delete tasks by task ids
602
+ task_ids: List[str] - task ids to delete
603
+ """
604
+ delete_url = f"https://{self.PIKPAK_API_HOST}/drive/v1/tasks"
605
+ params = {
606
+ "task_ids": task_ids,
607
+ "delete_files": delete_files,
608
+ }
609
+ try:
610
+ captcha_result = await self.captcha_init("GET:/drive/v1/tasks")
611
+ self.captcha_token = captcha_result.get("captcha_token")
612
+ await self._request_delete(delete_url, params=params)
613
+ except Exception as e:
614
+ raise PikpakException(f"Failing to delete tasks: {task_ids}. {e}")
615
+
616
+ async def get_task_status(self, task_id: str, file_id: str) -> DownloadStatus:
617
+ """
618
+ task_id: str - 离线下载任务id
619
+ file_id: str - 离线下载文件id
620
+
621
+ 获取离线下载任务状态, 临时实现, 后期可能变更
622
+ """
623
+ try:
624
+ infos = await self.offline_list()
625
+ if infos and infos.get("tasks", []):
626
+ for task in infos.get("tasks", []):
627
+ if task_id == task.get("id"):
628
+ return DownloadStatus.downloading
629
+ file_info = await self.offline_file_info(file_id=file_id)
630
+ if file_info:
631
+ return DownloadStatus.done
632
+ else:
633
+ return DownloadStatus.not_found
634
+ except PikpakException:
635
+ return DownloadStatus.error
636
+
637
+ async def path_to_id(self, path: str, create: bool = False) -> List[Dict[str, str]]:
638
+ """
639
+ path: str - 路径
640
+ create: bool - 是否创建不存在的文件夹
641
+
642
+ 将形如 /path/a/b 的路径转换为 文件夹的id
643
+ """
644
+ if not path or len(path) <= 0:
645
+ return []
646
+ paths = path.split("/")
647
+ paths = [p.strip() for p in paths if len(p) > 0]
648
+ # 构造不同级别的path表达式,尝试找到距离目标最近的那一层
649
+ multi_level_paths = ["/" + "/".join(paths[: i + 1]) for i in range(len(paths))]
650
+ path_ids = [
651
+ self._path_id_cache[p]
652
+ for p in multi_level_paths
653
+ if p in self._path_id_cache
654
+ ]
655
+ # 判断缓存命中情况
656
+ hit_cnt = len(path_ids)
657
+ if hit_cnt == len(paths):
658
+ return path_ids
659
+ elif hit_cnt == 0:
660
+ count = 0
661
+ parent_id = None
662
+ else:
663
+ count = hit_cnt
664
+ parent_id = path_ids[-1]["id"]
665
+
666
+ next_page_token = None
667
+ while count < len(paths):
668
+ data = await self.file_list(
669
+ parent_id=parent_id, next_page_token=next_page_token
670
+ )
671
+ record_of_target_path = None
672
+ for f in data.get("files", []):
673
+ current_path = "/" + "/".join(paths[:count] + [f.get("name")])
674
+ file_type = (
675
+ "folder" if f.get("kind", "").find("folder") != -1 else "file"
676
+ )
677
+ record = {
678
+ "id": f.get("id"),
679
+ "name": f.get("name"),
680
+ "file_type": file_type,
681
+ }
682
+ self._path_id_cache[current_path] = record
683
+ if f.get("name") == paths[count]:
684
+ record_of_target_path = record
685
+ # 不break: 剩下的文���也同样缓存起来
686
+ if record_of_target_path is not None:
687
+ path_ids.append(record_of_target_path)
688
+ count += 1
689
+ parent_id = record_of_target_path["id"]
690
+ elif data.get("next_page_token") and (
691
+ not next_page_token or next_page_token != data.get("next_page_token")
692
+ ):
693
+ next_page_token = data.get("next_page_token")
694
+ elif create:
695
+ data = await self.create_folder(name=paths[count], parent_id=parent_id)
696
+ file_id = data.get("file").get("id")
697
+ record = {
698
+ "id": file_id,
699
+ "name": paths[count],
700
+ "file_type": "folder",
701
+ }
702
+ path_ids.append(record)
703
+ current_path = "/" + "/".join(paths[: count + 1])
704
+ self._path_id_cache[current_path] = record
705
+ count += 1
706
+ parent_id = file_id
707
+ else:
708
+ break
709
+ return path_ids
710
+
711
+ async def file_batch_move(
712
+ self,
713
+ ids: List[str],
714
+ to_parent_id: Optional[str] = None,
715
+ ) -> Dict[str, Any]:
716
+ """
717
+ ids: List[str] - 文件id列表
718
+ to_parent_id: str - 移动到的文件夹id, 默认为根目录
719
+
720
+ 批量移动文件
721
+ """
722
+ to = (
723
+ {
724
+ "parent_id": to_parent_id,
725
+ }
726
+ if to_parent_id
727
+ else {}
728
+ )
729
+ captcha_result = await self.captcha_init("GET:/drive/v1/files:batchMove")
730
+ self.captcha_token = captcha_result.get("captcha_token")
731
+ result = await self._request_post(
732
+ url=f"https://{self.PIKPAK_API_HOST}/drive/v1/files:batchMove",
733
+ data={
734
+ "ids": ids,
735
+ "to": to,
736
+ },
737
+ )
738
+ return result
739
+
740
+ async def file_batch_copy(
741
+ self,
742
+ ids: List[str],
743
+ to_parent_id: Optional[str] = None,
744
+ ) -> Dict[str, Any]:
745
+ """
746
+ ids: List[str] - 文件id列表
747
+ to_parent_id: str - 复制到的文件夹id, 默认为根目录
748
+
749
+ 批量复制文件
750
+ """
751
+ to = (
752
+ {
753
+ "parent_id": to_parent_id,
754
+ }
755
+ if to_parent_id
756
+ else {}
757
+ )
758
+ captcha_result = await self.captcha_init("GET:/drive/v1/files:batchCopy")
759
+ self.captcha_token = captcha_result.get("captcha_token")
760
+ result = await self._request_post(
761
+ url=f"https://{self.PIKPAK_API_HOST}/drive/v1/files:batchCopy",
762
+ data={
763
+ "ids": ids,
764
+ "to": to,
765
+ },
766
+ )
767
+ return result
768
+
769
+ async def file_move_or_copy_by_path(
770
+ self,
771
+ from_path: List[str],
772
+ to_path: str,
773
+ move: bool = False,
774
+ create: bool = False,
775
+ ) -> Dict[str, Any]:
776
+ """
777
+ from_path: List[str] - 要移动或复制的文件路径列表
778
+ to_path: str - 移动或复制到的路径
779
+ is_move: bool - 是否移动, 默认为复制
780
+ create: bool - 是否创建不存在的文件夹
781
+
782
+ 根据路径移动或复制文件
783
+ """
784
+ from_ids: List[str] = []
785
+ for path in from_path:
786
+ if path_ids := await self.path_to_id(path):
787
+ if file_id := path_ids[-1].get("id"):
788
+ from_ids.append(file_id)
789
+ if not from_ids:
790
+ raise PikpakException("要移动的文件不存在")
791
+ to_path_ids = await self.path_to_id(to_path, create=create)
792
+ if to_path_ids:
793
+ to_parent_id = to_path_ids[-1].get("id")
794
+ else:
795
+ to_parent_id = None
796
+ if move:
797
+ result = await self.file_batch_move(ids=from_ids, to_parent_id=to_parent_id)
798
+ else:
799
+ result = await self.file_batch_copy(ids=from_ids, to_parent_id=to_parent_id)
800
+ return result
801
+
802
+ async def get_download_url(self, file_id: str) -> Dict[str, Any]:
803
+ """
804
+ id: str - 文件id
805
+
806
+ Returns the file details data.
807
+ 1. Use `medias[0][link][url]` for streaming with high speed in streaming services or tools.
808
+ 2. Use `web_content_link` to download the file
809
+ """
810
+ result = await self.captcha_init(
811
+ action=f"GET:/drive/v1/files/{file_id}",
812
+ )
813
+ self.captcha_token = result.get("captcha_token")
814
+ result = await self._request_get(
815
+ url=f"https://{self.PIKPAK_API_HOST}/drive/v1/files/{file_id}?",
816
+ )
817
+ self.captcha_token = None
818
+ return result
819
+
820
+ async def file_rename(self, id: str, new_file_name: str) -> Dict[str, Any]:
821
+ """
822
+ id: str - 文件id
823
+ new_file_name: str - 新的文件名
824
+
825
+ 重命名文件
826
+ 返回文件的详细信息
827
+ """
828
+ data = {
829
+ "name": new_file_name,
830
+ }
831
+ captcha_result = await self.captcha_init(f"GET:/drive/v1/files/{id}")
832
+ self.captcha_token = captcha_result.get("captcha_token")
833
+ result = await self._request_patch(
834
+ url=f"https://{self.PIKPAK_API_HOST}/drive/v1/files/{id}",
835
+ data=data,
836
+ )
837
+ return result
838
+
839
+ async def file_batch_star(
840
+ self,
841
+ ids: List[str],
842
+ ) -> Dict[str, Any]:
843
+ """
844
+ ids: List[str] - 文件id列表
845
+
846
+ 批量给文件加星标
847
+ """
848
+ data = {
849
+ "ids": ids,
850
+ }
851
+ captcha_result = await self.captcha_init(f"GET:/drive/v1/files/star")
852
+ self.captcha_token = captcha_result.get("captcha_token")
853
+ result = await self._request_post(
854
+ url=f"https://{self.PIKPAK_API_HOST}/drive/v1/files:star",
855
+ data=data,
856
+ )
857
+ return result
858
+
859
+ async def file_batch_unstar(
860
+ self,
861
+ ids: List[str],
862
+ ) -> Dict[str, Any]:
863
+ """
864
+ ids: List[str] - 文件id列表
865
+
866
+ 批量给文件取消星标
867
+ """
868
+ data = {
869
+ "ids": ids,
870
+ }
871
+ captcha_result = await self.captcha_init(f"GET:/drive/v1/files/unstar")
872
+ self.captcha_token = captcha_result.get("captcha_token")
873
+ result = await self._request_post(
874
+ url=f"https://{self.PIKPAK_API_HOST}/drive/v1/files:unstar",
875
+ data=data,
876
+ )
877
+ return result
878
+
879
+ async def file_star_list(
880
+ self,
881
+ size: int = 100,
882
+ next_page_token: Optional[str] = None,
883
+ ) -> Dict[str, Any]:
884
+ """
885
+ size: int - 每次请求的数量
886
+ next_page_token: str - 下一页的page token
887
+
888
+ 获取加星标的文件列表,可以获得文件下载链接
889
+ parent_id只可取默认值*,子目录列表通过获取星标目录以后自行使用file_list方法获取
890
+ """
891
+ additional_filters = {"system_tag": {"in": "STAR"}}
892
+ result = await self.file_list(
893
+ size=size,
894
+ parent_id="*",
895
+ next_page_token=next_page_token,
896
+ additional_filters=additional_filters,
897
+ )
898
+ return result
899
+
900
+ async def file_batch_share(
901
+ self,
902
+ ids: List[str],
903
+ need_password: Optional[bool] = False,
904
+ expiration_days: Optional[int] = -1,
905
+ ) -> Dict[str, Any]:
906
+ """
907
+ ids: List[str] - 文件id列表
908
+ need_password: Optional[bool] - 是否需要分享密码
909
+ expiration_days: Optional[int] - 分享天数
910
+
911
+ 批量分享文件,并生成分享链接
912
+ 返回数据结构:
913
+ {
914
+ "share_id": "xxx", //分享ID
915
+ "share_url": "https://mypikpak.com/s/xxx", // 分享链接
916
+ "pass_code": "53fe", // 分享密码
917
+ "share_text": "https://mypikpak.com/s/xxx",
918
+ "share_list": []
919
+ }
920
+ """
921
+ data = {
922
+ "file_ids": ids,
923
+ "share_to": "encryptedlink" if need_password else "publiclink",
924
+ "expiration_days": expiration_days,
925
+ "pass_code_option": "REQUIRED" if need_password else "NOT_REQUIRED",
926
+ }
927
+ captcha_result = await self.captcha_init(f"GET:/drive/v1/share")
928
+ self.captcha_token = captcha_result.get("captcha_token")
929
+ result = await self._request_post(
930
+ url=f"https://{self.PIKPAK_API_HOST}/drive/v1/share",
931
+ data=data,
932
+ )
933
+ return result
934
+
935
+ async def get_quota_info(self) -> Dict[str, Any]:
936
+ """
937
+ 获取当前空间的quota信息
938
+ 返回数据结构如下:
939
+ {
940
+ "kind": "drive#about",
941
+ "quota": {
942
+ "kind": "drive#quota",
943
+ "limit": "10995116277760", //空间总大小, 单位Byte
944
+ "usage": "5113157556024", // 已用空间大小,单位Byte
945
+ "usage_in_trash": "1281564700871", // 回收站占用大小,单位Byte
946
+ "play_times_limit": "-1",
947
+ "play_times_usage": "0"
948
+ },
949
+ "expires_at": "",
950
+ "quotas": {}
951
+ }
952
+ """
953
+ # captcha_result = await self.captcha_init(f"GET:/drive/v1/about")
954
+ # self.captcha_token = captcha_result.get("captcha_token")
955
+ result = await self._request_get(
956
+ url=f"https://{self.PIKPAK_API_HOST}/drive/v1/about",
957
+ )
958
+ return result
959
+
960
+ async def get_invite_code(self):
961
+ captcha_result = await self.captcha_init(f"GET:/vip/v1/activity/inviteCode")
962
+ self.captcha_token = captcha_result.get("captcha_token")
963
+ result = await self._request_get(
964
+ url=f"https://{self.PIKPAK_API_HOST}/vip/v1/activity/inviteCode",
965
+ )
966
+ return result["code"]
967
+
968
+ async def vip_info(self):
969
+ captcha_result = await self.captcha_init(f"GET:/drive/v1/privilege/vip")
970
+ self.captcha_token = captcha_result.get("captcha_token")
971
+ result = await self._request_get(
972
+ url=f"https://{self.PIKPAK_API_HOST}/drive/v1/privilege/vip",
973
+ )
974
+ return result
975
+
976
+ async def get_transfer_quota(self) -> Dict[str, Any]:
977
+ """
978
+ Get transfer quota
979
+ """
980
+ url = f"https://{self.PIKPAK_API_HOST}/vip/v1/quantity/list?type=transfer"
981
+ captcha_result = await self.captcha_init(
982
+ f"GET:/vip/v1/quantity/list?type=transfer"
983
+ )
984
+ self.captcha_token = captcha_result.get("captcha_token")
985
+ result = await self._request_get(url)
986
+ return result
987
+
988
+ async def get_share_folder(
989
+ self, share_id: str, pass_code_token: str, parent_id: str = None
990
+ ) -> Dict[str, Any]:
991
+ """
992
+ 获取分享链接下文件夹内容
993
+
994
+ Args:
995
+ share_id: str - 分享ID eg. /s/VO8BcRb-XXXXX 的 VO8BcRb-XXXXX
996
+ pass_code_token: str - 通过 get_share_info 获取到的 pass_code_token
997
+ parent_id: str - 父文件夹id, 默认列出根目录
998
+ """
999
+ data = {
1000
+ "limit": "100",
1001
+ "thumbnail_size": "SIZE_LARGE",
1002
+ "order": "6",
1003
+ "share_id": share_id,
1004
+ "parent_id": parent_id,
1005
+ "pass_code_token": pass_code_token,
1006
+ }
1007
+ url = f"https://{self.PIKPAK_API_HOST}/drive/v1/share/detail"
1008
+ captcha_result = await self.captcha_init(f"GET:/drive/v1/share/detail")
1009
+ self.captcha_token = captcha_result.get("captcha_token")
1010
+ return await self._request_get(url, params=data)
1011
+
1012
+ async def get_share_info(
1013
+ self, share_link: str, pass_code: str = None
1014
+ ) -> ValueError | Dict[str, Any] | List[Dict[str | Any, str | Any]]:
1015
+ """
1016
+ 获取分享链接下内容
1017
+
1018
+ Args:
1019
+ share_link: str - 分享链接
1020
+ pass_code: str - 分享密码, 无密码则留空
1021
+ """
1022
+ match = re.search(r"/s/([^/]+)(?:.*/([^/]+))?$", share_link)
1023
+ if match:
1024
+ share_id = match.group(1)
1025
+ parent_id = match.group(2) if match.group(2) else None
1026
+ else:
1027
+ return ValueError("Share Link Is Not Right")
1028
+
1029
+ data = {
1030
+ "limit": "100",
1031
+ "thumbnail_size": "SIZE_LARGE",
1032
+ "order": "3",
1033
+ "share_id": share_id,
1034
+ "parent_id": parent_id,
1035
+ "pass_code": pass_code,
1036
+ }
1037
+ url = f"https://{self.PIKPAK_API_HOST}/drive/v1/share"
1038
+ captcha_result = await self.captcha_init(f"GET:/drive/v1/share")
1039
+ self.captcha_token = captcha_result.get("captcha_token")
1040
+ return await self._request_get(url, params=data)
1041
+
1042
+ async def restore(
1043
+ self, share_id: str, pass_code_token: str, file_ids: List[str]
1044
+ ) -> Dict[str, Any]:
1045
+ """
1046
+
1047
+ Args:
1048
+ share_id: 分享链接eg. /s/VO8BcRb-XXXXX 的 VO8BcRb-XXXXX
1049
+ pass_code_token: get_share_info获取, 无密码则留空
1050
+ file_ids: 需要转存的文件/文件夹ID列表, get_share_info获取id值
1051
+ """
1052
+ data = {
1053
+ "share_id": share_id,
1054
+ "pass_code_token": pass_code_token,
1055
+ "file_ids": file_ids,
1056
+ }
1057
+ captcha_result = await self.captcha_init(f"GET:/drive/v1/share/restore")
1058
+ self.captcha_token = captcha_result.get("captcha_token")
1059
+ result = await self._request_post(
1060
+ url=f"https://{self.PIKPAK_API_HOST}/drive/v1/share/restore", data=data
1061
+ )
1062
+ return result
pikpakapi/enums.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+
3
+
4
+ class DownloadStatus(Enum):
5
+ not_downloading = "not_downloading"
6
+ downloading = "downloading"
7
+ done = "done"
8
+ error = "error"
9
+ not_found = "not_found"
pikpakapi/utils.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ from uuid import uuid4
3
+ import time
4
+
5
+ CLIENT_ID = "ZQL_zwA4qhHcoe_2"
6
+ CLIENT_SECRET = "Og9Vr1L8Ee6bh0olFxFDRg"
7
+ CLIENT_VERSION = "1.06.0.2132"
8
+ PACKAG_ENAME = "com.thunder.downloader"
9
+ SDK_VERSION = "2.0.3.203100 "
10
+ APP_NAME = PACKAG_ENAME
11
+
12
+
13
+ def get_timestamp() -> int:
14
+ """
15
+ Get current timestamp.
16
+ """
17
+ return int(time.time() * 1000)
18
+
19
+
20
+ def device_id_generator() -> str:
21
+ """
22
+ Generate a random device id.
23
+ """
24
+ return str(uuid4()).replace("-", "")
25
+
26
+
27
+ SALTS = [
28
+ "kVy0WbPhiE4v6oxXZ88DvoA3Q",
29
+ "lON/AUoZKj8/nBtcE85mVbkOaVdVa",
30
+ "rLGffQrfBKH0BgwQ33yZofvO3Or",
31
+ "FO6HWqw",
32
+ "GbgvyA2",
33
+ "L1NU9QvIQIH7DTRt",
34
+ "y7llk4Y8WfYflt6",
35
+ "iuDp1WPbV3HRZudZtoXChxH4HNVBX5ZALe",
36
+ "8C28RTXmVcco0",
37
+ "X5Xh",
38
+ "7xe25YUgfGgD0xW3ezFS",
39
+ "",
40
+ "CKCR",
41
+ "8EmDjBo6h3eLaK7U6vU2Qys0NsMx",
42
+ "t2TeZBXKqbdP09Arh9C3",
43
+ ]
44
+
45
+
46
+ def captcha_sign(device_id: str, timestamp: str) -> str:
47
+ """
48
+ Generate a captcha sign.
49
+
50
+ 在网页端的js中, 搜索 captcha_sign, 可以找到对应的js代码
51
+
52
+ """
53
+ sign = CLIENT_ID + CLIENT_VERSION + PACKAG_ENAME + device_id + timestamp
54
+ for salt in SALTS:
55
+ sign = hashlib.md5((sign + salt).encode()).hexdigest()
56
+ return f"1.{sign}"
57
+
58
+
59
+ def generate_device_sign(device_id, package_name):
60
+ signature_base = f"{device_id}{package_name}1appkey"
61
+
62
+ # 计算 SHA-1 哈希
63
+ sha1_hash = hashlib.sha1()
64
+ sha1_hash.update(signature_base.encode("utf-8"))
65
+ sha1_result = sha1_hash.hexdigest()
66
+
67
+ # 计算 MD5 哈希
68
+ md5_hash = hashlib.md5()
69
+ md5_hash.update(sha1_result.encode("utf-8"))
70
+ md5_result = md5_hash.hexdigest()
71
+
72
+ device_sign = f"div101.{device_id}{md5_result}"
73
+
74
+ return device_sign
75
+
76
+
77
+ def build_custom_user_agent(device_id, user_id):
78
+ device_sign = generate_device_sign(device_id, PACKAG_ENAME)
79
+
80
+ user_agent_parts = [
81
+ f"ANDROID-{APP_NAME}/{CLIENT_VERSION}",
82
+ "protocolVersion/200",
83
+ "accesstype/",
84
+ f"clientid/{CLIENT_ID}",
85
+ f"clientversion/{CLIENT_VERSION}",
86
+ "action_type/",
87
+ "networktype/WIFI",
88
+ "sessionid/",
89
+ f"deviceid/{device_id}",
90
+ "providername/NONE",
91
+ f"devicesign/{device_sign}",
92
+ "refresh_token/",
93
+ f"sdkversion/{SDK_VERSION}",
94
+ f"datetime/{get_timestamp()}",
95
+ f"usrno/{user_id}",
96
+ f"appname/{APP_NAME}",
97
+ "session_origin/",
98
+ "grant_type/",
99
+ "appid/",
100
+ "clientip/",
101
+ "devicename/Xiaomi_M2004j7ac",
102
+ "osversion/13",
103
+ "platformversion/10",
104
+ "accessmode/",
105
+ "devicemodel/M2004J7AC",
106
+ ]
107
+
108
+ return " ".join(user_agent_parts)
templates/index.html ADDED
@@ -0,0 +1,1929 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
3
+ <html>
4
+
5
+ <head>
6
+ <meta charset="UTF-8" />
7
+ <title>Task</title>
8
+ <link rel="stylesheet" type="text/css" href="https://www.unpkg.com/[email protected]/dist/css/bootstrap.min.css" />
9
+ <link href="https://cdn.jsdelivr.net/npm/@mdi/[email protected]/css/materialdesignicons.min.css" rel="stylesheet" />
10
+ <link href="https://cdn.jsdelivr.net/npm/[email protected]/css/fonts.min.css" rel="stylesheet" />
11
+ <link href="//unpkg.com/[email protected]/dist/css/layui.css" rel="stylesheet">
12
+ </head>
13
+
14
+ <body style="height:100%;">
15
+ <div id="root"></div>
16
+ <a id="back-to-top" href="#" class="btn btn-success btn-lg back-to-top" role="button"><i
17
+ class="mdi mdi-arrow-up"></i></a>
18
+ <script crossorigin src="https://unpkg.com/[email protected]/umd/react.production.min.js"></script>
19
+ <script crossorigin src="https://unpkg.com/[email protected]/umd/react-dom.production.min.js"></script>
20
+ <script src="https://www.unpkg.com/[email protected]/dist/jquery.min.js"></script>
21
+ <script src="https://cdn.jsdelivr.net/npm/@popperjs/[email protected]/dist/umd/popper.min.js"></script>
22
+ <script src="https://www.unpkg.com/[email protected]/dist/js/bootstrap.min.js"></script>
23
+ <script src="https://unpkg.com/[email protected]/dist/react-bootstrap.min.js"></script>
24
+ <script src="https://unpkg.com/[email protected]/dist/redux.min.js"></script>
25
+ <script src="https://unpkg.com/[email protected]/umd/react-router-dom.min.js"></script>
26
+ <script src="https://unpkg.com/[email protected]/babel.min.js"></script>
27
+ <script src="https://unpkg.com/[email protected]/runtime.js"></script>
28
+ <script src="https://cdn.bootcdn.net/ajax/libs/babel-polyfill/7.12.1/polyfill.min.js"></script>
29
+ <script src="https://unpkg.com/[email protected]/dist/axios.min.js"></script>
30
+ <script src="//unpkg.com/[email protected]/dist/layui.js"></script>
31
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/ajv/8.17.1/ajv7.min.js"></script>
32
+ <script src="https://unpkg.com/@tanstack/[email protected]/build/umd/index.production.js"></script>
33
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/mitt.umd.min.js"></script>
34
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/localforage/1.10.0/localforage.min.js"></script>
35
+ <script src="https://cdn.jsdelivr.net/npm/@fancyapps/[email protected]/dist/fancybox/fancybox.umd.js"></script>
36
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fancyapps/[email protected]/dist/fancybox/fancybox.css" />
37
+
38
+ <style>
39
+ .bi {
40
+ display: inline-block;
41
+ width: 1rem;
42
+ height: 1rem;
43
+ }
44
+
45
+ /*
46
+ * Sidebar
47
+ */
48
+ @media (min-width: 768px) {
49
+ .sidebar {
50
+ width: 100%;
51
+ }
52
+
53
+ .sidebar .offcanvas-lg {
54
+ position: -webkit-sticky;
55
+ position: sticky;
56
+ top: 48px;
57
+ }
58
+
59
+ .navbar-search {
60
+ display: block;
61
+ }
62
+ }
63
+
64
+ .sidebar .nav-link {
65
+ font-size: 0.875rem;
66
+ font-weight: 500;
67
+ }
68
+
69
+ .sidebar .nav-link.active {
70
+ color: #2470dc;
71
+ }
72
+
73
+ .sidebar-heading {
74
+ font-size: 0.75rem;
75
+ }
76
+
77
+ /*
78
+ * Navbar
79
+ */
80
+ .navbar {
81
+ background-color: teal;
82
+ }
83
+
84
+ .navbar-brand {
85
+ padding-top: 0.75rem;
86
+ padding-bottom: 0.75rem;
87
+ /* background-color: rgba(0, 0, 0, .25);
88
+ box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25); */
89
+ }
90
+
91
+ .navbar .form-control {
92
+ padding: 0.75rem 1rem;
93
+ }
94
+
95
+ .bd-placeholder-img {
96
+ font-size: 1.125rem;
97
+ text-anchor: middle;
98
+ -webkit-user-select: none;
99
+ -moz-user-select: none;
100
+ user-select: none;
101
+ }
102
+
103
+ @media (min-width: 768px) {
104
+ .bd-placeholder-img-lg {
105
+ font-size: 3.5rem;
106
+ }
107
+ }
108
+
109
+ .b-example-divider {
110
+ width: 100%;
111
+ height: 3rem;
112
+ background-color: rgba(0, 0, 0, 0.1);
113
+ border: solid rgba(0, 0, 0, 0.15);
114
+ border-width: 1px 0;
115
+ box-shadow: inset 0 0.5em 1.5em rgba(0, 0, 0, 0.1),
116
+ inset 0 0.125em 0.5em rgba(0, 0, 0, 0.15);
117
+ }
118
+
119
+ .b-example-vr {
120
+ flex-shrink: 0;
121
+ width: 1.5rem;
122
+ height: 100vh;
123
+ }
124
+
125
+ .bi {
126
+ vertical-align: -0.125em;
127
+ fill: currentColor;
128
+ }
129
+
130
+ .nav-scroller {
131
+ position: relative;
132
+ z-index: 2;
133
+ height: 2.75rem;
134
+ overflow-y: hidden;
135
+ }
136
+
137
+ .nav-scroller .nav {
138
+ display: flex;
139
+ flex-wrap: nowrap;
140
+ padding-bottom: 1rem;
141
+ margin-top: -1px;
142
+ overflow-x: auto;
143
+ text-align: center;
144
+ white-space: nowrap;
145
+ -webkit-overflow-scrolling: touch;
146
+ }
147
+
148
+ .btn-bd-primary {
149
+ --bd-violet-bg: #712cf9;
150
+ --bd-violet-rgb: 112.520718, 44.062154, 249.437846;
151
+
152
+ --bs-btn-font-weight: 600;
153
+ --bs-btn-color: var(--bs-white);
154
+ --bs-btn-bg: var(--bd-violet-bg);
155
+ --bs-btn-border-color: var(--bd-violet-bg);
156
+ --bs-btn-hover-color: var(--bs-white);
157
+ --bs-btn-hover-bg: #6528e0;
158
+ --bs-btn-hover-border-color: #6528e0;
159
+ --bs-btn-focus-shadow-rgb: var(--bd-violet-rgb);
160
+ --bs-btn-active-color: var(--bs-btn-hover-color);
161
+ --bs-btn-active-bg: #5a23c8;
162
+ --bs-btn-active-border-color: #5a23c8;
163
+ }
164
+
165
+ .bd-mode-toggle {
166
+ z-index: 1500;
167
+ }
168
+
169
+ .bd-mode-toggle .dropdown-menu .active .bi {
170
+ display: block !important;
171
+ }
172
+
173
+ .back-to-top {
174
+ position: fixed;
175
+ bottom: 25px;
176
+ right: 25px;
177
+ display: none;
178
+ }
179
+
180
+ .leftsidebar {
181
+ height: 100%;
182
+ box-shadow: inset -1px 0 0 rgba(0, 0, 0, 0.1);
183
+ }
184
+
185
+ @media (min-width: 768px) {
186
+ .leftsidebar {
187
+ min-width: 15%;
188
+ }
189
+ }
190
+
191
+ @media (max-width: 768px) {
192
+ .leftsidebar {
193
+ max-width: 50%;
194
+ }
195
+ }
196
+
197
+ .bg-teal {
198
+ background-color: teal;
199
+ }
200
+ </style>
201
+
202
+ <script type="text/babel" data-presets="react" data-type="module">
203
+ window.layer = layui.layer;
204
+ //事件监听开始 通过修改localstorage实现跨页面事件监听
205
+ const emitter = mitt();
206
+ // 监听 localStorage 变化
207
+ window.addEventListener("storage", (event) => {
208
+ if (event.key === "event") {
209
+ const { type, data } = JSON.parse(event.newValue);
210
+ emitter.emit(type, data);
211
+ }
212
+ });
213
+ // 封装 emit 方法
214
+ const emitEvent = (type, data) => {
215
+ // 触发本地事件
216
+ emitter.emit(type, data);
217
+ const randomString = Math.random()
218
+ .toString(36)
219
+ .substring(2, 10); // 生成一个随机字符串确保event每次的值不一样,如果一样会不触发事件
220
+ const identity = `${Date.now()}-${randomString}`;
221
+ // 存储到 localStorage,以便其他页面能够接收到
222
+ localStorage.setItem(
223
+ "event",
224
+ JSON.stringify({ type, data, identity })
225
+ );
226
+ };
227
+
228
+ // 封装 on 方法
229
+ const onEvent = (type, callback) => {
230
+ emitter.on(type, callback);
231
+ };
232
+
233
+ // 封装 off 方法
234
+ const offEvent = (type, callback) => {
235
+ emitter.off(type, callback);
236
+ };
237
+ //事件监听结束
238
+
239
+
240
+ Fancybox.bind("[data-fancybox]", {
241
+ Toolbar: {
242
+ display: {
243
+ right: ["slideshow", "download", "thumbs", "close"],
244
+ },
245
+ },
246
+ Images: {
247
+ initialSize: "fit",
248
+ }
249
+ });
250
+
251
+
252
+ var settingStorage = localforage.createInstance({
253
+ name: "setting",
254
+ driver: localforage.LOCALSTORAGE
255
+ });
256
+ // settingStorage.setItem("category", { name: 'test', id: 1 });
257
+ // settingStorage.getItem('category').then(function (value) {
258
+ // console.log(value);
259
+ // }).catch(function (err) {
260
+ // console.log(err);
261
+ // });
262
+ // settingStorage.getItem('category', function (err, value) {
263
+ // console.log(value.name);
264
+ // });
265
+
266
+
267
+ const { createStore, combineReducers } = Redux;
268
+ // 从 localStorage 加载初始状态
269
+ const loadStateFromLocalStorage = () => {
270
+ try {
271
+ const serializedState = localStorage.getItem('settings');
272
+ if (serializedState === null) {
273
+ return {}; // 默认值
274
+ }
275
+ return JSON.parse(serializedState);
276
+ } catch (e) {
277
+ console.error("Could not load state from localStorage:", e);
278
+ return {}; // 默认值
279
+ }
280
+ };
281
+ // 保存状态到 localStorage
282
+ const saveStateToLocalStorage = (state) => {
283
+ try {
284
+ const serializedState = JSON.stringify(state);
285
+ localStorage.setItem('settings', serializedState);
286
+ } catch (e) {
287
+ console.error("Could not save state to localStorage:", e);
288
+ }
289
+ };
290
+
291
+ // 定义初始状态
292
+ const initialSettingsState = loadStateFromLocalStorage();
293
+ // 创建 settings Reducer
294
+ function settingsReducer(state = initialSettingsState, action) {
295
+ switch (action.type) {
296
+ case 'SAVE_SETTING':
297
+ return { ...state, ...action.payload };
298
+ default:
299
+ return state;
300
+ }
301
+ }
302
+
303
+ // 合并 Reducer(如果有多个)
304
+ const rootReducer = combineReducers({
305
+ settings: settingsReducer,
306
+ });
307
+
308
+ // 创建 Redux Store
309
+ const STORE = createStore(rootReducer);
310
+
311
+ // 订阅 Store 的变化,并将状态保存到 localStorage
312
+ STORE.subscribe(() => {
313
+ saveStateToLocalStorage(STORE.getState().settings);
314
+ });
315
+
316
+
317
+ //数据校验
318
+ // var ajv = new ajv7.default()
319
+ // const schema = {
320
+ // type: "object",
321
+ // properties: {
322
+ // foo: { type: "integer" },
323
+ // bar: { type: "string" }
324
+ // },
325
+ // required: ["foo"],
326
+ // additionalProperties: false
327
+ // }
328
+
329
+ // const validate = ajv.compile(schema)
330
+
331
+ // const data = {
332
+ // foo: 1,
333
+ // bar: "abc"
334
+ // }
335
+ // const valid = validate(data)
336
+ // if (!valid) console.log(validate.errors)
337
+
338
+
339
+ const bytesToSize = (bytes) => {
340
+ if (bytes === 0) return '0 B';
341
+ var k = 1024;
342
+ sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
343
+ i = Math.floor(Math.log(bytes) / Math.log(k));
344
+ return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
345
+ };
346
+ const formatDate = (date) => {
347
+ var d = new Date(date);
348
+ var year = d.getFullYear();
349
+ var month = d.getMonth() + 1;
350
+ var day = d.getDate() < 10 ? '0' + d.getDate() : '' + d.getDate();
351
+ var hour = d.getHours();
352
+ var minutes = d.getMinutes();
353
+ var seconds = d.getSeconds();
354
+ return year + '-' + month + '-' + day + ' ' + hour + ':' + minutes + ':' + seconds;
355
+ };
356
+
357
+ let layerLoading = null;
358
+
359
+ const showLoading = () => {
360
+ const loadindex = layer.load(1);
361
+ layerLoading = loadindex;
362
+ }
363
+
364
+ const hideLoading = () => {
365
+ layer.close(layerLoading);
366
+ }
367
+
368
+
369
+ const { useState, useEffect, useRef } = React;
370
+ const { HashRouter, Route, Link, Switch, useLocation, useParams } = ReactRouterDOM;
371
+ const { useQuery, useMutation, useQueryClient, QueryClient, QueryClientProvider } = ReactQuery;
372
+ const queryClient = new QueryClient()
373
+ const {
374
+ Alert,
375
+ Badge,
376
+ Button,
377
+ ButtonGroup,
378
+ ButtonToolbar,
379
+ Card,
380
+ Collapse,
381
+ Col,
382
+ Container,
383
+ Dropdown,
384
+ Form,
385
+ Image,
386
+ InputGroup,
387
+ ListGroup,
388
+ Modal,
389
+ Nav,
390
+ Navbar,
391
+ NavDropdown,
392
+ Offcanvas,
393
+ Pagination,
394
+ Row,
395
+ Table,
396
+ } = ReactBootstrap;
397
+ //注意修改js文件后需要直接访问js以更新浏览器缓存
398
+
399
+ // 表格组件
400
+ const DataTable = ({ data, columns }) => {
401
+ return (
402
+ <Table responsive bordered>
403
+ <thead>
404
+ <tr className="text-center">
405
+ {columns.map((column, index) => (
406
+ <th key={index}>{column.title}</th>
407
+ ))}
408
+ </tr>
409
+ </thead>
410
+ <tbody>
411
+ {data.map((row, rowIndex) => (
412
+ <tr key={rowIndex} className="text-center">
413
+ {columns.map((column, colIndex) => (
414
+ <td key={colIndex}>
415
+ {/* 调用渲染方法,如果没有定义,则直接显示数据 */}
416
+ {column.render
417
+ ? column.render(row)
418
+ : row[column.dataIndex]}
419
+ </td>
420
+ ))}
421
+ </tr>
422
+ ))}
423
+ </tbody>
424
+ </Table>
425
+ );
426
+ };
427
+ //分页组件
428
+ const Paginate = (props) => {
429
+ const page = props.page;
430
+ const pageCount = Math.ceil(
431
+ props.totalCount / props.itemsPerPage
432
+ );
433
+
434
+ const SelectItems = () => {
435
+ const pageNumbers = Array.from(
436
+ { length: pageCount },
437
+ (_, i) => i + 1
438
+ );
439
+ return (
440
+ <select
441
+ className="page-link border-0 h-100 py-0"
442
+ style={{ width: "auto" }}
443
+ onChange={(e) => {
444
+ props.onClick(parseInt(e.target.value));
445
+ }}
446
+ >
447
+ {pageNumbers.map((number) => {
448
+ const selected = number === page ? true : false;
449
+ return (
450
+ <option
451
+ key={number}
452
+ value={number}
453
+ selected={selected}
454
+ >
455
+ {number}
456
+ </option>
457
+ );
458
+ })}
459
+ </select>
460
+ );
461
+ };
462
+ return (
463
+ <div className="d-flex justify-content-center align-items-baseline">
464
+
465
+ <Pagination>
466
+ {pageCount > 1 && page > 1 && (
467
+ <Pagination.First
468
+ onClick={() => {
469
+ props.onClick(1);
470
+ }}
471
+ />
472
+ )}
473
+ {pageCount > 1 && page > 1 && (
474
+ <Pagination.Prev
475
+ onClick={() => {
476
+ props.onClick(page - 1);
477
+ }}
478
+ />
479
+ )}
480
+ <Pagination.Item linkClassName="p-0 h-100 d-inline-block">
481
+ <SelectItems />
482
+ </Pagination.Item>
483
+ <Pagination.Item>
484
+ <span className="text-info">
485
+ {page}/{pageCount}
486
+ </span>
487
+ </Pagination.Item>
488
+ {pageCount > 1 && page < pageCount && (
489
+ <Pagination.Next
490
+ onClick={() => {
491
+ props.onClick(page + 1);
492
+ }}
493
+ />
494
+ )}
495
+ {pageCount > 1 && page < pageCount && (
496
+ <Pagination.Last
497
+ onClick={() => {
498
+ props.onClick(pageCount);
499
+ }}
500
+ />
501
+ )}
502
+ </Pagination>
503
+ </div>
504
+ );
505
+ };
506
+ //图标组件
507
+ const Icon = (props) => {
508
+ return (
509
+ <span
510
+ onClick={props.onClick}
511
+ className={`mdi mdi-${props.icon} fs-${props.size} ${props.className}`}
512
+ ></span>
513
+ );
514
+ };
515
+ //按钮图标组件
516
+ const IconButton = (props) => {
517
+ return (
518
+ <Button
519
+ variant="success"
520
+ onClick={props.onClick}
521
+ className={props.className}
522
+ >
523
+ <span
524
+ className={`mdi mdi-${props.icon} fs-${props.iconSize} ${props.iconClassName}`}
525
+ ></span>
526
+ {props.text}
527
+ </Button>
528
+ );
529
+ };
530
+ //video组件
531
+ const createCaption = (video) => {
532
+ var html = video.code + ' ' + video.title;
533
+ //html+="<a href='/tags'>test</a>";
534
+ video.tags.map(tag => {
535
+ html += tag;
536
+ })
537
+ return html;
538
+ };
539
+
540
+
541
+ const AsyncImage = (props) => {
542
+ const [loadedSrc, setLoadedSrc] = React.useState(null);
543
+ React.useEffect(() => {
544
+ setLoadedSrc(null);
545
+ if (props.src) {
546
+ const handleLoad = () => {
547
+ setLoadedSrc(props.src);
548
+ };
549
+ const image = document.createElement("img");
550
+ image.addEventListener('load', handleLoad);
551
+ image.src = props.src;
552
+ return () => {
553
+ image.removeEventListener('load', handleLoad);
554
+ };
555
+ }
556
+ }, [props.src]);
557
+ if (loadedSrc === props.src) {
558
+ return (
559
+ <img {...props} />
560
+ );
561
+ }
562
+ return <img {...props} src="https://placehold.co/600x400?text=Loading" />;
563
+ };
564
+
565
+ const Video = ({ video }) => {
566
+
567
+ const [showPreview, setShowPreview] = useState(null);
568
+ const [holdPreviews, setHoldPreviews] = useState([]);
569
+ const videoRef = useRef(null);
570
+
571
+
572
+ const clickPreview = (id) => {
573
+ if (!holdPreviews.includes(id)) {
574
+ setHoldPreviews((prev) => [...prev, id]);
575
+ playPreview(id);
576
+ }
577
+ };
578
+
579
+ const playPreview = (id) => {
580
+ const preview = document.getElementById(`preview-${id}`);
581
+
582
+ if (!preview.getAttribute("src")) {
583
+ preview.addEventListener("loadedmetadata", (event) => {
584
+ event.target.play();
585
+ setShowPreview(id);
586
+ });
587
+
588
+ preview.setAttribute("src", preview.getAttribute("data-src"));
589
+ } else {
590
+ preview.play();
591
+ setShowPreview(id);
592
+ }
593
+ };
594
+
595
+
596
+ const { mutateAsync: favoriteMutation } = useMutation({
597
+ mutationKey: ["switch-favorite"],
598
+ mutationFn: async (favorite) => {
599
+ showLoading();
600
+ return await axios.post("/favorite", favorite)
601
+ },
602
+ onSuccess: async (data, variables, context) => {
603
+ hideLoading();
604
+ layer.msg('操作成功', { time: 2000, icon: 6 });
605
+ },
606
+ onError: () => {
607
+ hideLoading();
608
+ layer.msg('操作失败', { time: 2000, icon: 5 });
609
+ }
610
+ })
611
+
612
+ return (
613
+ <Card>
614
+ <a href={video.poster} target="_blank" data-fancybox="gallery" className={holdPreviews.includes(video.id) ? 'd-none' : 'd-block'}
615
+ data-download-src={video.src} data-caption={createCaption(video)}>
616
+ <AsyncImage className="card-img-top" src={video.poster} />
617
+ </a>
618
+ <video
619
+ className={holdPreviews.includes(video.id) ? 'd-block' : 'd-none'}
620
+ id={`preview-${video.id}`}
621
+ ref={videoRef}
622
+ data-src={`https://fourhoi.com/${video.code.toLowerCase()}/preview.mp4`}
623
+ controls
624
+ autoPlay
625
+ webkit-playsinline="true"
626
+ playsinline="true"
627
+ style={{ width: "100%", height: "100%", objectFit: "cover" }}
628
+ />
629
+
630
+ <Card.Body>
631
+ <Card.Title>{`${video.code} ${video.title}`}</Card.Title>
632
+ <Card.Text>
633
+ {video.tags.map((tag, index) => (
634
+ <a target="_blank" href={`#/tags/${encodeURIComponent(tag)}`} className="badge bg-secondary text-wrap m-1">{tag}</a>
635
+ ))}
636
+ </Card.Text>
637
+
638
+ <Dropdown className="float-start">
639
+ <Dropdown.Toggle variant="primary" id="dropdown-basic">
640
+ 播放
641
+ </Dropdown.Toggle>
642
+ <Dropdown.Menu>
643
+ <Dropdown.Item href={video.src} target="_blank">网页</Dropdown.Item>
644
+ <Dropdown.Item href={`vlc://${video.src}`} target="_blank">VLC</Dropdown.Item>
645
+ <Dropdown.Item href={`videoplayerapp://open?url=${encodeURIComponent(video.src)}`} target="_blank">Video Player</Dropdown.Item>
646
+ </Dropdown.Menu>
647
+ </Dropdown>
648
+
649
+
650
+ <Button variant="primary" onClick={() => clickPreview(video.id)}><Icon
651
+ icon="video"
652
+ size="8"
653
+ className="text-text-primary"
654
+ /></Button>
655
+
656
+
657
+ <Button className={`btn float-end ${video.favorite ? 'btn-danger' : 'btn-secondary'}`} onClick={async () => {
658
+ layer.confirm('确定要进行操作?不可撤销!', {
659
+ btn: ['确定', '取消'] //按钮
660
+ }, async function () {
661
+ await favoriteMutation({ id: video.id, favorite: !video.favorite })
662
+ }, async function () {
663
+
664
+ });
665
+
666
+ }}><Icon
667
+ icon="star"
668
+ size="8"
669
+ className="text-text-primary"
670
+ /></Button>
671
+
672
+
673
+
674
+ </Card.Body>
675
+ </Card>
676
+ )
677
+ }
678
+
679
+ //设置框
680
+ const SettingModal = (props) => {
681
+ const settings = [
682
+ { "alist": [{ "label": "Alist地址", "key": "alist_host", "show": true }, { "label": "Alist令牌", "key": "alist_token", "show": false }] },
683
+ { "github": [{ "label": "Actions地址", "key": "github_host", "show": true }, { "label": "Github令牌", "key": "github_token", "show": false }] },
684
+ { "directus": [{ "label": "Directus地址", "key": "directus_host", "show": true }, { "label": "Directus令牌", "key": "directus_token", "show": false }] }
685
+ ]
686
+ const [setting, setSetting] = useState({});
687
+ // useEffect(() => {
688
+ // localStorage.setItem('settings', JSON.stringify(setting));
689
+ // }, [setting]);
690
+
691
+ const loadSetting = () => {
692
+ const storedSettings = STORE.getState().settings;
693
+ if (storedSettings) {
694
+ setSetting(storedSettings);
695
+ }
696
+ }
697
+ const saveSetting = () => {
698
+ STORE.dispatch({ type: 'SAVE_SETTING', payload: setting })
699
+ //localStorage.setItem('settings', JSON.stringify(setting));
700
+ }
701
+ return (
702
+ <Modal show={props.show} onHide={props.onHide} onShow={loadSetting}>
703
+ <Modal.Header closeButton onHide={props.onHide}>
704
+ <Modal.Title>设置</Modal.Title>
705
+ </Modal.Header>
706
+ <Modal.Body>
707
+ <Form>
708
+ <ListGroup>
709
+ {settings.map((value, index) => {
710
+ const key = Object.keys(value)[0];
711
+ const items = value[key];
712
+ return (<ListGroup.Item>
713
+ {items.map((setting_item) => {
714
+ return (
715
+ <Form.Group as={Row} className="mb-3">
716
+ <Form.Label column sm="3">
717
+ {setting_item.label}
718
+ </Form.Label>
719
+ <Col sm="9">
720
+ <Form.Control type={setting_item.show ? "input" : "password"} value={setting[setting_item.key]} name={setting_item.key} placeholder={setting_item.label} onChange={(e) => { setSetting({ ...setting, [setting_item.key]: e.target.value }) }} />
721
+ </Col>
722
+ </Form.Group>
723
+ )
724
+ })}
725
+ </ListGroup.Item>)
726
+ })}
727
+ </ListGroup>
728
+ </Form>
729
+ </Modal.Body>
730
+ <Modal.Footer className="justify-content-between">
731
+ <Button
732
+ variant="secondary"
733
+ onClick={() => {
734
+ props.onHide();
735
+ }}
736
+ >
737
+ 关闭
738
+ </Button>
739
+ <Button
740
+ variant="primary"
741
+ onClick={() => {
742
+ saveSetting();
743
+ props.onHide();
744
+ //props.onSave();
745
+ }}
746
+ >
747
+ 保存
748
+ </Button>
749
+ </Modal.Footer>
750
+ </Modal>
751
+ );
752
+ };
753
+
754
+ //axios封装开始
755
+ const useAxios = () => {
756
+ const [response, setResponse] = useState(null);
757
+ const [error, setError] = useState("");
758
+ const [loading, setLoading] = useState(false);
759
+
760
+ // Create an Axios instance
761
+ const axiosInstance = axios.create({});
762
+
763
+ // Set up request and response interceptors
764
+ axiosInstance.interceptors.request.use(
765
+ (config) => {
766
+ // Log or modify request here
767
+ //console.log("Sending request to:", config.url);
768
+ return config;
769
+ },
770
+ (error) => {
771
+ // Handle request error here
772
+ return Promise.reject(error);
773
+ }
774
+ );
775
+
776
+ axiosInstance.interceptors.response.use(
777
+ (response) => {
778
+ // Log or modify response here
779
+ //console.log("Received response from:", response.config.url);
780
+ return response;
781
+ },
782
+ (error) => {
783
+ // Handle response error here
784
+ return Promise.reject(error);
785
+ }
786
+ );
787
+
788
+ useEffect(() => {
789
+ const source = axios.CancelToken.source();
790
+ return () => {
791
+ // Cancel the request when the component unmounts
792
+ source.cancel(
793
+ "组件被卸载: 请求取消."
794
+ );
795
+ };
796
+ }, []);
797
+
798
+ // Making the API call with cancellation support
799
+ const fetchData = async ({ url, method, data, headers }) => {
800
+ setLoading(true);
801
+ try {
802
+ const result = await axiosInstance({
803
+ url,
804
+ method,
805
+ headers: headers ? headers : {},
806
+ data:
807
+ method.toLowerCase() === "get"
808
+ ? undefined
809
+ : data,
810
+ params:
811
+ method.toLowerCase() === "get"
812
+ ? data
813
+ : undefined,
814
+ cancelToken: axios.CancelToken.source().token,
815
+ });
816
+ setResponse(result.data);
817
+ } catch (error) {
818
+ if (axios.isCancel(error)) {
819
+ console.log("Request cancelled", error.message);
820
+ } else {
821
+ setError(
822
+ error.response
823
+ ? error.response.data
824
+ : error.message
825
+ );
826
+ }
827
+ } finally {
828
+ setLoading(false);
829
+ }
830
+ };
831
+ return [response, error, loading, fetchData];
832
+ };
833
+ //axios封闭结束
834
+
835
+ //分页hooks
836
+ const usePagination = () => {
837
+ const [pagination, setPagination] = useState({
838
+ pageSize: 36,
839
+ pageIndex: 1,
840
+ });
841
+ const { pageSize, pageIndex } = pagination;
842
+
843
+
844
+ return {
845
+ limit: pageSize,
846
+ onPaginationChange: setPagination,
847
+ pagination,
848
+ skip: pageSize * (pageIndex - 1),
849
+ };
850
+ }
851
+ //分页结束
852
+
853
+
854
+ //API定义开始
855
+ const getFiles = () => {
856
+ const [response, error, loading, fetchData] = useAxios();
857
+
858
+ const fetchDataByPage = async (setting, query) => {
859
+ var host = setting.alist_host;
860
+ if (!host.endsWith("/")) {
861
+ host = host + '/'
862
+ }
863
+ fetchData({
864
+ url: host + 'api/fs/list',
865
+ method: "POST",
866
+ data: query,
867
+ headers: {
868
+ 'Authorization': setting.alist_token,
869
+ 'Content-Type': 'application/json'
870
+ },
871
+ });
872
+ };
873
+ return [response, error, loading, fetchDataByPage];
874
+ };
875
+
876
+ const paginateLinksGet = async (limit, page, keyword) => {
877
+ const url = `/video?size=${limit}&page=${page}&kw=${keyword}`;
878
+ const { data } = await axios.get(url)
879
+ return data
880
+ }
881
+
882
+ const paginateFavoritesGet = async (limit, page, keyword) => {
883
+ const url = `/favorites?size=${limit}&page=${page}&kw=${keyword}`;
884
+ const { data } = await axios.get(url)
885
+ return data
886
+ }
887
+
888
+
889
+ const paginateTagLinksGet = async (limit, page, tag) => {
890
+ const url = `/tags?size=${limit}&page=${page}&tag=${tag}`;
891
+ const { data } = await axios.get(url)
892
+ return data
893
+ }
894
+
895
+ const paginateTasksGet = async (limit, skip) => {
896
+ const setting = STORE.getState().settings;
897
+ const url = setting.directus_host + `items/task?limit=${limit}&offset=${skip}&meta[]=filter_count&sort[]=-id`;
898
+ const { data } = await axios.get(url, { headers: { Authorization: "Bearer " + setting.directus_token } })
899
+ return data
900
+ }
901
+
902
+
903
+ //API定义结束
904
+
905
+ const Layout = ({ children }) => {
906
+ useEffect(() => {
907
+ // 组件挂载时执行的代码(相当于 componentDidMount)
908
+ }, []); // 空数组表示只在挂载和卸载时执行
909
+
910
+ const [showSideBar, setShowSideBar] = useState(false);
911
+ const handleSidebarClose = () => setShowSideBar(false);
912
+ const handleSidebarShow = () => setShowSideBar(true);
913
+ const toggleSidebarShow = () => {
914
+ setShowSideBar(!showSideBar);
915
+ };
916
+
917
+ const [setting, setSetting] = useState(false);
918
+
919
+ return (
920
+ <div>
921
+ <header className="sticky-top">
922
+ <Navbar expand="md">
923
+ <Container fluid>
924
+ <div>
925
+ <Navbar.Toggle
926
+ className="shadow-none border-0"
927
+ onClick={handleSidebarShow}
928
+ children={
929
+ <Icon
930
+ icon="menu"
931
+ size="3"
932
+ className="text-white"
933
+ />
934
+ }
935
+ />
936
+ <Navbar.Brand
937
+ as={Link}
938
+ to="/"
939
+ className="text-white"
940
+ >
941
+ 收藏
942
+ </Navbar.Brand>
943
+ </div>
944
+ <div className="d-flex">
945
+ <Tasks />
946
+ <LocalTasks />
947
+ <Button
948
+ style={{
949
+ backgroundColor: "transparent",
950
+ }}
951
+ className="nav-link btn"
952
+ onClick={() => {
953
+ setSetting(true)
954
+ }}
955
+ children={
956
+ <Icon
957
+ icon="dots-vertical"
958
+ size="3"
959
+ className="text-white"
960
+ />
961
+ }
962
+ ></Button>
963
+ <SettingModal
964
+ show={setting}
965
+ onHide={() => {
966
+ setSetting(false);
967
+ }}
968
+ />
969
+ </div>
970
+ </Container>
971
+ </Navbar>
972
+ </header>
973
+ <Container fluid>
974
+ <Row style={{ minHeight: "100vh" }}>
975
+ <Col
976
+ md="2"
977
+ lg="2"
978
+ xl="2"
979
+ className="ps-0 d-none d-md-block"
980
+ >
981
+ <Offcanvas
982
+ className="leftsidebar h-100 bg-light"
983
+ show={showSideBar}
984
+ onHide={handleSidebarClose}
985
+ placement="start"
986
+ responsive="md"
987
+ >
988
+ <Offcanvas.Header
989
+ className="py-2 border-bottom"
990
+ closeButton
991
+ >
992
+ <Offcanvas.Title>
993
+ 离线任务
994
+ </Offcanvas.Title>
995
+ </Offcanvas.Header>
996
+ <Offcanvas.Body className="p-0">
997
+ <Container fluid className="p-0">
998
+ <Nav
999
+ activeKey="1"
1000
+ className="flex-column"
1001
+ >
1002
+ <Nav.Link
1003
+ as={Link}
1004
+ className="nav-link text-dark"
1005
+ to="/"
1006
+ onClick={
1007
+ handleSidebarClose
1008
+ }
1009
+ >
1010
+ <Icon
1011
+ icon="plus"
1012
+ size="6"
1013
+ className="me-2"
1014
+ />
1015
+ 收藏
1016
+ </Nav.Link>
1017
+ <Nav.Link
1018
+ as={Link}
1019
+ className="nav-link text-dark"
1020
+ to="/videos"
1021
+ onClick={
1022
+ handleSidebarClose
1023
+ }
1024
+ >
1025
+ <Icon
1026
+ icon="movie"
1027
+ size="6"
1028
+ className="me-2"
1029
+ />
1030
+ 视频
1031
+ </Nav.Link>
1032
+ </Nav>
1033
+ </Container>
1034
+ </Offcanvas.Body>
1035
+ </Offcanvas>
1036
+ </Col>
1037
+
1038
+ <Col xs="12" sm="12" md="10" lg="10" xl="10">
1039
+ <main>
1040
+ <Container fluid className="pt-2 px-0 pb-5">
1041
+ {children}
1042
+ </Container>
1043
+ </main>
1044
+ </Col>
1045
+ </Row>
1046
+ </Container>
1047
+ </div>
1048
+ );
1049
+ };
1050
+ const Home = () => {
1051
+ const location = useLocation();
1052
+ const { id } = useParams();
1053
+ return (
1054
+ <div>
1055
+ <div className="d-flex justify-content-between align-items-center p-2 border-bottom bg-light">
1056
+ <label className="fs-3">Home</label>
1057
+ <ButtonToolbar
1058
+ aria-label="文件列表"
1059
+ className="bg-teal rounded"
1060
+ >
1061
+ <ButtonGroup className="bg-teal">
1062
+ <IconButton
1063
+ onClick={() => {
1064
+ alert("test")
1065
+ }}
1066
+ text="刷新"
1067
+ className="bg-teal border-0"
1068
+ icon="reload"
1069
+ iconClassName="me-1 text-white"
1070
+ iconSize="6"
1071
+ />
1072
+ <IconButton
1073
+ onClick={() => {
1074
+ alert("hello");
1075
+ }}
1076
+ text="删除"
1077
+ className="bg-teal border-0"
1078
+ icon="delete-outline"
1079
+ iconClassName="me-1 text-white"
1080
+ iconSize="6"
1081
+ />
1082
+ </ButtonGroup>
1083
+ </ButtonToolbar>
1084
+ </div>
1085
+ <Container fluid className="p-2"></Container>
1086
+ </div>
1087
+ );
1088
+ };
1089
+
1090
+ const Videos = () => {
1091
+ const [reload, setReload] = useState(false);
1092
+ const { limit, onPaginationChange, skip, pagination } = usePagination();
1093
+ const [meta, setMeta] = useState({ filter_count: 0 })
1094
+ const [keyword, setKeyword] = useState("")
1095
+ const [search, setSearch] = useState("")
1096
+ const [videos, setVideos] = useState([])
1097
+ const { data: linksData, refetch: linksRefetch, isLoading: linksLoading, error: linksError } = useQuery({
1098
+ queryKey: ['get_paginate_links', limit, pagination.pageIndex, search],
1099
+ queryFn: () => paginateLinksGet(limit, pagination.pageIndex, search),
1100
+ enabled: false,
1101
+ })
1102
+ useEffect(() => {
1103
+ linksRefetch()
1104
+ }, [pagination, reload, search]);
1105
+ useEffect(() => {
1106
+ linksRefetch()
1107
+ }, []);
1108
+ useEffect(() => {
1109
+ if (linksData) {
1110
+ setMeta({ filter_count: linksData.total })
1111
+ setVideos([...linksData.items])
1112
+ }
1113
+ }, [linksData]);
1114
+
1115
+ const handleSearchClick = () => {
1116
+ setSearch(keyword)
1117
+ onPaginationChange({ pageSize: 36, pageIndex: 1 })
1118
+ };
1119
+
1120
+
1121
+
1122
+ const forceUpdate = () => {
1123
+ setReload((pre) => !pre);
1124
+ };
1125
+
1126
+ return (
1127
+ <div>
1128
+ <div className="d-flex justify-content-between align-items-center p-2 border-bottom bg-light">
1129
+ <label className="fs-3">视频列表</label>
1130
+ <ButtonToolbar
1131
+ aria-label="视频列表"
1132
+ className="bg-teal rounded"
1133
+ >
1134
+ <ButtonGroup className="bg-teal">
1135
+ <IconButton
1136
+ onClick={() => {
1137
+ forceUpdate();
1138
+ }}
1139
+ text="刷新"
1140
+ className="bg-teal border-0"
1141
+ icon="reload"
1142
+ iconClassName="me-1 text-white"
1143
+ iconSize="6"
1144
+ />
1145
+ </ButtonGroup>
1146
+ </ButtonToolbar>
1147
+ </div>
1148
+ {linksError && (
1149
+ <div className="text-center text-danger">
1150
+ 发生错误,请稍后重试!!!
1151
+ </div>
1152
+ )}
1153
+
1154
+ <Container fluid className="p-2">
1155
+ <InputGroup className="mb-3">
1156
+ <Form.Control
1157
+ placeholder="关键词"
1158
+ aria-label="关键词"
1159
+ aria-describedby="关键词"
1160
+ onChange={e => setKeyword(e.target.value)}
1161
+ />
1162
+ <Button variant="outline-secondary" id="button-addon2" onClick={() => { handleSearchClick() }}>
1163
+ 搜索
1164
+ </Button>
1165
+ </InputGroup>
1166
+
1167
+ <Row>
1168
+ <Col xs={12} className="py-2">
1169
+ <Paginate page={pagination.pageIndex} onClick={(i) => { onPaginationChange({ pageSize: 36, pageIndex: i }) }} itemsPerPage="36" totalCount={meta.filter_count} />
1170
+ </Col>
1171
+ </Row>
1172
+ {(linksLoading) && (
1173
+ <Row>
1174
+ <Col xs={12} className="py-2">
1175
+ <div className="text-center text-success">
1176
+ 正在努力加载中......
1177
+ </div>
1178
+ </Col>
1179
+ </Row>
1180
+ )}
1181
+ {linksData && (
1182
+ <Row>
1183
+ {videos.map((video, index) => (
1184
+ <Col xs={12} md={3} className="py-2">
1185
+ <Video video={video} />
1186
+ </Col>
1187
+ ))}
1188
+ </Row>
1189
+ )}
1190
+
1191
+
1192
+
1193
+
1194
+ <Row>
1195
+ <Col xs={12} className="py-2">
1196
+ <Paginate page={pagination.pageIndex} onClick={(i) => { onPaginationChange({ pageSize: 36, pageIndex: i }) }} itemsPerPage="36" totalCount={meta.filter_count} />
1197
+ </Col>
1198
+ </Row>
1199
+ </Container>
1200
+ </div>
1201
+ );
1202
+ };
1203
+
1204
+
1205
+ const Favorites = () => {
1206
+ const [reload, setReload] = useState(false);
1207
+ const { limit, onPaginationChange, skip, pagination } = usePagination();
1208
+ const [meta, setMeta] = useState({ filter_count: 0 })
1209
+ const [keyword, setKeyword] = useState("")
1210
+ const [search, setSearch] = useState("")
1211
+ const [videos, setVideos] = useState([])
1212
+ const { data: linksData, refetch: linksRefetch, isLoading: linksLoading, error: linksError } = useQuery({
1213
+ queryKey: ['get_paginate_favorites', limit, pagination.pageIndex, search],
1214
+ queryFn: () => paginateFavoritesGet(limit, pagination.pageIndex, search),
1215
+ enabled: false,
1216
+ })
1217
+ useEffect(() => {
1218
+ linksRefetch()
1219
+ }, [pagination, reload, search]);
1220
+ useEffect(() => {
1221
+ linksRefetch()
1222
+ }, []);
1223
+ useEffect(() => {
1224
+ if (linksData) {
1225
+ setMeta({ filter_count: linksData.total })
1226
+ setVideos([...linksData.items])
1227
+ }
1228
+ }, [linksData]);
1229
+
1230
+ const handleSearchClick = () => {
1231
+ setSearch(keyword)
1232
+ onPaginationChange({ pageSize: 36, pageIndex: 1 })
1233
+ };
1234
+
1235
+
1236
+
1237
+ const forceUpdate = () => {
1238
+ setReload((pre) => !pre);
1239
+ };
1240
+
1241
+ return (
1242
+ <div>
1243
+ <div className="d-flex justify-content-between align-items-center p-2 border-bottom bg-light">
1244
+ <label className="fs-3">收藏列表</label>
1245
+ <ButtonToolbar
1246
+ aria-label="收藏列表"
1247
+ className="bg-teal rounded"
1248
+ >
1249
+ <ButtonGroup className="bg-teal">
1250
+ <IconButton
1251
+ onClick={() => {
1252
+ forceUpdate();
1253
+ }}
1254
+ text="刷新"
1255
+ className="bg-teal border-0"
1256
+ icon="reload"
1257
+ iconClassName="me-1 text-white"
1258
+ iconSize="6"
1259
+ />
1260
+ </ButtonGroup>
1261
+ </ButtonToolbar>
1262
+ </div>
1263
+ {linksError && (
1264
+ <div className="text-center text-danger">
1265
+ 发生错误,请稍后重试!!!
1266
+ </div>
1267
+ )}
1268
+
1269
+ <Container fluid className="p-2">
1270
+ <InputGroup className="mb-3">
1271
+ <Form.Control
1272
+ placeholder="关键词"
1273
+ aria-label="关键词"
1274
+ aria-describedby="关键词"
1275
+ onChange={e => setKeyword(e.target.value)}
1276
+ />
1277
+ <Button variant="outline-secondary" id="button-addon2" onClick={() => { handleSearchClick() }}>
1278
+ 搜索
1279
+ </Button>
1280
+ </InputGroup>
1281
+
1282
+ <Row>
1283
+ <Col xs={12} className="py-2">
1284
+ <Paginate page={pagination.pageIndex} onClick={(i) => { onPaginationChange({ pageSize: 36, pageIndex: i }) }} itemsPerPage="36" totalCount={meta.filter_count} />
1285
+ </Col>
1286
+ </Row>
1287
+ {(linksLoading) && (
1288
+ <Row>
1289
+ <Col xs={12} className="py-2">
1290
+ <div className="text-center text-success">
1291
+ 正在努力加载中......
1292
+ </div>
1293
+ </Col>
1294
+ </Row>
1295
+ )}
1296
+ {linksData && (
1297
+ <Row>
1298
+ {videos.map((video, index) => (
1299
+ <Col xs={12} md={3} className="py-2">
1300
+ <Video video={video} />
1301
+ </Col>
1302
+ ))}
1303
+ </Row>
1304
+ )}
1305
+
1306
+
1307
+
1308
+
1309
+ <Row>
1310
+ <Col xs={12} className="py-2">
1311
+ <Paginate page={pagination.pageIndex} onClick={(i) => { onPaginationChange({ pageSize: 36, pageIndex: i }) }} itemsPerPage="36" totalCount={meta.filter_count} />
1312
+ </Col>
1313
+ </Row>
1314
+ </Container>
1315
+ </div>
1316
+ );
1317
+ };
1318
+
1319
+
1320
+ const Tags = () => {
1321
+ const { tag } = useParams()
1322
+ const [reload, setReload] = useState(false);
1323
+ const { limit, onPaginationChange, skip, pagination } = usePagination();
1324
+ const [meta, setMeta] = useState({ filter_count: 0 })
1325
+ const [keyword, setKeyword] = useState("")
1326
+ const [videos, setVideos] = useState([])
1327
+ const { data: linksData, refetch: linksRefetch, isLoading: linksLoading, error: linksError } = useQuery({
1328
+ queryKey: ['get_tag_links', limit, pagination.pageIndex],
1329
+ queryFn: () => paginateTagLinksGet(limit, pagination.pageIndex, tag),
1330
+ enabled: false,
1331
+ })
1332
+ useEffect(() => {
1333
+ linksRefetch()
1334
+ }, [pagination, reload]);
1335
+ useEffect(() => {
1336
+ linksRefetch()
1337
+ }, []);
1338
+ useEffect(() => {
1339
+ if (linksData) {
1340
+ setMeta({ filter_count: linksData.total })
1341
+ setVideos([...linksData.items])
1342
+ }
1343
+ }, [linksData]);
1344
+
1345
+
1346
+ const forceUpdate = () => {
1347
+ setReload((pre) => !pre);
1348
+ };
1349
+
1350
+ return (
1351
+ <div>
1352
+ <div className="d-flex justify-content-between align-items-center p-2 border-bottom bg-light">
1353
+ <label className="fs-3">视频列表</label>
1354
+ <ButtonToolbar
1355
+ aria-label="视频列表"
1356
+ className="bg-teal rounded"
1357
+ >
1358
+ <ButtonGroup className="bg-teal">
1359
+ <IconButton
1360
+ onClick={() => {
1361
+ forceUpdate();
1362
+ }}
1363
+ text="刷新"
1364
+ className="bg-teal border-0"
1365
+ icon="reload"
1366
+ iconClassName="me-1 text-white"
1367
+ iconSize="6"
1368
+ />
1369
+ </ButtonGroup>
1370
+ </ButtonToolbar>
1371
+ </div>
1372
+ {linksError && (
1373
+ <div className="text-center text-danger">
1374
+ 发生错误,请稍后重试!!!
1375
+ </div>
1376
+ )}
1377
+
1378
+ <Container fluid className="p-2">
1379
+ <Row>
1380
+ <Col xs={12} className="py-2">
1381
+ <Paginate page={pagination.pageIndex} onClick={(i) => { onPaginationChange({ pageSize: 36, pageIndex: i }) }} itemsPerPage="36" totalCount={meta.filter_count} />
1382
+ </Col>
1383
+ </Row>
1384
+ {(linksLoading) && (
1385
+ <Row>
1386
+ <Col xs={12} className="py-2">
1387
+ <div className="text-center text-success">
1388
+ 正在努力加载中......
1389
+ </div>
1390
+ </Col>
1391
+ </Row>
1392
+ )}
1393
+ {linksData && (
1394
+ <Row>
1395
+ {videos.map((video, index) => (
1396
+ <Col xs={12} md={3} className="py-2">
1397
+ <Video video={video} />
1398
+ </Col>
1399
+ ))}
1400
+ </Row>
1401
+ )}
1402
+
1403
+ <Row>
1404
+ <Col xs={12} className="py-2">
1405
+ <Paginate page={pagination.pageIndex} onClick={(i) => { onPaginationChange({ pageSize: 36, pageIndex: i }) }} itemsPerPage="36" totalCount={meta.filter_count} />
1406
+ </Col>
1407
+ </Row>
1408
+ </Container>
1409
+ </div>
1410
+ );
1411
+ };
1412
+
1413
+
1414
+ const Tasks = () => {
1415
+ const [show, setShow] = useState(false);
1416
+ const handleClose = () => setShow(false);
1417
+ const handleShow = () => setShow(true);
1418
+ const [reload, setReload] = useState(false);
1419
+ const { limit, onPaginationChange, skip, pagination } = usePagination();
1420
+ const [meta, setMeta] = useState({ filter_count: 0 })
1421
+ const [tasks, setTasks] = useState([])
1422
+ const { data: tasksData, refetch: tasksRefetch, isLoading: tasksLoading, error: tasksError } = useQuery({
1423
+ queryKey: ['get_paginate_tasks', limit, skip],
1424
+ queryFn: () => paginateTasksGet(limit, skip),
1425
+ enabled: show,
1426
+ })
1427
+
1428
+ useEffect(() => {
1429
+ //tasksRefetch()
1430
+ }, [pagination, reload]);
1431
+
1432
+ useEffect(() => {
1433
+ if (tasksData) {
1434
+ setMeta(tasksData.meta)
1435
+ setTasks([...tasksData.data])
1436
+ }
1437
+ }, [tasksData]);
1438
+
1439
+
1440
+ const forceUpdate = () => {
1441
+ setReload((pre) => !pre);
1442
+ };
1443
+
1444
+ return (
1445
+ <div>
1446
+ <Button
1447
+ style={{
1448
+ backgroundColor: "transparent",
1449
+ }}
1450
+ className="nav-link btn"
1451
+ onClick={handleShow}
1452
+ children={
1453
+ <span>
1454
+ <Icon
1455
+ icon="cloud-download-outline"
1456
+ size="3"
1457
+ className="text-white"
1458
+ />
1459
+ </span>
1460
+ }
1461
+ ></Button>
1462
+
1463
+
1464
+ <Modal show={show} onHide={handleClose}>
1465
+ <Modal.Header closeButton>
1466
+ <Modal.Title>远程下载任务</Modal.Title>
1467
+ </Modal.Header>
1468
+ <Modal.Body className="py-0">
1469
+ {tasksError && (
1470
+ <div className="text-center text-danger">
1471
+ 发生错误,请稍后重试!!!
1472
+ </div>
1473
+ )}
1474
+ {(tasksLoading) && (
1475
+ <div className="text-center text-success">
1476
+ 正在努力加载中......
1477
+ </div>
1478
+ )}
1479
+ <Container fluid className="p-2">
1480
+ <Row>
1481
+ <Col xs={12}>
1482
+ <Paginate page={pagination.pageIndex} onClick={(i) => { onPaginationChange({ pageSize: 36, pageIndex: i }) }} itemsPerPage="36" totalCount={meta.filter_count} />
1483
+ </Col>
1484
+ </Row>
1485
+
1486
+ <Row>
1487
+ <Col xs={12}>
1488
+ <Table bordered hover>
1489
+ <thead>
1490
+ <tr>
1491
+ <th>#</th>
1492
+ <th>文件名</th>
1493
+ <th>状态</th>
1494
+ </tr>
1495
+ </thead>
1496
+ {tasksData && (
1497
+ <tbody>
1498
+ {tasks.map((task, index) => (
1499
+ <tr>
1500
+ <td>{task.id}</td>
1501
+ <td>{task.url.substr(task.url.indexOf('##') + 2)}</td>
1502
+ <td>{task.status == 'draft' ? <span className="text-warning">待下载</span> : <span class="text-success">正在下载中</span>}</td>
1503
+ </tr>
1504
+ ))}
1505
+ </tbody>
1506
+ )}
1507
+
1508
+ </Table>
1509
+
1510
+ </Col>
1511
+ </Row>
1512
+
1513
+ <Row>
1514
+ <Col xs={12} className="py-2">
1515
+ <Paginate page={pagination.pageIndex} onClick={(i) => { onPaginationChange({ pageSize: 36, pageIndex: i }) }} itemsPerPage="36" totalCount={meta.filter_count} />
1516
+ </Col>
1517
+ </Row>
1518
+ </Container>
1519
+ </Modal.Body>
1520
+ <Modal.Footer className="justify-content-between">
1521
+ <Button variant="primary" onClick={() => { forceUpdate(); }}>
1522
+ 刷新
1523
+ </Button>
1524
+ <Button variant="primary" onClick={() => {
1525
+ const setting = STORE.getState().settings;
1526
+ showLoading();
1527
+ axios.post(setting.github_host, { "ref": "main", "inputs": {} }, {
1528
+ headers: {
1529
+ 'Authorization': "Bearer " + setting.github_token,
1530
+ 'Accept': 'application/vnd.github+json',
1531
+ 'X-GitHub-Api-Version': '2022-11-28',
1532
+ },
1533
+ }).then(function (response) {
1534
+ layer.msg('任务启动成功', { time: 2000, icon: 6 });
1535
+ //console.log(response);
1536
+ })
1537
+ .catch(function (error) {
1538
+ console.log(error);
1539
+ }).finally(() => {
1540
+ hideLoading();
1541
+ });
1542
+ }}>
1543
+ 开始下载
1544
+ </Button>
1545
+ <Button variant="primary" onClick={handleClose}>
1546
+ 关闭
1547
+ </Button>
1548
+ </Modal.Footer>
1549
+ </Modal>
1550
+
1551
+ </div >);
1552
+ };
1553
+
1554
+
1555
+
1556
+
1557
+
1558
+
1559
+
1560
+
1561
+ const LocalTasks = () => {
1562
+ const [show, setShow] = useState(false);
1563
+ const handleClose = () => setShow(false);
1564
+ const handleShow = () => setShow(true);
1565
+ const [downloads, setDownloads] = useState([])
1566
+ const [addDownloadObject, setAddDownloadObject] = useState({})
1567
+ const setting = STORE.getState().settings;
1568
+ const columns = [
1569
+ { title: "文件名称", dataIndex: "name" },
1570
+ { title: "大小", dataIndex: "size", render: (row) => (bytesToSize(row.size)) },
1571
+ { title: "日期", dataIndex: "created", render: (row) => (formatDate(row.created)) },
1572
+ {
1573
+ title: "操作",
1574
+ dataIndex: "name",
1575
+ render: (row) => (
1576
+ <div>
1577
+ <Icon
1578
+ icon="delete-outline"
1579
+ size="6"
1580
+ className="me-2"
1581
+ onClick={() => {
1582
+ layer.confirm('确定删除?该操作无法撤销!!!', { icon: 3 }, function (index) {
1583
+ setDownloads(
1584
+ downloads.filter(a =>
1585
+ a.name !== row.name
1586
+ )
1587
+ );
1588
+ layer.close(index);
1589
+ }, function () {
1590
+
1591
+ });
1592
+ }}
1593
+ />
1594
+ <Icon
1595
+ icon="pencil-outline"
1596
+ size="6"
1597
+ className="me-2"
1598
+ onClick={() => {
1599
+ layer.prompt({
1600
+ title: '输入文件名称,并确认',
1601
+ formType: 0,
1602
+ value: row.name,
1603
+ success: function (layero, index) {
1604
+ $(".layui-layer").eq(0).css("top", "0px");
1605
+ $("div[aria-modal]").eq(0).removeAttr("tabindex");//解决弹出窗的input无法获取焦点的问题
1606
+ },
1607
+ end: function (layero, index) {
1608
+ $("div[aria-modal]").eq(0).attr("tabindex", -1).focus();//再把焦点还回去
1609
+ }
1610
+ }, function (value, index) {
1611
+ const newDownloads = downloads.map(downloadItem => {
1612
+ if (downloadItem.name === row.name) {
1613
+ return {
1614
+ ...downloadItem,
1615
+ name: value
1616
+ };
1617
+ }
1618
+ return downloadItem;
1619
+ });
1620
+ setDownloads(newDownloads);
1621
+ layer.close(index);
1622
+ });
1623
+ }}
1624
+ />
1625
+ </div>
1626
+ ),
1627
+ },
1628
+ ];
1629
+
1630
+
1631
+ const { mutateAsync: localTaskdMutation } = useMutation({
1632
+ mutationKey: ["get-download"],
1633
+ mutationFn: async () => {
1634
+ showLoading();
1635
+ var host = setting.directus_host;
1636
+ if (!host.endsWith("/")) {
1637
+ host = host + '/'
1638
+ }
1639
+ var url = host + 'items/task';
1640
+ const tasks = downloads.map(task => {
1641
+ return { url: task.url + '##' + task.name }
1642
+ })
1643
+ return await axios.post(url, tasks, {
1644
+ headers: {
1645
+ 'Authorization': "Bearer " + setting.directus_token,
1646
+ 'Content-Type': 'application/json'
1647
+ },
1648
+ })
1649
+ },
1650
+ onSuccess: async (data, variables, context) => {
1651
+ hideLoading();
1652
+ layer.msg('任务添加成功', { time: 2000, icon: 6 });
1653
+ },
1654
+ onError: () => {
1655
+ hideLoading();
1656
+ layer.msg('任务添加失败', { time: 2000, icon: 5 });
1657
+ }
1658
+ })
1659
+
1660
+
1661
+
1662
+ const addDowload = (fileinfo) => {
1663
+ const file = fileinfo.data.data;
1664
+ const download = { name: file.name, size: file.size, url: file.raw_url, created: file.created }
1665
+ setAddDownloadObject(download)
1666
+ }
1667
+ useEffect(() => {
1668
+ if (addDownloadObject && ('name' in addDownloadObject)) {
1669
+ setDownloads([...downloads, addDownloadObject])
1670
+ setAddDownloadObject({})
1671
+ }
1672
+ }, [addDownloadObject]);
1673
+ useEffect(() => {
1674
+ onEvent("addDownload", addDowload)
1675
+ settingStorage.getItem('downloads').then(function (value) {
1676
+ if (value) {
1677
+ setDownloads(value)
1678
+ }
1679
+ }).catch(function (err) {
1680
+ console.log(err)
1681
+ });
1682
+ }, []);
1683
+ useEffect(() => {
1684
+ settingStorage.setItem('downloads', downloads)
1685
+ }, [downloads]);
1686
+
1687
+
1688
+ if (downloads.length > 0) {
1689
+ return (
1690
+ <div>
1691
+ <Button
1692
+ style={{
1693
+ backgroundColor: "transparent",
1694
+ }}
1695
+ className="nav-link btn"
1696
+ onClick={handleShow}
1697
+ children={
1698
+ <span>
1699
+ <Icon
1700
+ icon="download"
1701
+ size="3"
1702
+ className="text-white"
1703
+ />
1704
+ <Badge bg="danger" style={{ top: '-15px', left: '-10px' }}>{downloads.length}</Badge>
1705
+ </span>
1706
+ }
1707
+ ></Button>
1708
+
1709
+
1710
+ <Modal show={show} onHide={handleClose}>
1711
+ <Modal.Header closeButton>
1712
+ <Modal.Title>本地下载任务</Modal.Title>
1713
+ </Modal.Header>
1714
+ <Modal.Body>
1715
+ {downloads && (
1716
+ <DataTable data={downloads ? downloads : []} columns={columns} />
1717
+ )}
1718
+ </Modal.Body>
1719
+ <Modal.Footer className="justify-content-between">
1720
+
1721
+ <ButtonGroup>
1722
+ <Button variant="primary" onClick={async () => { await localTaskdMutation() }}>
1723
+ 添加转存
1724
+ </Button>
1725
+ </ButtonGroup>
1726
+
1727
+ <ButtonGroup>
1728
+ <Button variant="danger" onClick={() => {
1729
+ layer.confirm('确定删除?该操作无法撤销!!!', { icon: 3 }, function (index) {
1730
+ setDownloads([]);
1731
+ layer.close(index);
1732
+ }, function () {
1733
+
1734
+ });
1735
+ }}>
1736
+ 清空
1737
+ </Button>
1738
+ <Button variant="primary" onClick={handleClose}>
1739
+ 关闭
1740
+ </Button>
1741
+ </ButtonGroup>
1742
+
1743
+
1744
+ </Modal.Footer>
1745
+ </Modal>
1746
+
1747
+ </div >
1748
+ );
1749
+ }
1750
+ }
1751
+
1752
+
1753
+ App = () => {
1754
+ const [open, setOpen] = useState(false);
1755
+ const [reload, setReload] = useState(false);
1756
+ const [response, error, loading, fetchDataByPage] = getFiles();
1757
+ const { folder } = useParams();
1758
+ const location = useLocation();
1759
+ const [path, setPath] = useState(decodeURI(location.pathname));
1760
+ const [page, setPage] = useState(1);
1761
+ const [query, setQuery] = useState({ "path": path, "password": "", "page": page, "per_page": 0, "refresh": true });
1762
+ const setting = STORE.getState().settings;
1763
+
1764
+
1765
+ //const queryClient = useQueryClient()
1766
+ // Queries
1767
+ //const { data, error, isLoading, refetch } = useQuery({
1768
+ // queryKey: ['test'], queryFn: () => axios.get("")
1769
+ //})
1770
+
1771
+ const { data: fileData, mutateAsync: downloadMutation } = useMutation({
1772
+ mutationKey: ["get-download"],
1773
+ mutationFn: async (fileinfo) => {
1774
+ showLoading();
1775
+ var host = setting.alist_host;
1776
+ if (!host.endsWith("/")) {
1777
+ host = host + '/'
1778
+ }
1779
+ var url = host + 'api/fs/get';
1780
+ return await axios.post(url, fileinfo, {
1781
+ headers: {
1782
+ 'Authorization': setting.alist_token,
1783
+ 'Content-Type': 'application/json'
1784
+ },
1785
+ })
1786
+ },
1787
+ onSuccess: async (data, variables, context) => {
1788
+ hideLoading();
1789
+ },
1790
+ onError: () => {
1791
+ hideLoading();
1792
+ }
1793
+ })
1794
+
1795
+ useEffect(() => {
1796
+ if (fileData) {
1797
+ emitEvent("addDownload", fileData)
1798
+ }
1799
+ }, [fileData]);
1800
+
1801
+
1802
+ const columns = [
1803
+ { title: "文件名称", dataIndex: "name" },
1804
+ { title: "大小", dataIndex: "size", render: (row) => (bytesToSize(row.size)) },
1805
+ { title: "日期", dataIndex: "created", render: (row) => (formatDate(row.created)) },
1806
+ {
1807
+ title: "操作",
1808
+ dataIndex: "name",
1809
+ render: (row) => (
1810
+ row.is_dir ? <Nav.Link
1811
+ as={Link}
1812
+ className="nav-link text-dark"
1813
+ to={decodeURI(path + row.name + '/')}
1814
+ target="_blank"
1815
+ >
1816
+ <Icon
1817
+ icon="open-in-new"
1818
+ size="6"
1819
+ className="me-2"
1820
+ />
1821
+ </Nav.Link> :
1822
+ <Icon
1823
+ icon="download-outline"
1824
+ size="6"
1825
+ className="me-2"
1826
+ onClick={async () => {
1827
+ let data = { "path": path + row.name, "password": "" }
1828
+ await downloadMutation(data);
1829
+ }}
1830
+ />
1831
+ ),
1832
+ },
1833
+ ];
1834
+ useEffect(() => {
1835
+ if (!setting.alist_token || setting.alist_token.length < 5) {
1836
+ layer.alert("请先正确配置Alsit的令牌", { icon: 5 });
1837
+ return
1838
+ }
1839
+ fetchDataByPage(setting, query);
1840
+ return () => { }
1841
+ }, [reload, query]);
1842
+
1843
+
1844
+ const forceUpdate = () => {
1845
+ setReload((pre) => !pre);
1846
+ };
1847
+
1848
+ return (
1849
+ <div>
1850
+ <div className="d-flex justify-content-between align-items-center p-2 border-bottom bg-light">
1851
+ <label className="fs-3">文件列表</label>
1852
+ <ButtonToolbar
1853
+ aria-label="功能区"
1854
+ className="bg-teal rounded"
1855
+ >
1856
+ <ButtonGroup className="bg-teal">
1857
+ <IconButton
1858
+ onClick={() => {
1859
+ emitEvent("test", { a: 'b' })
1860
+ }}
1861
+ text="刷新"
1862
+ className="bg-teal border-0"
1863
+ icon="reload"
1864
+ iconClassName="me-1 text-white"
1865
+ iconSize="6"
1866
+ />
1867
+ </ButtonGroup>
1868
+ </ButtonToolbar>
1869
+ </div>
1870
+ <Container fluid className="p-2">
1871
+ {error && (
1872
+ <div className="text-center text-danger">
1873
+ {error}
1874
+ </div>
1875
+ )}
1876
+ {(loading) && (
1877
+ <div className="text-center text-success">
1878
+ 正在努力加载中......
1879
+ </div>
1880
+ )}
1881
+ {response && (
1882
+ <DataTable data={response.data.content ? response.data.content : []} columns={columns} />
1883
+ )}
1884
+ </Container>
1885
+ </div>
1886
+ );
1887
+ };
1888
+
1889
+ const container = document.getElementById("root");
1890
+ const root = ReactDOM.createRoot(container);
1891
+ root.render(
1892
+ <QueryClientProvider client={queryClient}>
1893
+ <HashRouter>
1894
+ <Route path="/:path?">
1895
+ <Layout>
1896
+ <Switch>
1897
+ <Route path="/videos" exact component={Videos} />
1898
+ <Route path="/tags/:tag?" exact component={Tags} />
1899
+ <Route path="/" exact component={Favorites} />
1900
+ </Switch>
1901
+ </Layout>
1902
+ </Route>
1903
+ </HashRouter>
1904
+ </QueryClientProvider>
1905
+ );
1906
+
1907
+ $(document).ready(function () {
1908
+ $(window).scroll(function () {
1909
+ if ($(this).scrollTop() > 50) {
1910
+ $("#back-to-top").fadeIn();
1911
+ } else {
1912
+ $("#back-to-top").fadeOut();
1913
+ }
1914
+ });
1915
+ // scroll body to 0px on click
1916
+ $("#back-to-top").click(function () {
1917
+ $("body,html").animate(
1918
+ {
1919
+ scrollTop: 0,
1920
+ },
1921
+ 400
1922
+ );
1923
+ return false;
1924
+ });
1925
+ });
1926
+ </script>
1927
+ </body>
1928
+
1929
+ </html>