feat: 新增文件断点续传功能

1. 新增自动删除空文件夹功能
2. 引入 aiofiles 库
3. 修正 Actions 错误

Closes #142
Closes #143
This commit is contained in:
JoeanAmier
2024-08-07 21:38:46 +08:00
parent 24dc8a1a53
commit 7cedd8d4b8
8 changed files with 93 additions and 18 deletions

View File

@@ -29,7 +29,11 @@ jobs:
- name: 构建可执行文件 - name: 构建可执行文件
run: | run: |
pyinstaller --icon=/static/images/TikTokDownloader.ico --add-data "static;static" --add-data "templates;templates" main.py if [ "$RUNNER_OS" = "Windows" ]; then
pyinstaller --icon=./static/XHS-Downloader.ico --add-data "static;static" --add-data "locale;locale" .\main.py
else
pyinstaller --icon=./static/XHS-Downloader.ico --add-data "static:static" --add-data "locale:locale" main.py
fi
- name: 上传文件 - name: 上传文件
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@@ -419,6 +419,7 @@ async def example():
* https://github.com/tiangolo/fastapi * https://github.com/tiangolo/fastapi
* https://github.com/textualize/textual/ * https://github.com/textualize/textual/
* https://textual.textualize.io/ * https://textual.textualize.io/
* https://github.com/omnilib/aiosqlite
* https://aiosqlite.omnilib.dev/en/stable/ * https://aiosqlite.omnilib.dev/en/stable/
* https://click.palletsprojects.com/en/8.1.x/ * https://click.palletsprojects.com/en/8.1.x/
* https://github.com/thewh1teagle/rookie * https://github.com/thewh1teagle/rookie

View File

@@ -420,6 +420,7 @@ async def example():
* https://github.com/tiangolo/fastapi * https://github.com/tiangolo/fastapi
* https://github.com/textualize/textual/ * https://github.com/textualize/textual/
* https://textual.textualize.io/ * https://textual.textualize.io/
* https://github.com/omnilib/aiosqlite
* https://aiosqlite.omnilib.dev/en/stable/ * https://aiosqlite.omnilib.dev/en/stable/
* https://click.palletsprojects.com/en/8.1.x/ * https://click.palletsprojects.com/en/8.1.x/
* https://github.com/thewh1teagle/rookie * https://github.com/thewh1teagle/rookie

View File

@@ -8,3 +8,4 @@ rookiepy>=0.5.2
httpx>=0.27.0 httpx>=0.27.0
fastapi>=0.111.0 fastapi>=0.111.0
uvicorn>=0.30.1 uvicorn>=0.30.1
aiofiles>=24.1.0

View File

