Merge pull request #482 from arabcoders/dev

Fix: handle errors when fetching remote thumbnails
This commit is contained in:
Abdulmohsen 2025-11-11 23:45:47 +03:00 committed by GitHub
commit 8ff6582316
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 247 additions and 47 deletions

44
FAQ.md
View file

@ -47,6 +47,7 @@ or the `environment:` section in `compose.yaml` file.
| YTP_ALLOW_INTERNAL_URLS | Allow requests to internal URLs | `false` |
| YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` |
| YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` |
| YTP_AUTO_CLEAR_HISTORY_DAYS | Number of days after which completed download history is cleared. | `0` |
> [!NOTE]
> To raise the maximum workers for specific extractor, you need to add a ENV variable that follows the pattern `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`.
@ -54,8 +55,12 @@ or the `environment:` section in `compose.yaml` file.
> The limit should not exceed the `YTP_MAX_WORKERS` value as it will be ignored.
> [!IMPORTANT]
> The env variable `YTP_SIMPLE_MODE` only control what being displayed for first time visitor, the users can still switch between the two modes via the WebUI settings page.
> The env variable `YTP_SIMPLE_MODE` only control what being displayed for first time visitor, the users can still switch between the two modes via the WebUI settings page.
## Notes about YTP_AUTO_CLEAR_HISTORY_DAYS
- `0` days means no automatic clearing of the download history. lowest value that will trigger the clearing is `1` day.
- This setting will **NOT** delete the downloaded files, it will only clear the history from the database.
# Browser extensions & bookmarklets
@ -507,3 +512,40 @@ Thats it, the `main.yml` will now disable the docker/github container registries
naming, your container name will be named `REGISTRY/ytptube` and the tags will be the same as the ones used in the github registry.
Unfortunately, the `native-builder.yml` workflow doesn't support self-hosted repositories at the moment.
# Getting No space left on device error
If you encounter this error: `OSError: [Errno 28] No space left on device` This indicates that either
the `/tmp` or `/downloads` directory has run out of available space.
This issue commonly occurs when:
- `/tmp` is mounted as `tmpfs` (memory-based storage)
- Your system has limited RAM
- You're downloading large video files
Since videos are temporarily stored in `/tmp` before being moved to the final download location, memory-based storage
may be insufficient for large downloads.
To fix the issue, modify your `compose.yaml` to use a disk-based directory for temporary files:
```yaml
services:
ytptube:
user: "${UID:-1000}:${UID:-1000}"
image: ghcr.io/arabcoders/ytptube:latest
container_name: ytptube
restart: unless-stopped
ports:
- "8081:8081"
volumes:
- ./config:/config:rw
- ./downloads:/downloads/local:rw
- ./temp:/tmp:rw
```
> [!NOTE]
> Replace the `tmpfs` mount with a local directory volume (`./temp:/tmp:rw`). This allows temporary files to use disk space instead of RAM.
After making the changes, restart your container. This should resolve the "No space left on device"
error during download.

View file

