From 6e7225ac4087f9dff3b79071f2174fc378e193f3 Mon Sep 17 00:00:00 2001 From: shiyu Date: Mon, 8 Sep 2025 16:51:09 +0800 Subject: [PATCH] feat: implement Quark adapter --- services/adapters/quark.py | 724 +++++++++++++++++++++++++++++++++++++ 1 file changed, 724 insertions(+) create mode 100644 services/adapters/quark.py diff --git a/services/adapters/quark.py b/services/adapters/quark.py new file mode 100644 index 0000000..6c1c9d5 --- /dev/null +++ b/services/adapters/quark.py @@ -0,0 +1,724 @@ +from __future__ import annotations +import asyncio +import base64 +import hashlib +import mimetypes +import os +import time +from typing import Dict, List, Tuple, Optional, AsyncIterator, Any + +import httpx +from fastapi import HTTPException +from fastapi.responses import StreamingResponse + +from models import StorageAdapter +from .base import BaseAdapter + + +# Quark 普通(UC)接口 +API_BASE = "https://drive.quark.cn/1/clouddrive" +REFERER = "https://pan.quark.cn" +PR = "ucpro" + + +class QuarkAdapter: + """夸克网盘(Cookie 模式) + + - 使用浏览器导出的 Cookie 进行鉴权 + - 通过 Quark/UC 的 clouddrive 接口实现:列目录、读写、分片上传、基础操作 + - 根 FID 固定为 "0";路径解析通过名称遍历 + """ + + def __init__(self, record: StorageAdapter): + self.record = record + cfg = record.config or {} + self.cookie: str = cfg.get("cookie") or cfg.get("Cookie") + self.root_fid: str = cfg.get("root_fid", "0") + self.use_transcoding_address: bool = bool(cfg.get("use_transcoding_address", False)) + self.only_list_video_file: bool = bool(cfg.get("only_list_video_file", False)) + + if not self.cookie: + raise ValueError("Quark 适配器需要 cookie 配置") + + # 运行期缓存 + self._dir_fid_cache: Dict[str, str] = {f"{self.root_fid}:": self.root_fid} + self._children_cache: Dict[str, List[Dict[str, Any]]] = {} + + # UA 与超时 + self._ua = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) quark-cloud-drive/2.5.20 Chrome/100.0.4896.160 " + "Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch" + ) + self._timeout = 30.0 + + # ----------------- + # 工具与通用请求 + # ----------------- + def get_effective_root(self, sub_path: str | None) -> str: + return self.root_fid + + async def _request( + self, + method: str, + pathname: str, + *, + json: Any | None = None, + params: Dict[str, str] | None = None, + ) -> Any: + headers = { + "Cookie": self._safe_cookie(self.cookie), + "Accept": "application/json, text/plain, */*", + "Referer": REFERER, + "User-Agent": self._ua, + } + query = {"pr": PR, "fr": "pc"} + if params: + query.update(params) + url = f"{API_BASE}{pathname}" + + async with httpx.AsyncClient(timeout=self._timeout) as client: + resp = await client.request(method, url, headers=headers, params=query, json=json) + # 更新运行期 cookie(若返回 __puus/__pus) + try: + for key in ("__puus", "__pus"): + v = resp.cookies.get(key) + if v: + # 简单替换/追加到 self.cookie + self._set_cookie_kv(key, v) + except Exception: + pass + + # 解析业务状态 + data = None + try: + data = resp.json() + except Exception: + resp.raise_for_status() + return resp + status = data.get("status") + code = data.get("code") + msg = data.get("message") or "" + if (status is not None and status >= 400) or (code is not None and code != 0): + raise HTTPException(502, detail=f"Quark error status={status} code={code} msg={msg}") + return data + + def _set_cookie_kv(self, key: str, value: str): + # 将指定键值写入 self.cookie(粗略字符串处理) + parts = [p.strip() for p in (self.cookie or "").replace("\r", "").replace("\n", "").split(";") if p.strip()] + found = False + for i, p in enumerate(parts): + if p.startswith(key + "="): + parts[i] = f"{key}={value}" + found = True + break + if not found: + parts.append(f"{key}={value}") + self.cookie = "; ".join(parts) + + def _sanitize_cookie(self, cookie: str) -> str: + if not cookie: + return "" + # 去除换行与前后空白 + cookie = cookie.replace("\r", "").replace("\n", "").strip() + # 统一分号分隔并去除多余空格/空段 + parts = [p.strip() for p in cookie.split(";") if p.strip()] + return "; ".join(parts) + + def _safe_cookie(self, cookie: str) -> str: + s = self._sanitize_cookie(cookie) + # 仅保留可见 ASCII (0x20-0x7E) + s = "".join(ch for ch in s if 32 <= ord(ch) <= 126) + return s + + # ----------------- + # 列表与路径解析 + # ----------------- + def _map_file_item(self, it: Dict[str, Any]) -> Dict[str, Any]: + # Quark/UC 列表项:file=true 表示文件;false 表示目录 + is_dir = not bool(it.get("file", False)) + updated_at_ms = int(it.get("updated_at", 0) or 0) + name = it.get("file_name") or it.get("filename") or it.get("name") + return { + "fid": it.get("fid"), + "name": name, + "is_dir": is_dir, + "size": 0 if is_dir else int(it.get("size", 0) or 0), + "mtime": updated_at_ms // 1000 if updated_at_ms else 0, + "type": "dir" if is_dir else "file", + } + + async def _list_children(self, parent_fid: str) -> List[Dict[str, Any]]: + if parent_fid in self._children_cache: + return self._children_cache[parent_fid] + + files: List[Dict[str, Any]] = [] + page = 1 + size = 100 + total = None + while True: + qp = {"pdir_fid": parent_fid, "_size": str(size), "_page": str(page), "_fetch_total": "1"} + data = await self._request("GET", "/file/sort", params=qp) + d = (data or {}).get("data", {}) + meta = (data or {}).get("metadata", {}) + page_files = d.get("list", []) + files.extend(page_files) + if total is None: + total = meta.get("_total") or meta.get("total") or 0 + if page * size >= int(total): + break + page += 1 + + mapped = [self._map_file_item(x) for x in files if (not self.only_list_video_file) or (not x.get("file")) or (x.get("category") == 1)] + self._children_cache[parent_fid] = mapped + return mapped + + def _dir_cache_key(self, base_fid: str, rel: str) -> str: + return f"{base_fid}:{rel.strip('/')}" + + async def _resolve_dir_fid_from(self, base_fid: str, rel: str) -> str: + key = rel.strip("/") + cache_key = self._dir_cache_key(base_fid, key) + if cache_key in self._dir_fid_cache: + return self._dir_fid_cache[cache_key] + if key == "": + self._dir_fid_cache[cache_key] = base_fid + return base_fid + + parent_fid = base_fid + path_so_far = [] + for seg in key.split("/"): + if seg == "": + continue + path_so_far.append(seg) + cache_key = self._dir_cache_key(base_fid, "/".join(path_so_far)) + cached = self._dir_fid_cache.get(cache_key) + if cached: + parent_fid = cached + continue + children = await self._list_children(parent_fid) + found = next((c for c in children if c["is_dir"] and c["name"] == seg), None) + if not found: + raise FileNotFoundError(f"Directory not found: {seg}") + parent_fid = found["fid"] + self._dir_fid_cache[cache_key] = parent_fid + + return parent_fid + + async def _find_child(self, parent_fid: str, name: str) -> Optional[Dict[str, Any]]: + children = await self._list_children(parent_fid) + for it in children: + if it["name"] == name: + return it + return None + + def _invalidate_children_cache(self, parent_fid: str): + if parent_fid in self._children_cache: + try: + del self._children_cache[parent_fid] + except Exception: + pass + + # ----------------- + # 目录与文件列表 + # ----------------- + async def list_dir( + self, + root: str, + rel: str, + page_num: int = 1, + page_size: int = 50, + sort_by: str = "name", + sort_order: str = "asc", + ) -> Tuple[List[Dict], int]: + base_fid = root or self.root_fid + fid = await self._resolve_dir_fid_from(base_fid, rel) + items = await self._list_children(fid) + + # 排序,目录优先 + reverse = sort_order.lower() == "desc" + + def get_sort_key(item): + key = (not item["is_dir"],) + sf = sort_by.lower() + if sf == "name": + key += (item["name"].lower(),) + elif sf == "size": + key += (item["size"],) + elif sf == "mtime": + key += (item["mtime"],) + else: + key += (item["name"].lower(),) + return key + + items.sort(key=get_sort_key, reverse=reverse) + total = len(items) + start = (page_num - 1) * page_size + end = start + page_size + return items[start:end], total + + # ----------------- + # 下载与流式下载 + # ----------------- + async def _get_download_url(self, fid: str) -> str: + data = await self._request("POST", "/file/download", json={"fids": [fid]}) + arr = (data or {}).get("data", []) + if not arr: + raise HTTPException(502, detail="No download data returned by Quark") + url = arr[0].get("download_url") or arr[0].get("DownloadUrl") + if not url: + raise HTTPException(502, detail="No download_url returned by Quark") + return url + + async def _get_transcoding_url(self, fid: str) -> Optional[str]: + try: + payload = {"fid": fid, "resolutions": "low,normal,high,super,2k,4k", "supports": "fmp4_av,m3u8,dolby_vision"} + data = await self._request("POST", "/file/v2/play/project", json=payload) + lst = (data or {}).get("data", {}).get("video_list", []) + for item in lst: + vi = item.get("video_info") or {} + url = vi.get("url") + if url: + return url + except Exception: + return None + return None + + def _is_video_name(self, name: str) -> bool: + mime, _ = mimetypes.guess_type(name) + return bool(mime and mime.startswith("video/")) + + def _download_headers(self) -> Dict[str, str]: + return {"Cookie": self._safe_cookie(self.cookie), "User-Agent": self._ua, "Referer": REFERER} + + async def read_file(self, root: str, rel: str) -> bytes: + if not rel or rel.endswith("/"): + raise IsADirectoryError("Path is a directory") + parent = rel.rsplit("/", 1)[0] if "/" in rel else "" + name = rel.rsplit("/", 1)[-1] + base_fid = root or self.root_fid + parent_fid = await self._resolve_dir_fid_from(base_fid, parent) + it = await self._find_child(parent_fid, name) + if not it or it["is_dir"]: + raise FileNotFoundError(rel) + url = await self._get_download_url(it["fid"]) + headers = self._download_headers() + async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client: + resp = await client.get(url, headers=headers) + if resp.status_code == 404: + raise FileNotFoundError(rel) + resp.raise_for_status() + return resp.content + + async def stream_file(self, root: str, rel: str, range_header: str | None): + if not rel or rel.endswith("/"): + raise IsADirectoryError("Path is a directory") + parent = rel.rsplit("/", 1)[0] if "/" in rel else "" + name = rel.rsplit("/", 1)[-1] + base_fid = root or self.root_fid + parent_fid = await self._resolve_dir_fid_from(base_fid, parent) + it = await self._find_child(parent_fid, name) + if not it or it["is_dir"]: + raise FileNotFoundError(rel) + url = await self._get_download_url(it["fid"]) + if self.use_transcoding_address and self._is_video_name(name): + tr = await self._get_transcoding_url(it["fid"]) + if tr: + url = tr + dl_headers = self._download_headers() + + # 预获取大小/是否支持范围 + total_size: Optional[int] = None + async with httpx.AsyncClient(timeout=self._timeout, follow_redirects=True) as client: + try: + head_resp = await client.head(url, headers=dl_headers) + if head_resp.status_code == 200: + cl = head_resp.headers.get("Content-Length") + if cl and cl.isdigit(): + total_size = int(cl) + except Exception: + pass + + mime, _ = mimetypes.guess_type(rel) + content_type = mime or "application/octet-stream" + + # 解析 Range + start = 0 + end: Optional[int] = None + status_code = 200 + if range_header and range_header.startswith("bytes="): + status_code = 206 + part = range_header.split("=", 1)[1] + s, e = part.split("-", 1) + if s.strip(): + start = int(s) + if e.strip(): + end = int(e) + + if total_size is not None and end is None and status_code == 206: + end = total_size - 1 + if end is not None and total_size is not None and end >= total_size: + end = total_size - 1 + if total_size is not None and start >= total_size: + raise HTTPException(416, detail="Requested Range Not Satisfiable") + + resp_headers: Dict[str, str] = {"Accept-Ranges": "bytes", "Content-Type": content_type} + if status_code == 206 and total_size is not None and end is not None: + resp_headers["Content-Range"] = f"bytes {start}-{end}/{total_size}" + resp_headers["Content-Length"] = str(end - start + 1) + elif total_size is not None: + resp_headers["Content-Length"] = str(total_size) + + async def iterator(): + headers = dict(dl_headers) + if status_code == 206 and end is not None: + headers["Range"] = f"bytes={start}-{end}" + async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client: + async with client.stream("GET", url, headers=headers) as resp: + if resp.status_code in (404, 416): + await resp.aclose() + raise HTTPException(resp.status_code, detail="Upstream not available") + async for chunk in resp.aiter_bytes(): + if chunk: + yield chunk + + return StreamingResponse(iterator(), status_code=status_code, headers=resp_headers, media_type=content_type) + + # ----------------- + # 上传(大文件分片) + # ----------------- + @staticmethod + def _md5_hex(b: bytes) -> str: + return hashlib.md5(b).hexdigest() + + @staticmethod + def _sha1_hex(b: bytes) -> str: + return hashlib.sha1(b).hexdigest() + + def _guess_mime(self, name: str) -> str: + mime, _ = mimetypes.guess_type(name) + return mime or "application/octet-stream" + + async def _upload_pre(self, filename: str, size: int, parent_fid: str) -> Dict[str, Any]: + now_ms = int(time.time() * 1000) + body = { + "ccp_hash_update": True, + "dir_name": "", + "file_name": filename, + "format_type": self._guess_mime(filename), + "l_created_at": now_ms, + "l_updated_at": now_ms, + "pdir_fid": parent_fid, + "size": size, + } + data = await self._request("POST", "/file/upload/pre", json=body) + return data + + async def write_file(self, root: str, rel: str, data: bytes): + async def gen(): + yield data + return await self.write_file_stream(root, rel, gen()) + + async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]): + if not rel or rel.endswith("/"): + raise HTTPException(400, detail="Invalid file path") + + parent = rel.rsplit("/", 1)[0] if "/" in rel else "" + name = rel.rsplit("/", 1)[-1] + base_fid = root or self.root_fid + parent_fid = await self._resolve_dir_fid_from(base_fid, parent) + + # 将数据落盘到临时文件,同时计算 MD5/SHA1 + import tempfile + + md5 = hashlib.md5() + sha1 = hashlib.sha1() + total = 0 + with tempfile.NamedTemporaryFile(delete=False) as tf: + tmp_path = tf.name + try: + async for chunk in data_iter: + if not chunk: + continue + total += len(chunk) + md5.update(chunk) + sha1.update(chunk) + tf.write(chunk) + finally: + tf.flush() + + md5_hex = md5.hexdigest() + sha1_hex = sha1.hexdigest() + + # 预上传,拿到上传信息 + pre_resp = await self._upload_pre(name, total, parent_fid) + pre_data = pre_resp.get("data", {}) + + # hash 秒传 + hash_body = {"md5": md5_hex, "sha1": sha1_hex, "task_id": pre_data.get("task_id")} + hash_resp = await self._request("POST", "/file/update/hash", json=hash_body) + if (hash_resp.get("data") or {}).get("finish") is True: + try: + os.unlink(tmp_path) + except Exception: + pass + # 刷新父目录缓存 + self._invalidate_children_cache(parent_fid) + return total + + # 分片上传 + part_size = int((pre_resp.get("metadata") or {}).get("part_size") or 0) + if part_size <= 0: + raise HTTPException(502, detail="Invalid part_size from Quark") + + bucket = pre_data.get("bucket") + obj_key = pre_data.get("obj_key") + upload_id = pre_data.get("upload_id") + upload_url = pre_data.get("upload_url") + if not (bucket and obj_key and upload_id and upload_url): + raise HTTPException(502, detail="Upload pre missing fields") + + # 计算 host 与基础 URL + try: + upload_host = upload_url.split("://", 1)[1] + except Exception: + upload_host = upload_url + base_url = f"https://{bucket}.{upload_host}/{obj_key}" + + # 分片循环 + etags: List[str] = [] + oss_ua = "aliyun-sdk-js/6.6.1 Chrome 98.0.4758.80 on Windows 10 64-bit" + async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client: + with open(tmp_path, "rb") as rf: + part_number = 1 + left = total + while left > 0: + sz = min(part_size, left) + data_bytes = rf.read(sz) + if len(data_bytes) != sz: + raise IOError("Failed to read part bytes") + now_str = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime()) + # 申请签名 + auth_meta = ( + "PUT\n\n" + f"{self._guess_mime(name)}\n" + f"{now_str}\n" + f"x-oss-date:{now_str}\n" + f"x-oss-user-agent:{oss_ua}\n" + f"/{bucket}/{obj_key}?partNumber={part_number}&uploadId={upload_id}" + ) + auth_req_body = {"auth_info": pre_data.get("auth_info"), "auth_meta": auth_meta, "task_id": pre_data.get("task_id")} + auth_resp = await self._request("POST", "/file/upload/auth", json=auth_req_body) + auth_key = (auth_resp.get("data") or {}).get("auth_key") + if not auth_key: + raise HTTPException(502, detail="upload/auth missing auth_key") + + put_headers = { + "Authorization": auth_key, + "Content-Type": self._guess_mime(name), + "Referer": REFERER + "/", + "x-oss-date": now_str, + "x-oss-user-agent": oss_ua, + } + put_url = f"{base_url}?partNumber={part_number}&uploadId={upload_id}" + put_resp = await client.put(put_url, headers=put_headers, content=data_bytes) + if put_resp.status_code != 200: + raise HTTPException(502, detail=f"Upload part failed status={put_resp.status_code} text={put_resp.text}") + etag = put_resp.headers.get("Etag", "") + etags.append(etag) + left -= sz + part_number += 1 + + # 组合 commit xml + parts_xml = [f"\n{i+1}\n{etags[i]}\n\n" for i in range(len(etags))] + body_xml = "\n\n" + "".join(parts_xml) + "" + content_md5 = base64.b64encode(hashlib.md5(body_xml.encode("utf-8")).digest()).decode("ascii") + callback = pre_data.get("callback") or {} + try: + import json as _json + callback_b64 = base64.b64encode(_json.dumps(callback).encode("utf-8")).decode("ascii") + except Exception: + callback_b64 = "" + + now_str = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime()) + auth_meta_commit = ( + "POST\n" + f"{content_md5}\n" + "application/xml\n" + f"{now_str}\n" + f"x-oss-callback:{callback_b64}\n" + f"x-oss-date:{now_str}\n" + f"x-oss-user-agent:{oss_ua}\n" + f"/{bucket}/{obj_key}?uploadId={upload_id}" + ) + auth_commit_resp = await self._request("POST", "/file/upload/auth", json={"auth_info": pre_data.get("auth_info"), "auth_meta": auth_meta_commit, "task_id": pre_data.get("task_id")}) + auth_key_commit = (auth_commit_resp.get("data") or {}).get("auth_key") + if not auth_key_commit: + raise HTTPException(502, detail="upload/auth(commit) missing auth_key") + + async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client: + commit_headers = { + "Authorization": auth_key_commit, + "Content-MD5": content_md5, + "Content-Type": "application/xml", + "Referer": REFERER + "/", + "x-oss-callback": callback_b64, + "x-oss-date": now_str, + "x-oss-user-agent": oss_ua, + } + commit_url = f"{base_url}?uploadId={upload_id}" + r = await client.post(commit_url, headers=commit_headers, content=body_xml.encode("utf-8")) + if r.status_code != 200: + raise HTTPException(502, detail=f"Upload commit failed status={r.status_code} text={r.text}") + + # finish + await self._request("POST", "/file/upload/finish", json={"obj_key": obj_key, "task_id": pre_data.get("task_id")}) + # 端合并存在轻微延迟,等待再刷新缓存 + try: + await asyncio.sleep(1.0) + except Exception: + pass + + try: + os.unlink(tmp_path) + except Exception: + pass + # 失效父目录缓存,确保后续列表可见 + self._invalidate_children_cache(parent_fid) + return total + + # ----------------- + # 基本文件操作 + # ----------------- + async def mkdir(self, root: str, rel: str): + if not rel or rel == "/": + raise HTTPException(400, detail="Cannot create root") + parent = rel.rstrip("/") + parent_rel, name = (parent.rsplit("/", 1) if "/" in parent else ("", parent)) + if not name: + raise HTTPException(400, detail="Invalid directory name") + pdir = await self._resolve_dir_fid_from(root or self.root_fid, parent_rel) + await self._request("POST", "/file", json={"dir_init_lock": False, "dir_path": "", "file_name": name, "pdir_fid": pdir}) + self._invalidate_children_cache(pdir) + + async def delete(self, root: str, rel: str): + # 解析对象 fid + 父目录,用于失效缓存 + base_fid = root or self.root_fid + if rel == "" or rel.endswith("/"): + parent_rel = rel.rstrip("/") + target_fid = await self._resolve_dir_fid_from(base_fid, parent_rel) + parent_of_target = await self._resolve_dir_fid_from(base_fid, (parent_rel.rsplit("/", 1)[0] if "/" in parent_rel else "")) + else: + parent_rel, name = (rel.rsplit("/", 1) if "/" in rel else ("", rel)) + parent_of_target = await self._resolve_dir_fid_from(base_fid, parent_rel) + it = await self._find_child(parent_of_target, name) + if not it: + return + target_fid = it["fid"] + await self._request("POST", "/file/delete", json={"action_type": 1, "exclude_fids": [], "filelist": [target_fid]}) + self._invalidate_children_cache(parent_of_target) + + async def move(self, root: str, src_rel: str, dst_rel: str): + # 支持跨目录与重命名:先移动到父目录,后重命名(若需要) + src_parent_rel, src_name = (src_rel.rsplit("/", 1) if "/" in src_rel else ("", src_rel)) + dst_parent_rel, dst_name = (dst_rel.rsplit("/", 1) if "/" in dst_rel else ("", dst_rel)) + + base_fid = root or self.root_fid + src_parent_fid = await self._resolve_dir_fid_from(base_fid, src_parent_rel) + obj = await self._find_child(src_parent_fid, src_name) + if not obj: + raise FileNotFoundError(src_rel) + dst_parent_fid = await self._resolve_dir_fid_from(base_fid, dst_parent_rel) + + if src_parent_fid != dst_parent_fid: + await self._request("POST", "/file/move", json={"action_type": 1, "exclude_fids": [], "filelist": [obj["fid"]], "to_pdir_fid": dst_parent_fid}) + self._invalidate_children_cache(src_parent_fid) + self._invalidate_children_cache(dst_parent_fid) + + if obj["name"] != dst_name: + await self._request("POST", "/file/rename", json={"fid": obj["fid"], "file_name": dst_name}) + self._invalidate_children_cache(dst_parent_fid) + + async def rename(self, root: str, src_rel: str, dst_rel: str): + src_parent_rel, src_name = (src_rel.rsplit("/", 1) if "/" in src_rel else ("", src_rel)) + base_fid = root or self.root_fid + src_parent_fid = await self._resolve_dir_fid_from(base_fid, src_parent_rel) + obj = await self._find_child(src_parent_fid, src_name) + if not obj: + raise FileNotFoundError(src_rel) + dst_name = dst_rel.rsplit("/", 1)[-1] + await self._request("POST", "/file/rename", json={"fid": obj["fid"], "file_name": dst_name}) + self._invalidate_children_cache(src_parent_fid) + + async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False): + raise NotImplementedError("QuarkOpen does not support copy via open API") + + # ----------------- + # STAT / EXISTS / 辅助 + # ----------------- + async def stat_file(self, root: str, rel: str): + # 通过父目录列表获取元数据 + base_fid = root or self.root_fid + if rel == "" or rel.endswith("/"): + # 目录 + fid = await self._resolve_dir_fid_from(base_fid, rel.rstrip("/")) + return {"name": rel.rstrip("/").split("/")[-1] if rel else "", "is_dir": True, "size": 0, "mtime": 0, "type": "dir", "fid": fid} + parent_rel, name = (rel.rsplit("/", 1) if "/" in rel else ("", rel)) + parent_fid = await self._resolve_dir_fid_from(base_fid, parent_rel) + it = await self._find_child(parent_fid, name) + if not it: + raise FileNotFoundError(rel) + return it + + async def exists(self, root: str, rel: str) -> bool: + try: + base_fid = root or self.root_fid + if rel == "" or rel.endswith("/"): + await self._resolve_dir_fid_from(base_fid, rel.rstrip("/")) + return True + parent_rel, name = (rel.rsplit("/", 1) if "/" in rel else ("", rel)) + parent_fid = await self._resolve_dir_fid_from(base_fid, parent_rel) + it = await self._find_child(parent_fid, name) + return it is not None + except FileNotFoundError: + return False + + async def stat_path(self, root: str, rel: str): + # 用于 move/copy 前的预检查调试 + try: + base_fid = root or self.root_fid + if rel == "" or rel.endswith("/"): + fid = await self._resolve_dir_fid_from(base_fid, rel.rstrip("/")) + return {"exists": True, "is_dir": True, "path": rel, "fid": fid} + parent_rel, name = (rel.rsplit("/", 1) if "/" in rel else ("", rel)) + parent_fid = await self._resolve_dir_fid_from(base_fid, parent_rel) + it = await self._find_child(parent_fid, name) + if it: + return {"exists": True, "is_dir": it["is_dir"], "path": rel, "fid": it["fid"]} + return {"exists": False, "is_dir": None, "path": rel} + except FileNotFoundError: + return {"exists": False, "is_dir": None, "path": rel} + + async def _resolve_target_fid(self, rel: str, *, base_fid: Optional[str] = None) -> str: + base = base_fid or self.root_fid + if rel == "" or rel.endswith("/"): + return await self._resolve_dir_fid_from(base, rel.rstrip("/")) + parent_rel, name = (rel.rsplit("/", 1) if "/" in rel else ("", rel)) + parent_fid = await self._resolve_dir_fid_from(base, parent_rel) + it = await self._find_child(parent_fid, name) + if not it: + raise FileNotFoundError(rel) + return it["fid"] + + +ADAPTER_TYPE = "Quark" + +CONFIG_SCHEMA = [ + {"key": "cookie", "label": "Cookie", "type": "password", "required": True, "placeholder": "从 pan.quark.cn 复制"}, + {"key": "root_fid", "label": "根 FID", "type": "string", "required": False, "default": "0"}, + {"key": "use_transcoding_address", "label": "视频转码直链", "type": "checkbox", "required": False, "default": False}, + {"key": "only_list_video_file", "label": "仅列出视频文件", "type": "checkbox", "required": False, "default": False}, +] + +def ADAPTER_FACTORY(rec: StorageAdapter) -> BaseAdapter: + return QuarkAdapter(rec)