@@ -1,13 +1,16 @@
from asyncio import gather from asyncio import gather
from pathlib import Path from pathlib import Path
from aiofiles import open
from httpx import HTTPError from httpx import HTTPError
from typing import TYPE_CHECKING
from source.module import ERROR from source.module import ERROR
from source.module import Manager from source.module import Manager
from source.module import logging from source.module import logging
from source.module import retry as re_download from source.module import retry as re_download
if TYPE_CHECKING:
from httpx import AsyncClient
__all__ = ['Download'] __all__ = ['Download']
@@ -26,7 +29,8 @@ class Download:
self.folder = manager.folder self.folder = manager.folder
self.temp = manager.temp self.temp = manager.temp
self.chunk = manager.chunk self.chunk = manager.chunk
self.client = manager.download_client self.client: "AsyncClient" = manager.download_client
self.headers = manager.blank_headers
self.retry = manager.retry self.retry = manager.retry
self.message = manager.message self.message = manager.message
self.folder_mode = manager.folder_mode self.folder_mode = manager.folder_mode
@@ -122,20 +126,32 @@ class Download:
@re_download @re_download
async def __download(self, url: str, path: Path, name: str, format_: str, log, bar): async def __download(self, url: str, path: Path, name: str, format_: str, log, bar):
temp = self.temp.joinpath(f"{name}.{format_}")
try: try:
async with self.client.stream("GET", url, ) as response: length, suffix = await self.__hand_file(url, format_, )
except HTTPError as error:
logging(log, str(error), ERROR)
logging(
log,
self.message(
"网络异常,{0} 请求失败").format(name),
ERROR,
)
return False
temp = self.temp.joinpath(f"{name}.{suffix}")
real = path.joinpath(f"{name}.{suffix}")
self.__update_headers_range(temp, )
try:
async with self.client.stream("GET", url, headers=self.headers) as response:
response.raise_for_status() response.raise_for_status()
suffix = self.__extract_type(
response.headers.get("Content-Type")) or format_
real = path.joinpath(f"{name}.{suffix}")
# self.__create_progress( # self.__create_progress(
# bar, int( # bar,
# int(
# response.headers.get( # response.headers.get(
# 'content-length', 0)) or None) # 'content-length', 0)) or None,
with temp.open("wb") as f: # )
async with open(temp, "ab") as f:
async for chunk in response.aiter_bytes(self.chunk): async for chunk in response.aiter_bytes(self.chunk):
f.write(chunk) await f.write(chunk)
# self.__update_progress(bar, len(chunk)) # self.__update_progress(bar, len(chunk))
self.manager.move(temp, real) self.manager.move(temp, real)
# self.__create_progress(bar, None) # self.__create_progress(bar, None)
@@ -146,14 +162,17 @@ class Download:
# self.__create_progress(bar, None) # self.__create_progress(bar, None)
logging(log, str(error), ERROR) logging(log, str(error), ERROR)
logging( logging(
log, self.message( log,
"网络异常,{0} 下载失败").format(name), ERROR) self.message(
"网络异常,{0} 下载失败").format(name),
ERROR,
)
return False return False
@staticmethod @staticmethod
def __create_progress(bar, total: int | None): def __create_progress(bar, total: int | None, completed=0, ):
if bar: if bar:
bar.update(total=total) bar.update(total=total, completed=completed)
@staticmethod @staticmethod
def __update_progress(bar, advance: int): def __update_progress(bar, advance: int):
@@ -163,3 +182,26 @@ class Download:
@classmethod @classmethod
def __extract_type(cls, content: str) -> str: def __extract_type(cls, content: str) -> str:
return cls.CONTENT_TYPE_MAP.get(content, "") return cls.CONTENT_TYPE_MAP.get(content, "")
async def __hand_file(self,
url: str,
suffix: str,
) -> [int, str]:
response = await self.client.head(url,
headers=self.headers | {
"Range": "bytes=0-",
}, )
response.raise_for_status()
suffix = self.__extract_type(
response.headers.get("Content-Type")) or suffix
length = response.headers.get(
"Content-Length", 0)
return int(length), suffix
@staticmethod
def __get_resume_byte_position(file: Path) -> int:
return file.stat().st_size if file.is_file() else 0
def __update_headers_range(self, file: Path) -> int:
self.headers["Range"] = f"bytes={(p := self.__get_resume_byte_position(file))}-"
return p

View File

@@ -4,3 +4,5 @@ from .namespace import Namespace
from .truncate import beautify_string from .truncate import beautify_string
from .truncate import trim_string from .truncate import trim_string
from .truncate import truncate_string from .truncate import truncate_string
from .file_folder import file_switch
from .file_folder import remove_empty_directories

View File

@@ -0,0 +1,21 @@
from pathlib import Path
def file_switch(path: Path) -> None:
if path.exists():
path.unlink()
else:
path.touch()
def remove_empty_directories(path: Path) -> None:
exclude = {
"\\.",
"\\_",
"\\__",
}
for dir_path, dir_names, file_names in path.walk(top_down=False, ):
if any(i in str(dir_path) for i in exclude):
continue
if not dir_names and not file_names:
dir_path.rmdir()

View File

@@ -17,6 +17,7 @@ from .static import SEC_CH_UA_PLATFORM
from .static import USERAGENT from .static import USERAGENT
from .static import WARNING from .static import WARNING
from .tools import logging from .tools import logging
from source.expansion import remove_empty_directories
__all__ = ["Manager"] __all__ = ["Manager"]
@@ -171,7 +172,9 @@ class Manager:
async def close(self): async def close(self):
await self.request_client.aclose() await self.request_client.aclose()
await self.download_client.aclose() await self.download_client.aclose()
self.__clean() # self.__clean()
remove_empty_directories(self.root)
remove_empty_directories(self.folder)
def __check_name_format(self, format_: str) -> str: def __check_name_format(self, format_: str) -> str:
keys = format_.split() keys = format_.split()