@ -77,6 +77,7 @@ services:
> [!IMPORTANT]
> Make sure to change the `user` line to match your user id and group id
> If you have low RAM, remove the `tmpfs` and mount a disk-based directory to `/tmp` instead. See [FAQ](FAQ.md#getting-no-space-left-on-device-error) for more information.
```bash
$ mkdir -p ./{config,downloads} && docker compose -f compose.yaml up -d

View file

@ -140,6 +140,15 @@ class DownloadQueue(metaclass=Singleton):
func=self._check_live,
id=f"{__class__.__name__}.{__class__._check_live.__name__}",
)
if self.config.auto_clear_history_days > 0:
Scheduler.get_instance().add(
# timer="8 */1 * * *",
timer="* * * * *",
func=self._delete_old_history,
id=f"{__class__.__name__}.{__class__._delete_old_history.__name__}",
)
# app.on_shutdown.append(self.on_shutdown)
async def test(self) -> bool:
@ -1262,6 +1271,48 @@ class DownloadQueue(metaclass=Singleton):
LOG.exception(e)
LOG.error(f"Failed to retry item '{item_ref}'. {e!s}")
async def _delete_old_history(self) -> None:
"""
Automatically delete old download history based on user specified days.
0 or negative value disables this feature.
"""
if self.config.auto_clear_history_days < 0 or self.is_paused() or self.done.empty():
return
cutoff_date: datetime = datetime.now(UTC) - timedelta(days=self.config.auto_clear_history_days)
items_to_delete: list[tuple[str, ItemDTO]] = []
for key, download in self.done.items():
info: ItemDTO = download.info
if not info or not info.timestamp:
continue
if "finished" != info.status or not info.filename:
continue
try:
timestamp_seconds: float = info.timestamp / 1e9
item_datetime: datetime = datetime.fromtimestamp(timestamp_seconds, tz=UTC)
if item_datetime < cutoff_date:
items_to_delete.append((key, info))
except (OSError, ValueError, OverflowError) as e:
LOG.error(f"Failed to parse timestamp '{info.timestamp}' for item '{info.title}': {e}")
titles: list[str] = []
for key, info in items_to_delete:
item_name: str = info.title or info.id or info._id
self._notify.emit(
Events.ITEM_DELETED,
data=info,
title="Download Cleared",
message=f"'{item_name}' record removed from history.",
)
titles.append(item_name)
self.done.delete(key)
if titles:
LOG.info(f"Automatically cleared '{', '.join(titles)}' from download history due to age.")
def _handle_task_exception(self, task: asyncio.Task) -> None:
if task.cancelled():
return

View file

@ -24,6 +24,8 @@ class Events:
CONNECTED: str = "connected"
CONFIGURATION: str = "configuration"
LOG_INFO: str = "log_info"
LOG_WARNING: str = "log_warning"
LOG_ERROR: str = "log_error"
@ -90,6 +92,7 @@ class Events:
"""
return [
Events.CONFIGURATION,
Events.CONNECTED,
Events.LOG_INFO,
Events.LOG_WARNING,
@ -97,7 +100,6 @@ class Events:
Events.LOG_SUCCESS,
Events.ITEM_ADDED,
Events.ITEM_UPDATED,
Events.ITEM_COMPLETED,
Events.ITEM_CANCELLED,
Events.ITEM_DELETED,
Events.ITEM_MOVED,

View file

@ -44,7 +44,7 @@ class HttpSocket:
self.sio = sio or socketio.AsyncServer(
async_handlers=True,
async_mode="aiohttp",
cors_allowed_origins=[],
cors_allowed_origins="*",
transports=["websocket", "polling"],
logger=self.config.debug,
engineio_logger=self.config.debug,

View file

@ -482,6 +482,9 @@ def check_id(file: Path) -> bool | str:
id: str | None = match.groupdict().get("id")
try:
if not file.parent.exists():
return False
for f in file.parent.iterdir():
if id not in f.stem:
continue

View file

@ -192,6 +192,9 @@ class Config(metaclass=Singleton):
playlist_items_concurrency: int = 4
"""The number of concurrent playlist items to be processed at same time."""
auto_clear_history_days: int = 0
"""Number of days after which completed download history is automatically cleared. 0 to disable."""
pictures_backends: list[str] = [
"https://unsplash.it/1920/1080?random",
"https://picsum.photos/1920/1080",
@ -230,6 +233,7 @@ class Config(metaclass=Singleton):
"playlist_items_concurrency",
"download_path_depth",
"download_info_expires",
"auto_clear_history_days",
)
"The variables that are integers."

View file

@ -71,6 +71,12 @@ async def get_thumbnail(request: Request, config: Config) -> Response:
LOG.debug(f"Fetching thumbnail from '{url}'.")
response = await client.request(method="GET", url=url, follow_redirects=True)
if response.status_code != web.HTTPOk.status_code:
LOG.error(f"Failed to fetch thumbnail from '{url}'. Status code: {response.status_code}.")
return web.json_response(
data={"error": "failed to retrieve the thumbnail."}, status=response.status_code
)
return web.Response(
body=response.content,
headers={

View file

@ -23,25 +23,31 @@ class _Data:
@route(RouteType.SOCKET, "connect", "socket_connect")
async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: str):
data = {
**queue.get(),
"config": config.frontend(),
"presets": Presets.get_instance().get_all(),
"dl_fields": DLFields.get_instance().get_all(),
"paused": queue.is_paused(),
}
data["folders"] = list_folders(
path=Path(config.download_path),
base=Path(config.download_path),
depth_limit=config.download_path_depth-1,
notify.emit(
Events.CONFIGURATION,
data={
"config": config.frontend(),
"presets": Presets.get_instance().get_all(),
"dl_fields": DLFields.get_instance().get_all(),
"paused": queue.is_paused(),
},
title="Client connected",
message=f"Client '{sid}' connected.",
to=sid,
)
notify.emit(
Events.CONNECTED,
data=data,
title="Client connected",
message=f"Client '{sid}' connected.",
data={
"folders": list_folders(
path=Path(config.download_path),
base=Path(config.download_path),
depth_limit=config.download_path_depth - 1,
),
**queue.get(),
},
title="Sending initial download data",
message=f"Sending initial download data to client '{sid}'.",
to=sid,
)

View file

@ -91,7 +91,6 @@ class TestEvents:
Events.LOG_SUCCESS,
Events.ITEM_ADDED,
Events.ITEM_UPDATED,
Events.ITEM_COMPLETED,
]
for expected in expected_frontend:

View file

@ -20,7 +20,7 @@ dependencies = [
"aiocron>=1.8",
"python-dotenv>=1.0.1",
"debugpy>=1.8.1",
"httpx",
"httpx[brotli,http2,socks,zstd]",
"async-timeout",
"pyjson5",
"curl_cffi>=0.13",

View file

@ -103,7 +103,7 @@
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown" v-if="config.app.file_logging || config.app.console_enabled">
<div class="navbar-item has-dropdown" v-if="config.app?.file_logging || config.app?.console_enabled">
<a class="navbar-link" @click="(e: MouseEvent) => openMenu(e)">
<span class="icon"><i class="fas fa-tools" /></span>
<span>Other</span>
@ -111,7 +111,7 @@
<div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/logs" @click.prevent="(e: MouseEvent) => changeRoute(e)"
v-if="config.app.file_logging">
v-if="config.app?.file_logging">
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
<span>Logs</span>
</NuxtLink>

View file

@ -51,11 +51,13 @@ export const useSocketStore = defineStore('socket', () => {
const connect = () => {
const opts = {
transports: ['websocket', 'polling'],
transports: ['polling', 'websocket'],
withCredentials: true,
reconnection: true,
reconnectionAttempts: 30,
reconnectionDelay: 5000
reconnectionAttempts: 50,
reconnectionDelay: 5000,
tryAllTransports: true,
timeout: 10000 * 5,
} as Partial<ManagerOptions & SocketOptions>
let url = runtimeConfig.public.wss
@ -70,28 +72,32 @@ export const useSocketStore = defineStore('socket', () => {
connectionStatus.value = 'connecting';
socket.value = io(url, opts)
socket.value.on('connect', () => {
on("connect_error", (e: any) => console.error("Socket connection error:", e));
on('connect', () => {
isConnected.value = true
connectionStatus.value = 'connected';
});
socket.value.on('disconnect', () => {
on('disconnect', () => {
isConnected.value = false
connectionStatus.value = 'disconnected';
});
socket.value.on('connected', stream => {
on('configuration', stream => {
const json = JSON.parse(stream)
config.setAll({
app: json.data.config,
tasks: json.data.tasks,
folders: json.data.folders,
presets: json.data.presets,
dl_fields: json.data.dl_fields,
paused: Boolean(json.data.paused)
} as Partial<ConfigState>)
})
on('connected', stream => {
const json = JSON.parse(stream);
config.add('folders', json.data.folders)
stateStore.addAll('queue', json.data.queue || {})
stateStore.addAll('history', json.data.done || {})
})
@ -122,21 +128,6 @@ export const useSocketStore = defineStore('socket', () => {
}
}, true);
on('item_completed', (stream: string) => {
const json = JSON.parse(stream);
if (true === stateStore.has('queue', json.data._id)) {
stateStore.remove('queue', json.data._id);
}
if (true === stateStore.has('history', json.data._id)) {
stateStore.update('history', json.data._id, json.data);
return;
}
stateStore.add('history', json.data._id, json.data);
});
on('item_cancelled', (stream: string) => {
const item = JSON.parse(stream);
const id = item.data._id

99
uv.lock
View file

@ -524,6 +524,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
]
[[package]]
name = "h2"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779 },
]
[[package]]
name = "hpack"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357 },
]
[[package]]
name = "httpcore"
version = "1.0.9"
@ -552,6 +574,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
[package.optional-dependencies]
brotli = [
{ name = "brotli", marker = "platform_python_implementation == 'CPython'" },
{ name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" },
]
http2 = [
{ name = "h2" },
]
socks = [
{ name = "socksio" },
]
zstd = [
{ name = "zstandard" },
]
[[package]]
name = "httpx-curl-cffi"
version = "0.1.4"
@ -578,6 +615,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794 },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007 },
]
[[package]]
name = "idna"
version = "3.11"
@ -1490,6 +1536,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
]
[[package]]
name = "socksio"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763 },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
@ -1710,7 +1765,7 @@ dependencies = [
{ name = "dateparser" },
{ name = "debugpy" },
{ name = "defusedxml" },
{ name = "httpx" },
{ name = "httpx", extra = ["brotli", "http2", "socks", "zstd"] },
{ name = "httpx-curl-cffi" },
{ name = "jmespath" },
{ name = "jsonschema" },
@ -1761,7 +1816,7 @@ requires-dist = [
{ name = "dateparser", specifier = ">=1.2.1" },
{ name = "debugpy", specifier = ">=1.8.1" },
{ name = "defusedxml", specifier = ">=0.7.1" },
{ name = "httpx" },
{ name = "httpx", extras = ["brotli", "http2", "socks", "zstd"] },
{ name = "httpx-curl-cffi", specifier = ">=0.1.4" },
{ name = "jmespath", specifier = ">=1.0.1" },
{ name = "jsonschema", specifier = ">=4.23.0" },
@ -1801,3 +1856,43 @@ sdist = { url = "https://files.pythonhosted.org/packages/11/f2/690a35762cf8366ce
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/62/c2da1c495291a52e561257d017585e08906d288035d025ccf636f6b9a266/zipstream_ng-1.9.0-py3-none-any.whl", hash = "sha256:31dc2cf617abdbf28d44f2e08c0d14c8eee2ea0ec26507a7e4d5d5f97c564b7a", size = 24852 },
]
[[package]]
name = "zstandard"
version = "0.25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735 },
{ url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440 },
{ url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070 },
{ url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001 },
{ url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120 },
{ url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230 },
{ url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173 },
{ url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736 },
{ url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368 },
{ url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022 },
{ url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889 },
{ url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952 },
{ url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054 },
{ url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113 },
{ url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936 },
{ url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232 },
{ url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671 },
{ url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887 },
{ url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658 },
{ url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849 },
{ url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095 },
{ url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751 },
{ url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818 },
{ url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402 },
{ url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108 },
{ url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248 },
{ url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330 },
{ url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123 },
{ url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591 },
{ url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513 },
{ url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118 },
{ url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940 },
]