mirror of
https://github.com/alexta69/metube.git
synced 2026-07-23 13:22:48 +00:00
Compare commits
16 Commits
2026.06.20
...
5315630ab0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5315630ab0 | |||
| 54463baf0e | |||
| b00d4785ee | |||
| 96e88a3555 | |||
| 49a46a7d1c | |||
| 961b54aa83 | |||
| e0549d6c24 | |||
| f315b75bb2 | |||
| c2c129db61 | |||
| 363f159a0a | |||
| 38c0ca22f4 | |||
| 24ae8f0742 | |||
| 0a946cc352 | |||
| 51fd203b71 | |||
| d136344c26 | |||
| 33f1412fac |
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
run: echo "date=$(date +'%Y.%m.%d')" >> "$GITHUB_OUTPUT"
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
id: date
|
||||
run: echo "date=$(date +'%Y.%m.%d')" >> $GITHUB_OUTPUT
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Get commits since last release
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
token: ${{ secrets.AUTOUPDATE_PAT }}
|
||||
-
|
||||
|
||||
@@ -9,7 +9,7 @@ If an addition would exceed the limit, trim existing prose elsewhere — prefer
|
||||
## Tech stack
|
||||
|
||||
- **Backend:** Python 3.13+, aiohttp, python-socketio 5.x, yt-dlp
|
||||
- **Frontend:** Angular 21, TypeScript, Bootstrap 5, SASS, ngx-socket-io
|
||||
- **Frontend:** Angular 22, TypeScript, Bootstrap 5, SASS, ngx-socket-io
|
||||
- **Package managers:** uv (Python), pnpm (frontend)
|
||||
- **Container:** Multi-stage Docker (Node builder + Python runtime), multi-arch (amd64/arm64)
|
||||
|
||||
|
||||
+84
-3
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import collections.abc
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -17,6 +18,25 @@ STATE_SCHEMA_VERSION = 2
|
||||
_BYTES_MARKER = "__metube_bytes__"
|
||||
_DATETIME_MARKER = "__metube_datetime__"
|
||||
|
||||
# Errnos that signal the filesystem cannot support the temp-file + rename
|
||||
# atomic-write strategy (for example an NFS-backed state dir returning EPERM on
|
||||
# mkstemp). These are safe to fall back on because they mean the atomic
|
||||
# mechanism is unavailable, not that the data write itself failed. Errors like
|
||||
# ENOSPC/EIO are deliberately excluded so a genuine storage failure surfaces
|
||||
# instead of silently truncating an existing good state file.
|
||||
_ATOMIC_UNSUPPORTED_ERRNOS = frozenset(
|
||||
e
|
||||
for e in (
|
||||
errno.EPERM,
|
||||
errno.EACCES,
|
||||
errno.ENOSYS,
|
||||
errno.EINVAL,
|
||||
getattr(errno, "EOPNOTSUPP", None),
|
||||
getattr(errno, "ENOTSUP", None),
|
||||
)
|
||||
if e is not None
|
||||
)
|
||||
|
||||
|
||||
def to_json_compatible(value: Any) -> Any:
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
@@ -62,6 +82,7 @@ class AtomicJsonStore:
|
||||
self.path = path
|
||||
self.kind = kind
|
||||
self.schema_version = schema_version
|
||||
self._direct_write_fallback_warned = False
|
||||
|
||||
def _ensure_parent(self) -> None:
|
||||
parent = os.path.dirname(self.path)
|
||||
@@ -96,6 +117,16 @@ class AtomicJsonStore:
|
||||
def save(self, data: dict[str, Any]) -> None:
|
||||
self._ensure_parent()
|
||||
payload = self._build_payload(data)
|
||||
try:
|
||||
self._atomic_write(payload)
|
||||
except OSError as exc:
|
||||
if exc.errno not in _ATOMIC_UNSUPPORTED_ERRNOS:
|
||||
raise
|
||||
self._warn_direct_write_fallback(exc)
|
||||
self._direct_write(payload)
|
||||
|
||||
def _atomic_write(self, payload: dict[str, Any]) -> None:
|
||||
text = self._serialize(payload)
|
||||
parent = os.path.dirname(self.path) or "."
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
prefix=f".{os.path.basename(self.path)}.",
|
||||
@@ -105,10 +136,9 @@ class AtomicJsonStore:
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, separators=(",", ":"))
|
||||
f.write("\n")
|
||||
f.write(text)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
self._best_effort_fsync(f.fileno())
|
||||
os.replace(tmp_path, self.path)
|
||||
self._fsync_directory(parent)
|
||||
except Exception:
|
||||
@@ -118,6 +148,57 @@ class AtomicJsonStore:
|
||||
pass
|
||||
raise
|
||||
|
||||
def _direct_write(self, payload: dict[str, Any]) -> None:
|
||||
# Serialize before truncating so a serialization failure never destroys
|
||||
# the existing state file (the atomic path gets this for free via its
|
||||
# temp file).
|
||||
text = self._serialize(payload)
|
||||
# Create with 0o600 so the fallback keeps the owner-only permissions the
|
||||
# atomic path gets from mkstemp; state files can contain URLs and
|
||||
# per-download option overrides that must not leak on shared mounts.
|
||||
fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
# The 0o600 mode above only applies when the file is created; force
|
||||
# it on rewrites too so an existing, broadly-permissioned state file
|
||||
# is tightened to match the atomic path. Best-effort because some
|
||||
# network filesystems reject chmod, and that must not re-crash save.
|
||||
try:
|
||||
os.fchmod(f.fileno(), 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
f.write(text)
|
||||
f.flush()
|
||||
self._best_effort_fsync(f.fileno())
|
||||
# Make the new directory entry durable too, matching the atomic path.
|
||||
self._fsync_directory(os.path.dirname(self.path) or ".")
|
||||
|
||||
@staticmethod
|
||||
def _best_effort_fsync(fileno: int) -> None:
|
||||
# Tolerate fsync being unsupported on the underlying filesystem (for
|
||||
# example a network mount that returns EINVAL/ENOSYS), but let genuine
|
||||
# storage failures such as ENOSPC/EIO surface so a non-durable write is
|
||||
# never reported as success. An unsupported fsync must not by itself
|
||||
# abandon the atomic rename path.
|
||||
try:
|
||||
os.fsync(fileno)
|
||||
except OSError as exc:
|
||||
if exc.errno not in _ATOMIC_UNSUPPORTED_ERRNOS:
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _serialize(payload: dict[str, Any]) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
def _warn_direct_write_fallback(self, exc: OSError) -> None:
|
||||
if self._direct_write_fallback_warned:
|
||||
return
|
||||
self._direct_write_fallback_warned = True
|
||||
log.warning(
|
||||
"Atomic state write failed for %s (%s); falling back to direct write",
|
||||
self.path,
|
||||
exc,
|
||||
)
|
||||
|
||||
def quarantine_invalid_file(self, exc: Exception) -> None:
|
||||
if not os.path.exists(self.path):
|
||||
return
|
||||
|
||||
@@ -4,6 +4,7 @@ import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
from state_store import AtomicJsonStore, from_json_compatible, to_json_compatible
|
||||
|
||||
@@ -21,6 +22,135 @@ class StateStoreTests(unittest.TestCase):
|
||||
self.assertEqual(payload["schema_version"], 2)
|
||||
self.assertEqual(payload["items"][0]["info"]["title"], "hello")
|
||||
|
||||
def test_save_falls_back_to_direct_write_when_mkstemp_fails(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue.json")
|
||||
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||
|
||||
with self.assertLogs("state_store", level="WARNING") as logs:
|
||||
with patch(
|
||||
"state_store.tempfile.mkstemp",
|
||||
side_effect=PermissionError(1, "Operation not permitted"),
|
||||
):
|
||||
store.save({"items": [{"key": "a"}]})
|
||||
|
||||
self.assertTrue(os.path.exists(path))
|
||||
self.assertTrue(any(path in message for message in logs.output))
|
||||
# Fallback keeps owner-only permissions, matching the atomic path.
|
||||
self.assertEqual(os.stat(path).st_mode & 0o777, 0o600)
|
||||
payload = store.load()
|
||||
self.assertEqual(payload["items"], [{"key": "a"}])
|
||||
|
||||
def test_fallback_tightens_permissions_on_existing_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue.json")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("{}")
|
||||
os.chmod(path, 0o644)
|
||||
|
||||
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||
with patch(
|
||||
"state_store.tempfile.mkstemp",
|
||||
side_effect=PermissionError(1, "Operation not permitted"),
|
||||
):
|
||||
store.save({"items": [{"key": "a"}]})
|
||||
|
||||
self.assertEqual(os.stat(path).st_mode & 0o777, 0o600)
|
||||
self.assertEqual(store.load()["items"], [{"key": "a"}])
|
||||
|
||||
def test_save_falls_back_to_direct_write_when_replace_fails(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue.json")
|
||||
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||
|
||||
with patch(
|
||||
"state_store.os.replace",
|
||||
side_effect=PermissionError(1, "Operation not permitted"),
|
||||
):
|
||||
store.save({"items": [{"key": "a"}]})
|
||||
|
||||
self.assertTrue(os.path.exists(path))
|
||||
payload = store.load()
|
||||
self.assertEqual(payload["items"], [{"key": "a"}])
|
||||
self.assertEqual([], [name for name in os.listdir(tmp) if name.endswith(".tmp")])
|
||||
|
||||
def test_save_reraises_when_atomic_and_direct_write_fail(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue.json")
|
||||
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||
|
||||
with patch(
|
||||
"state_store.tempfile.mkstemp",
|
||||
side_effect=PermissionError(1, "Operation not permitted"),
|
||||
):
|
||||
with patch(
|
||||
"state_store.os.open",
|
||||
side_effect=PermissionError(13, "Permission denied"),
|
||||
):
|
||||
with self.assertRaises(PermissionError) as ctx:
|
||||
store.save({"items": [{"key": "a"}]})
|
||||
|
||||
self.assertEqual(ctx.exception.errno, 13)
|
||||
self.assertFalse(os.path.exists(path))
|
||||
|
||||
def test_unsupported_fsync_keeps_atomic_path(self):
|
||||
# fsync being unsupported (EINVAL/ENOSYS) must not by itself trigger the
|
||||
# direct-write fallback; the atomic temp-file + rename path still runs.
|
||||
import errno as _errno
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue.json")
|
||||
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||
|
||||
with patch(
|
||||
"state_store.os.fsync",
|
||||
side_effect=OSError(_errno.EINVAL, "Invalid argument"),
|
||||
):
|
||||
with self.assertNoLogs("state_store", level="WARNING"):
|
||||
store.save({"items": [{"key": "a"}]})
|
||||
|
||||
self.assertEqual(store.load()["items"], [{"key": "a"}])
|
||||
self.assertEqual([], [name for name in os.listdir(tmp) if name.endswith(".tmp")])
|
||||
|
||||
def test_save_reraises_and_preserves_state_on_non_atomic_errno(self):
|
||||
# A storage failure such as ENOSPC is not an "atomic unavailable"
|
||||
# signal, so it must surface instead of falling back to a direct write
|
||||
# that would truncate the existing good state file.
|
||||
import errno as _errno
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue.json")
|
||||
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||
store.save({"items": [{"key": "good"}]})
|
||||
|
||||
with patch(
|
||||
"state_store.tempfile.mkstemp",
|
||||
side_effect=OSError(_errno.ENOSPC, "No space left on device"),
|
||||
):
|
||||
with self.assertRaises(OSError) as ctx:
|
||||
store.save({"items": [{"key": "new"}]})
|
||||
|
||||
self.assertEqual(ctx.exception.errno, _errno.ENOSPC)
|
||||
# Existing state is untouched.
|
||||
self.assertEqual(store.load()["items"], [{"key": "good"}])
|
||||
|
||||
def test_serialization_failure_preserves_existing_state(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue.json")
|
||||
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||
store.save({"items": [{"key": "good"}]})
|
||||
|
||||
# Even on the fallback path, a non-serializable payload must raise
|
||||
# before the existing good state file is touched.
|
||||
with patch(
|
||||
"state_store.tempfile.mkstemp",
|
||||
side_effect=PermissionError(1, "Operation not permitted"),
|
||||
):
|
||||
with self.assertRaises(TypeError):
|
||||
store.save({"items": object()})
|
||||
|
||||
self.assertEqual(store.load()["items"], [{"key": "good"}])
|
||||
|
||||
def test_invalid_file_is_quarantined(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue.json")
|
||||
|
||||
+24
-23
@@ -21,43 +21,44 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@11.5.2",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.17",
|
||||
"@angular/common": "^21.2.17",
|
||||
"@angular/compiler": "^21.2.17",
|
||||
"@angular/core": "^21.2.17",
|
||||
"@angular/forms": "^21.2.17",
|
||||
"@angular/platform-browser": "^21.2.17",
|
||||
"@angular/platform-browser-dynamic": "^21.2.17",
|
||||
"@angular/service-worker": "^21.2.17",
|
||||
"@angular/animations": "^22.0.4",
|
||||
"@angular/common": "^22.0.4",
|
||||
"@angular/compiler": "^22.0.4",
|
||||
"@angular/core": "^22.0.4",
|
||||
"@angular/forms": "^22.0.4",
|
||||
"@angular/platform-browser": "^22.0.4",
|
||||
"@angular/platform-browser-dynamic": "^22.0.4",
|
||||
"@angular/service-worker": "^22.0.4",
|
||||
"@fortawesome/angular-fontawesome": "~4.0.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.2.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
||||
"@ng-select/ng-select": "^21.8.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.3.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.3.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.3.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.3.0",
|
||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||
"@ng-select/ng-select": "^23.2.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
"ngx-cookie-service": "^21.3.1",
|
||||
"ngx-cookie-service": "^22.0.0",
|
||||
"ngx-socket-io": "~4.10.0",
|
||||
"rxjs": "~7.8.2",
|
||||
"tslib": "^2.8.1",
|
||||
"zone.js": "0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-eslint/builder": "21.1.0",
|
||||
"@angular/build": "^21.2.16",
|
||||
"@angular/cli": "^21.2.16",
|
||||
"@angular/compiler-cli": "^21.2.17",
|
||||
"@angular/localize": "^21.2.17",
|
||||
"@angular-eslint/builder": "22.0.0",
|
||||
"@angular/build": "^22.0.4",
|
||||
"@angular/cli": "^22.0.4",
|
||||
"@angular/compiler-cli": "^22.0.4",
|
||||
"@angular/localize": "^22.0.4",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"angular-eslint": "21.1.0",
|
||||
"angular-eslint": "22.0.0",
|
||||
"eslint": "^9.39.4",
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "8.47.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "8.62.0",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1715
-1619
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { provideHttpClient, withInterceptorsFromDi, withXhr } from '@angular/common/http';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
@@ -12,6 +12,6 @@ export const appConfig: ApplicationConfig = {
|
||||
// or after 30 seconds (whichever comes first).
|
||||
registrationStrategy: 'registerWhenStable:30000'
|
||||
}),
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClient(withXhr(), withInterceptorsFromDi()),
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, ElementRef, viewChild, output, input } from "@angular/core";
|
||||
import { Component, ElementRef, viewChild, output, input, ChangeDetectionStrategy } from "@angular/core";
|
||||
import { Checkable } from "../interfaces";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
|
||||
@@ -10,7 +10,10 @@ import { FormsModule } from "@angular/forms";
|
||||
<label class="form-check-label visually-hidden" for="{{id()}}-select-all">Select all</label>
|
||||
</div>
|
||||
`,
|
||||
imports: [
|
||||
// Shared Checkable objects are mutated in place; Eager preserves pre-v22 behavior.
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
imports: [
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { Component, input, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { SelectAllCheckboxComponent } from './master-checkbox.component';
|
||||
import { Checkable } from '../interfaces';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -11,7 +11,10 @@ import { FormsModule } from '@angular/forms';
|
||||
<label class="form-check-label visually-hidden" for="{{master().id()}}-{{id()}}-select">Select item</label>
|
||||
</div>
|
||||
`,
|
||||
imports: [
|
||||
// Shared Checkable objects are mutated in place; Eager preserves pre-v22 behavior.
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
imports: [
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
|
||||
@@ -12,5 +12,13 @@
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
],
|
||||
"angularCompilerOptions": {
|
||||
"extendedDiagnostics": {
|
||||
"checks": {
|
||||
"nullishCoalescingNotNullable": "suppress",
|
||||
"optionalChainNotNullable": "suppress"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,14 +106,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.14.0"
|
||||
version = "4.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -347,15 +347,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "deno"
|
||||
version = "2.8.3"
|
||||
version = "2.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/ec/98f972cca4ca734534947bdf28b86a99e9ed8a5a31a061be2dec9b4105c2/deno-2.8.3.tar.gz", hash = "sha256:5413f4a1814ac3b8e441ca7fe9eb677a4152a42eef05efd56a61d750088a7fc8", size = 8163, upload-time = "2026-06-11T16:13:53.129Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/96/098c84eff4b8ec7da8fb1b43d3371c22f021b7642d5b2bdad8d1780cd605/deno-2.9.0.tar.gz", hash = "sha256:7548d37528eaca993a2d4483ce092eca971fa206faadb0f4c8af8c3f039677d2", size = 8168, upload-time = "2026-06-25T15:03:34.872Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/42/71f88d6c04c7f4335f94c1c9eb5aee7e928e1a53056bb9b20c0c4d889f48/deno-2.8.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:57825615e8d4182160401b56907b2e36712aed55a5dfdef2c0e3089379bb6988", size = 42314540, upload-time = "2026-06-11T16:13:39.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/95/032101e28532fa9ed28ca7dc737934d4631544fa3b11f4efe3573970a05a/deno-2.8.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:05dbbbd8edf1907abcbcf55395e63f00d74c2672b6039dd1ecd5656baebaa56c", size = 38101511, upload-time = "2026-06-11T16:13:42.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/24/68c1d2d79933738acb8e5ef3a4584f57c6d5deb56c84073a5f4079c24647/deno-2.8.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:9fdb7574a6437fdd59f668e745dc3b3e32725fa360b64204a6b000689fa534e9", size = 42053660, upload-time = "2026-06-11T16:13:45.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/56/0d23c6daa1b139c31591df801feda3ac7d0d6225f1686cbf5caaa5db5c27/deno-2.8.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:9ca5727e5650f8459f39875f0af4b2242ead77da4ce8a8d52e86647036f3d63a", size = 43800396, upload-time = "2026-06-11T16:13:47.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/aa/e35b736205b89c98f31ed4abeebaa7846774e51006b14601e4edb7728e67/deno-2.8.3-py3-none-win_amd64.whl", hash = "sha256:37da75ee91448e4e6f5626f0d5cb18e295dbb6cff5cf780fab1f92ebbbd44071", size = 41552180, upload-time = "2026-06-11T16:13:50.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/64/ebd8bb0fc2ef57b0f8cff53cd9b43261c9c16dfaf0a07db37b9fcb2ec186/deno-2.9.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f53bbb11a9a7eeae3865470baa1d5cb02da105ac073019953e9d59e0588bc10e", size = 42301281, upload-time = "2026-06-25T15:03:18.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/3c/1cc1c8c731e5dada562640dc42c0603c017bc1ba731a69255cecd9b25be0/deno-2.9.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e58aa71c144dcd91ef9fd5b044dd465d1be571b6763c3a777924d4d54f7033e7", size = 37950190, upload-time = "2026-06-25T15:03:21.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/86/e5487fe222840aca26c292cfa2532fb0ba4b080b6106f89cce530a7470a5/deno-2.9.0-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:3401337f5c3b75e8f7c1b07589612cddac66ddc17a0a18e7eff6e55f23a1b53b", size = 42017185, upload-time = "2026-06-25T15:03:25.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/d0/7f73a8d5f77947c327272147d89d005426888d4d5b3d6575181c1a7271e9/deno-2.9.0-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:63988d645e591bc171a574aa6b3ada2ddfc9bcf59f0178412a9fde1991bfc111", size = 43869469, upload-time = "2026-06-25T15:03:28.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/34/ca326c53558f99792eb4e2c19ca88763e6890ce1ffb44b9536ecd7ae983f/deno-2.9.0-py3-none-win_amd64.whl", hash = "sha256:29f6f616b47e5babd9604bcd1a76ec3576895275f8696dedfb33ab6545f6ed85", size = 41589185, upload-time = "2026-06-25T15:03:32.002Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -628,11 +628,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mutagen"
|
||||
version = "1.47.0"
|
||||
version = "1.48.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/e6/64bc71b74eef4b68e61eb921dcf72dabd9e4ec4af1e11891bbd312ccbb77/mutagen-1.47.0.tar.gz", hash = "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99", size = 1274186, upload-time = "2023-09-03T16:33:33.411Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/70/1675da133ea92227da41bf5b24e1c66be597ff736a1533ade41da986852f/mutagen-1.48.1.tar.gz", hash = "sha256:8f95637ab9f6f305cec6bd1294e197debe207998e3e068596563c74f86b0a173", size = 1276978, upload-time = "2026-06-25T09:47:32.443Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719", size = 194391, upload-time = "2023-09-03T16:33:29.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d8/a29e4e3991765e7ce4ed1f7e4074fe1ba9da03e0048639734de60f9cadb9/mutagen-1.48.1-py3-none-any.whl", hash = "sha256:4f077fe87d3fc7fba259aa63d8c026b18382ca6a42ef37c61e16f1b1b5b82fe7", size = 195706, upload-time = "2026-06-25T09:47:30.296Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -849,14 +849,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-engineio"
|
||||
version = "4.13.2"
|
||||
version = "4.13.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "simple-websocket" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fa/6d/4384c2723adad93a3d6de4297e6d9c8b93be7f778a407f34f6ee0b2bea3e/python_engineio-4.13.2.tar.gz", hash = "sha256:a7732e99cfb7db6ed1aee31f18d7f73bbae086a92f31dee019bc646155d9684e", size = 79639, upload-time = "2026-05-21T21:45:07.578Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/a0/f75491f942184d9960b15e763270f765fe9f239745ca5f9e16289011aed4/python_engineio-4.13.3.tar.gz", hash = "sha256:572b7783e341fed21edbc7cea297ccd378dad79265fdde96aa4664420a7c06c9", size = 79734, upload-time = "2026-06-20T22:53:52.197Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl", hash = "sha256:8c101cd170e400dc4e970cd523325cde22df8fc25140953f379327055d701a6b", size = 59993, upload-time = "2026-05-21T21:45:06.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-py3-none-any.whl", hash = "sha256:1f60ecaf1358190f0e26c48c578a60428dc02a8f1295bc3dbf53d1b31116821f", size = 59993, upload-time = "2026-06-20T22:53:50.775Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1117,11 +1117,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "yt-dlp"
|
||||
version = "2026.6.9"
|
||||
version = "2026.7.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/a4/1b0979d28f87774bb67fbbc66bce44f9dd1aa0e547a99e22985fac945c33/yt_dlp-2026.6.9.tar.gz", hash = "sha256:d50fcb95f48d61bedde33e408c1881d4c279e51c31354a599ce09e96ba0f4b86", size = 3030590, upload-time = "2026-06-09T23:27:14.831Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/c5/9972af4b472b0d55badf841ebafd2f98944cb0ae0f46e11d01f363ea5b91/yt_dlp-2026.7.4.tar.gz", hash = "sha256:b094813404f87a9dd2186f00815231df32e5fd8a5403be0f807b3bb2d21a4432", size = 3049326, upload-time = "2026-07-04T22:42:14.837Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ee/188a3dadf9dfdac713243521f919feca1cd091d4358c9ea7e8ebb710a7cc/yt_dlp-2026.6.9-py3-none-any.whl", hash = "sha256:442ba4c75724b9496144c8434b617962ee08d0ee7c26ec663848fe9b78d5a3e4", size = 3169035, upload-time = "2026-06-09T23:27:12.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/8a/cd4c9b02c10c563adfe78118310129641900e1cd6de888cfae2452072696/yt_dlp-2026.7.4-py3-none-any.whl", hash = "sha256:f11f2b11d5a8ac4059f9bdf29fa4407dc7c6bb00c5097e95ca22a7a9db518266", size = 3184705, upload-time = "2026-07-04T22:42:12.989Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
||||
Reference in New Issue
Block a user