feat(adapters): add SMB share adapter
This commit is contained in:
1 parent
a5b57a77d5
commit
980a1570db
8 files changed
+335
-2
No files matched your search
@@ -39,7 +39,7 @@ Utilizes an extensible adapter pattern to easily integrate various storage types
|
||||
|
||||
| Category | Adapters |
|
||||
|---|---|
|
||||
| **Standard Protocols** | Local, S3-compatible, WebDAV, SFTP, FTP |
|
||||
| **Standard Protocols** | Local, S3-compatible, WebDAV, SFTP, FTP, SMB |
|
||||
| **Cloud Drives** | Google Drive, OneDrive, Dropbox, Quark, UC, 123Pan, 115, 189 |
|
||||
| **Special** | Telegram, AList, Foxel-to-Foxel |
|
||||
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@
|
||||
|
||||
| 分类 | 适配器 |
|
||||
|---|---|
|
||||
| **标准协议** | 本地存储、S3 兼容存储、WebDAV、SFTP、FTP |
|
||||
| **标准协议** | 本地存储、S3 兼容存储、WebDAV、SFTP、FTP、SMB |
|
||||
| **网盘服务** | Google Drive、OneDrive、Dropbox、夸克网盘、UC 网盘、123 网盘、115 网盘、天翼云盘 |
|
||||
| **特殊类型** | Telegram、AList、Foxel 互联 |
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import posixpath
|
||||
import stat as statmod
|
||||
|
||||
import smbclient
|
||||
from smbclient import shutil as smb_shutil
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from ._remote import clean_path, entry, paginate
|
||||
|
||||
|
||||
class SMBAdapter:
|
||||
"""SMB2/3 share adapter backed by smbprotocol."""
|
||||
|
||||
def __init__(self, record):
|
||||
self.record = record
|
||||
cfg = record.config or {}
|
||||
self.server = str(cfg.get("server") or cfg.get("address") or "").strip()
|
||||
self.share = str(cfg.get("share") or cfg.get("share_name") or "").strip("/")
|
||||
self.username = cfg.get("username") or ""
|
||||
self.password = cfg.get("password") or ""
|
||||
self.root = clean_path(cfg.get("root") or "")
|
||||
self.timeout = float(cfg.get("timeout", 30))
|
||||
self.port = int(cfg.get("port", 445))
|
||||
if not self.server or not self.share:
|
||||
raise ValueError("SMB requires server and share")
|
||||
if any(c in self.server for c in "/\\") or any(c in self.share for c in "/\\"):
|
||||
raise ValueError("SMB server and share must be single path components")
|
||||
self.base = "\\\\" + self.server.strip("\\/") + "\\" + self.share.replace("/", "\\")
|
||||
self._options = {"username": self.username, "password": self.password, "port": self.port,
|
||||
"connection_timeout": self.timeout, "connection_cache": {}}
|
||||
|
||||
def get_effective_root(self, sub_path):
|
||||
return "/".join(filter(None, (self.root, clean_path(sub_path))))
|
||||
|
||||
def _path(self, root, rel=""):
|
||||
combined = clean_path("/".join(filter(None, (root or self.root, rel))))
|
||||
return self.base if not combined else self.base + "\\" + combined.replace("/", "\\")
|
||||
|
||||
async def _call(self, func, *args, **kwargs):
|
||||
return await asyncio.to_thread(func, *args, **kwargs)
|
||||
|
||||
async def list_dir(self, root, rel, page_num=1, page_size=50, sort_by="name", sort_order="asc"):
|
||||
path = self._path(root, rel)
|
||||
def scan():
|
||||
result = []
|
||||
with smbclient.scandir(path, **self._options) as iterator:
|
||||
for item in iterator:
|
||||
info = item.stat(follow_symlinks=False)
|
||||
is_dir = statmod.S_ISDIR(info.st_mode)
|
||||
result.append(entry(item.name, is_dir, info.st_size, info.st_mtime, path=item.path))
|
||||
return result
|
||||
items = await self._call(scan)
|
||||
return paginate(items, page_num, page_size, sort_by, sort_order)
|
||||
|
||||
async def stat_file(self, root, rel):
|
||||
path = self._path(root, rel)
|
||||
try:
|
||||
info = await self._call(smbclient.stat, path, follow_symlinks=False, **self._options)
|
||||
except FileNotFoundError:
|
||||
raise FileNotFoundError(rel)
|
||||
return entry(posixpath.basename(clean_path(rel)), statmod.S_ISDIR(info.st_mode), info.st_size, info.st_mtime,
|
||||
path=path)
|
||||
|
||||
async def exists(self, root, rel):
|
||||
try:
|
||||
await self.stat_file(root, rel)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
async def stat_path(self, root, rel):
|
||||
try:
|
||||
item = await self.stat_file(root, rel)
|
||||
return {**item, "exists": True, "path": rel}
|
||||
except FileNotFoundError:
|
||||
return {"exists": False, "is_dir": None, "path": rel}
|
||||
|
||||
async def read_file(self, root, rel):
|
||||
path = self._path(root, rel)
|
||||
def read():
|
||||
with smbclient.open_file(path, mode="rb", **self._options) as file:
|
||||
return file.read()
|
||||
return await self._call(read)
|
||||
|
||||
async def stream_file(self, root, rel, range_header):
|
||||
item = await self.stat_file(root, rel)
|
||||
if item["is_dir"]:
|
||||
raise IsADirectoryError(rel)
|
||||
start, end = 0, item["size"] - 1
|
||||
if range_header:
|
||||
try:
|
||||
if not range_header.startswith("bytes=") or "," in range_header:
|
||||
raise ValueError
|
||||
begin, finish = range_header[6:].split("-")
|
||||
if begin:
|
||||
start = int(begin)
|
||||
end = int(finish) if finish else item["size"] - 1
|
||||
else:
|
||||
suffix = int(finish)
|
||||
if suffix <= 0:
|
||||
raise ValueError
|
||||
start, end = max(0, item["size"] - suffix), item["size"] - 1
|
||||
if start < 0 or start >= item["size"] or end < start:
|
||||
raise ValueError
|
||||
end = min(end, item["size"] - 1)
|
||||
except ValueError:
|
||||
raise HTTPException(416, "Requested Range Not Satisfiable",
|
||||
headers={"Content-Range": f"bytes */{item['size']}"})
|
||||
path = self._path(root, rel)
|
||||
length = max(0, end - start + 1)
|
||||
|
||||
async def body():
|
||||
file = await self._call(smbclient.open_file, path, mode="rb", **self._options)
|
||||
remaining = length
|
||||
try:
|
||||
await self._call(file.seek, start)
|
||||
while remaining:
|
||||
chunk = await self._call(file.read, min(1024 * 1024, remaining))
|
||||
if not chunk:
|
||||
raise IOError("SMB file was truncated during download")
|
||||
remaining -= len(chunk)
|
||||
yield chunk
|
||||
finally:
|
||||
await self._call(file.close)
|
||||
|
||||
headers = {"Content-Length": str(length), "Accept-Ranges": "bytes"}
|
||||
if range_header:
|
||||
headers["Content-Range"] = f"bytes {start}-{end}/{item['size']}"
|
||||
return StreamingResponse(body(), status_code=206 if range_header else 200, headers=headers,
|
||||
media_type=mimetypes.guess_type(rel)[0] or "application/octet-stream")
|
||||
|
||||
async def write_file(self, root, rel, data):
|
||||
async def chunks():
|
||||
yield data
|
||||
return await self.write_file_stream(root, rel, chunks())
|
||||
|
||||
async def write_file_stream(self, root, rel, data_iter):
|
||||
if not clean_path(rel):
|
||||
raise ValueError("Cannot overwrite mount root")
|
||||
path = self._path(root, rel)
|
||||
await self._call(smbclient.makedirs, path.rsplit("\\", 1)[0], exist_ok=True, **self._options)
|
||||
file = await self._call(smbclient.open_file, path, mode="wb", **self._options)
|
||||
size = 0
|
||||
try:
|
||||
async for chunk in data_iter:
|
||||
if chunk:
|
||||
written = await self._call(file.write, chunk)
|
||||
if written != len(chunk):
|
||||
raise IOError("Incomplete SMB write")
|
||||
size += written
|
||||
finally:
|
||||
await self._call(file.close)
|
||||
return size
|
||||
|
||||
async def write_upload_file(self, root, rel, file_obj, filename=None, file_size=None, content_type=None):
|
||||
file_obj.seek(0)
|
||||
async def chunks():
|
||||
while chunk := await self._call(file_obj.read, 1024 * 1024):
|
||||
yield chunk
|
||||
return {"size": await self.write_file_stream(root, rel, chunks())}
|
||||
|
||||
async def mkdir(self, root, rel):
|
||||
await self._call(smbclient.makedirs, self._path(root, rel), exist_ok=False, **self._options)
|
||||
|
||||
async def delete(self, root, rel):
|
||||
if not clean_path(rel):
|
||||
raise ValueError("Cannot delete mount root")
|
||||
item = await self.stat_file(root, rel)
|
||||
path = self._path(root, rel)
|
||||
await self._call(smb_shutil.rmtree if item["is_dir"] else smbclient.remove, path, **self._options)
|
||||
|
||||
async def move(self, root, src_rel, dst_rel):
|
||||
self._validate_transfer(src_rel, dst_rel)
|
||||
if await self.exists(root, dst_rel):
|
||||
raise FileExistsError(dst_rel)
|
||||
await self._call(smbclient.rename, self._path(root, src_rel), self._path(root, dst_rel), **self._options)
|
||||
|
||||
async def rename(self, root, src_rel, dst_rel):
|
||||
return await self.move(root, src_rel, dst_rel)
|
||||
|
||||
async def copy(self, root, src_rel, dst_rel, overwrite=False):
|
||||
self._validate_transfer(src_rel, dst_rel)
|
||||
if await self.exists(root, dst_rel):
|
||||
if not overwrite:
|
||||
raise FileExistsError(dst_rel)
|
||||
await self.delete(root, dst_rel)
|
||||
source = self._path(root, src_rel)
|
||||
target = self._path(root, dst_rel)
|
||||
item = await self.stat_file(root, src_rel)
|
||||
if item["is_dir"]:
|
||||
await self._call(smb_shutil.copytree, source, target, **self._options)
|
||||
else:
|
||||
await self._call(smbclient.makedirs, target.rsplit("\\", 1)[0], exist_ok=True, **self._options)
|
||||
await self._call(smb_shutil.copyfile, source, target, **self._options)
|
||||
|
||||
@staticmethod
|
||||
def _validate_transfer(src, dst):
|
||||
src, dst = clean_path(src), clean_path(dst)
|
||||
if not src or not dst or src == dst or dst.startswith(src + "/"):
|
||||
raise ValueError("Invalid transfer destination")
|
||||
|
||||
|
||||
ADAPTER_TYPE = "smb"
|
||||
ADAPTER_FACTORY = SMBAdapter
|
||||
CONFIG_SCHEMA = [
|
||||
{"key": "server", "label": "Server", "type": "string", "required": True, "placeholder": "nas.example.com"},
|
||||
{"key": "share", "label": "Share name", "type": "string", "required": True},
|
||||
{"key": "port", "label": "Port", "type": "number", "default": 445},
|
||||
{"key": "username", "label": "Username", "type": "string", "required": False},
|
||||
{"key": "password", "label": "Password", "type": "password", "required": False},
|
||||
{"key": "root", "label": "Root directory", "type": "string", "default": "/"},
|
||||
{"key": "timeout", "label": "Timeout (seconds)", "type": "number", "default": 30},
|
||||
]
|
||||
@@ -28,4 +28,5 @@ dependencies = [
|
||||
"tortoise-orm>=1.1.8",
|
||||
"uvicorn>=0.54.0",
|
||||
"websockets>=17.1",
|
||||
"smbprotocol>=1.17.0",
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
import io
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from domain.adapters.providers.smb import SMBAdapter
|
||||
|
||||
|
||||
class SMBAdapterTests(unittest.IsolatedAsyncioTestCase):
|
||||
def make(self):
|
||||
return SMBAdapter(SimpleNamespace(config={"server": "nas", "share": "data", "username": "u", "password": "p"}))
|
||||
|
||||
def test_no_network_in_constructor_and_sessions_are_isolated(self):
|
||||
first, second = self.make(), self.make()
|
||||
self.assertIsNot(first._options["connection_cache"], second._options["connection_cache"])
|
||||
self.assertEqual(first._path("docs", "a.txt"), r"\\nas\data\docs\a.txt")
|
||||
with self.assertRaises(ValueError):
|
||||
first._path("docs", "../secret")
|
||||
|
||||
async def test_download_ranges_and_handle_cleanup(self):
|
||||
data = b"0123456789"
|
||||
for header, expected in [(None, data), ("bytes=2-4", b"234"), ("bytes=-3", b"789"), ("bytes=8-99", b"89")]:
|
||||
with self.subTest(header=header):
|
||||
file = io.BytesIO(data)
|
||||
adapter = self.make()
|
||||
with patch("domain.adapters.providers.smb.smbclient.stat", return_value=SimpleNamespace(
|
||||
st_mode=0, st_size=10, st_mtime=0)), patch("domain.adapters.providers.smb.smbclient.open_file", return_value=file):
|
||||
response = await adapter.stream_file("", "file", header)
|
||||
self.assertEqual(b"".join([chunk async for chunk in response.body_iterator]), expected)
|
||||
self.assertEqual(int(response.headers["content-length"]), len(expected))
|
||||
self.assertTrue(file.closed)
|
||||
|
||||
async def test_invalid_ranges_never_open_a_file(self):
|
||||
adapter = self.make()
|
||||
for header in ["bytes=9-", "bytes=-0", "bytes=2-1", "other=1-2", "bytes=1-2,3-4"]:
|
||||
with patch("domain.adapters.providers.smb.smbclient.stat", return_value=SimpleNamespace(
|
||||
st_mode=0, st_size=4, st_mtime=0)), patch("domain.adapters.providers.smb.smbclient.open_file") as opened:
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
await adapter.stream_file("", "file", header)
|
||||
self.assertEqual(caught.exception.status_code, 416)
|
||||
opened.assert_not_called()
|
||||
|
||||
async def test_stream_upload_is_incremental(self):
|
||||
written = []
|
||||
class File:
|
||||
closed = False
|
||||
def write(self, chunk): written.append(chunk); return len(chunk)
|
||||
def close(self): self.closed = True
|
||||
file = File()
|
||||
async def chunks():
|
||||
yield b"first"
|
||||
self.assertEqual(written, [b"first"])
|
||||
yield b"second"
|
||||
with patch("domain.adapters.providers.smb.smbclient.makedirs"), patch(
|
||||
"domain.adapters.providers.smb.smbclient.open_file", return_value=file):
|
||||
size = await self.make().write_file_stream("", "file", chunks())
|
||||
self.assertEqual(size, 11)
|
||||
self.assertTrue(file.closed)
|
||||
|
||||
async def test_copy_uses_sdk_and_rejects_self_overwrite(self):
|
||||
adapter = self.make()
|
||||
with self.assertRaises(ValueError):
|
||||
await adapter.copy("", "file", "file", overwrite=True)
|
||||
with patch.object(adapter, "exists", return_value=False), patch.object(adapter, "stat_file", return_value={"is_dir": False}), patch(
|
||||
"domain.adapters.providers.smb.smbclient.makedirs"), patch("domain.adapters.providers.smb.smb_shutil.copyfile") as copy:
|
||||
await adapter.copy("", "src", "dst")
|
||||
self.assertEqual(copy.call_args.args, (r"\\nas\data\src", r"\\nas\data\dst"))
|
||||
@@ -603,6 +603,7 @@ dependencies = [
|
||||
{ name = "python-multipart" },
|
||||
{ name = "qdrant-client" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "smbprotocol" },
|
||||
{ name = "telethon" },
|
||||
{ name = "tortoise-orm" },
|
||||
{ name = "uvicorn" },
|
||||
@@ -630,6 +631,7 @@ requires-dist = [
|
||||
{ name = "python-multipart", specifier = ">=0.0.32" },
|
||||
{ name = "qdrant-client", specifier = ">=1.19.1" },
|
||||
{ name = "setuptools", specifier = ">=84.0.0" },
|
||||
{ name = "smbprotocol", specifier = ">=1.17.0" },
|
||||
{ name = "telethon", specifier = ">=1.45.0" },
|
||||
{ name = "tortoise-orm", specifier = ">=1.1.8" },
|
||||
{ name = "uvicorn", specifier = ">=0.54.0" },
|
||||
@@ -1541,6 +1543,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyspnego"
|
||||
version = "0.12.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "sspilib", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/8f/30bb9568554899a6147bed657762a3c707e2d3dcaabf3492161cc9550bb4/pyspnego-0.12.3.tar.gz", hash = "sha256:c4982c9f92e6aa5979c9d9a142a21339ff82ebcfbdb4e0649320cb050961a3cd", size = 226528, upload-time = "2026-09-29T21:23:02.305Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/93/fd/0e09467d5b8e229388c897126b4aed1e5fef2295a8661e2cad21fa9a8214/pyspnego-0.12.3-py3-none-any.whl", hash = "sha256:39b87aa00491e554cef05441bf2f7e52e85b09b5229ff62a89c35b59548794be", size = 130323, upload-time = "2026-09-29T21:23:00.877Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
@@ -1738,6 +1753,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smbprotocol"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "pyspnego" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/ff/1a86a76423210bc9558e6ea978c576b13da448644401f16ee2260d4d3316/smbprotocol-1.17.0.tar.gz", hash = "sha256:bcc27edfff7d727a7eb30424138766e1ee216ae2ffc1af03dab0448f29c4731c", size = 190845, upload-time = "2026-07-07T03:23:20.523Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/d3/250e13e7b3473a0f441f4d680d511ad30ce432d0180115dec9840d88fe89/smbprotocol-1.17.0-py3-none-any.whl", hash = "sha256:bd1abff5417f5af83ca516a64ab8e5acece3dbdcf58d4e5e23e47f5165a77349", size = 128208, upload-time = "2026-07-07T03:23:19.178Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.5.0"
|
||||
@@ -1751,6 +1779,23 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/e4/cdda14023c316d71493bc54fdffc3dd006631b88866145c9d3cc33e0f1df/sse_starlette-3.5.0-py3-none-any.whl", hash = "sha256:3e6e1070df3f0f5d9cea81496de92dbb72f6721871d99748ece67441dd8b7997", size = 17407, upload-time = "2026-09-28T17:48:13.228Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sspilib"
|
||||
version = "0.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0d/70/946df678028019b3bbdd6e4fa1fb46f7ccdf46fab1b807813e79aff30e84/sspilib-0.6.0.tar.gz", hash = "sha256:b4d4e3402230f14cd93999f3953619163075bcaca3e13dcf2ab5942c269627f4", size = 59976, upload-time = "2026-08-24T06:44:15.771Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/89/d54087115dd1bdeafaaaee610defdcb940ebc241e1e2e7c52ec323e077bc/sspilib-0.6.0-cp311-abi3-win32.whl", hash = "sha256:70223bdfefde9656c48dd17bb58fd0fb7e879e5b08ab028267d585206f393886", size = 473115, upload-time = "2026-08-24T06:43:47.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/07/2519c128aad9c4062aa46bb8d584e01f8154d119e0a09191258da5935788/sspilib-0.6.0-cp311-abi3-win_amd64.whl", hash = "sha256:b841fabe630101d9e2be6ba56407a2a539087ee85d6ca1477748c21135c9f636", size = 557383, upload-time = "2026-08-24T06:43:49.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e5/a0aacae71a731a7575e2412b43549214b3fae898a8358a3d63b7c805aa42/sspilib-0.6.0-cp311-abi3-win_arm64.whl", hash = "sha256:9073afb8f30dc0e7f3a40b39fef0fc29e4bda6a3d4e77936f3c496cedc4beade", size = 484383, upload-time = "2026-08-24T06:43:50.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/1d/679cee04cca4244d2a829686df201662618c2e87c6a19390861b04ffd9ee/sspilib-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:0482631c67a57d8710ecbba3bbe49a9fb60e153dfcfee6d1f9b8a8ff6e419548", size = 592349, upload-time = "2026-08-24T06:43:59.426Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/bc/77c0ec2fee5d94446d245b2ce1dba2f6f2dcfecef20be8d362d2b9bc6a5f/sspilib-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5937dbe41c34cbbfabbafb84238a06aa8912210cae8a4b909fa20f965868cc89", size = 713678, upload-time = "2026-08-24T06:44:00.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/12/afd3c28e75a863aa2f29b40887570ebc91b12407818f2812d366b7fce1c2/sspilib-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7ea194eeb68d8848fde7125a7ac67fbd4a9052442b7ce69ce6c0c8115227ec67", size = 558596, upload-time = "2026-08-24T06:44:02.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/b2/68ae0e965f0d4ad6cab30291adb490d73a5a898735452b3c99158d98bfbb/sspilib-0.6.0-cp315-cp315t-win32.whl", hash = "sha256:03335e14f8563e350506b47a15887a99f08102a80cfb6bd715ad83ab1c2c89ae", size = 589548, upload-time = "2026-08-24T06:44:11.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/4f/1d3c64284faf9fa9b7d5ec2b4b9152cbcd59d0bf260883bd485be5d58551/sspilib-0.6.0-cp315-cp315t-win_amd64.whl", hash = "sha256:071e4e2a617a39a33e31de2c1f313427533879acabe475fedea31f129f7970bb", size = 707549, upload-time = "2026-08-24T06:44:12.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/9d/26db0d0998bf4b20bc14809e30c20845b9ef34eb6114e9fe39541c7ee0a8/sspilib-0.6.0-cp315-cp315t-win_arm64.whl", hash = "sha256:97bb2e9d4d916d4d6f566888a81997bb3ab0e05ac41549fa0fbebabac66de0e8", size = 555718, upload-time = "2026-08-24T06:44:14.359Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.7.0"
|
||||
|
||||
@@ -594,6 +594,7 @@
|
||||
"adapter.type.pan123": "123Pan (Account)",
|
||||
"adapter.type.pan115": "115 Cloud (Cookie)",
|
||||
"adapter.type.cloud189": "189 Cloud",
|
||||
"adapter.type.smb": "SMB Share",
|
||||
"adapter.type.alist": "AList",
|
||||
"adapter.type.openlist": "OpenList",
|
||||
"Automation Tasks": "Automation Tasks",
|
||||
|
||||
@@ -585,6 +585,7 @@
|
||||
"adapter.type.pan123": "123 网盘(账号)",
|
||||
"adapter.type.pan115": "115 网盘(Cookie)",
|
||||
"adapter.type.cloud189": "天翼云盘",
|
||||
"adapter.type.smb": "SMB 共享",
|
||||
"adapter.type.alist": "AList",
|
||||
"adapter.type.openlist": "OpenList",
|
||||
"Automation Tasks": "自动化任务",
|
||||
|
||||
Reference in new issue
Block a user