mirror of
https://github.com/JoeanAmier/XHS-Downloader.git
synced 2026-03-22 06:57:16 +08:00
feat: 新增文件断点续传功能
1. 新增自动删除空文件夹功能 2. 引入 aiofiles 库 3. 修正 Actions 错误 Closes #142 Closes #143
This commit is contained in:
6
.github/workflows/executable_build.yml
vendored
6
.github/workflows/executable_build.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
21
source/expansion/file_folder.py
Normal file
21
source/expansion/file_folder.py
Normal 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()
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user