mirror of
https://github.com/alexta69/metube.git
synced 2026-03-18 22:43:51 +00:00
Compare commits
28 Commits
2026.02.22
...
2026.03.08
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b0eaad67e | ||
|
|
2a166ccf1f | ||
|
|
3bbe1e8424 | ||
|
|
a2740375be | ||
|
|
2736425e19 | ||
|
|
0d905c0b61 | ||
|
|
6de4a56f28 | ||
|
|
1f4c4df847 | ||
|
|
d211f24e00 | ||
|
|
13acd5b309 | ||
|
|
fc5f8cf8ca | ||
|
|
4565d5abb3 | ||
|
|
54e25484c5 | ||
|
|
7cfb0c3a1d | ||
|
|
d2e6c079f9 | ||
|
|
3587098e80 | ||
|
|
1915bdfc46 | ||
|
|
58c317f7cd | ||
|
|
880eda8435 | ||
|
|
fd3aaea9d9 | ||
|
|
da84753e20 | ||
|
|
7427cbb0c0 | ||
|
|
053e41cf52 | ||
|
|
77da359234 | ||
|
|
8dff6448b2 | ||
|
|
dd4e05325a | ||
|
|
ce9703cd04 | ||
|
|
973a87ffc6 |
10
.github/workflows/main.yml
vendored
10
.github/workflows/main.yml
vendored
@@ -18,26 +18,26 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
30
Dockerfile
30
Dockerfile
@@ -6,24 +6,6 @@ RUN corepack enable && corepack prepare pnpm --activate
|
||||
RUN CI=true pnpm install && pnpm run build
|
||||
|
||||
|
||||
FROM rust:1.93-slim AS bgutil-builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
python3 && \
|
||||
BGUTIL_TAG="$(curl -Ls -o /dev/null -w '%{url_effective}' https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/latest | sed 's#.*/tag/##')" && \
|
||||
curl -L "https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/archive/refs/tags/${BGUTIL_TAG}.tar.gz" \
|
||||
| tar -xz --strip-components=1 && \
|
||||
cargo build --release
|
||||
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
@@ -52,14 +34,22 @@ RUN sed -i 's/\r$//g' docker-entrypoint.sh && \
|
||||
UV_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode && \
|
||||
uv cache clean && \
|
||||
rm -f /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/uvw && \
|
||||
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y && \
|
||||
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y v2.7.2 && \
|
||||
apt-get purge -y --auto-remove build-essential && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
mkdir /.cache && chmod 777 /.cache
|
||||
|
||||
COPY --from=bgutil-builder /src/target/release/bgutil-pot /usr/local/bin/bgutil-pot
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN BGUTIL_TAG="$(curl -Ls -o /dev/null -w '%{url_effective}' https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/latest | sed 's#.*/tag/##')" && \
|
||||
case "$TARGETARCH" in \
|
||||
amd64) BGUTIL_ARCH="x86_64" ;; \
|
||||
arm64) BGUTIL_ARCH="aarch64" ;; \
|
||||
*) echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \
|
||||
esac && \
|
||||
curl -L -o /usr/local/bin/bgutil-pot \
|
||||
"https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/download/${BGUTIL_TAG}/bgutil-pot-linux-${BGUTIL_ARCH}" && \
|
||||
chmod +x /usr/local/bin/bgutil-pot && \
|
||||
PLUGIN_DIR="$(python3 -c 'import site; print(site.getsitepackages()[0])')" && \
|
||||
curl -L -o /tmp/bgutil-ytdlp-pot-provider-rs.zip \
|
||||
"https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/download/${BGUTIL_TAG}/bgutil-ytdlp-pot-provider-rs.zip" && \
|
||||
|
||||
21
README.md
21
README.md
@@ -36,6 +36,7 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
||||
* __MAX_CONCURRENT_DOWNLOADS__: Maximum number of simultaneous downloads allowed. For example, if set to `5`, then at most five downloads will run concurrently, and any additional downloads will wait until one of the active downloads completes. Defaults to `3`.
|
||||
* __DELETE_FILE_ON_TRASHCAN__: if `true`, downloaded files are deleted on the server, when they are trashed from the "Completed" section of the UI. Defaults to `false`.
|
||||
* __DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT__: Maximum number of playlist items that can be downloaded. Defaults to `0` (no limit).
|
||||
* __CLEAR_COMPLETED_AFTER__: Number of seconds after which completed (and failed) downloads are automatically removed from the "Completed" list. Defaults to `0` (disabled).
|
||||
|
||||
### 📁 Storage & Directories
|
||||
|
||||
@@ -55,8 +56,8 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
||||
|
||||
* __OUTPUT_TEMPLATE__: The template for the filenames of the downloaded videos, formatted according to [this spec](https://github.com/yt-dlp/yt-dlp/blob/master/README.md#output-template). Defaults to `%(title)s.%(ext)s`.
|
||||
* __OUTPUT_TEMPLATE_CHAPTER__: The template for the filenames of the downloaded videos when split into chapters via postprocessors. Defaults to `%(title)s - %(section_number)s %(section_title)s.%(ext)s`.
|
||||
* __OUTPUT_TEMPLATE_PLAYLIST__: The template for the filenames of the downloaded videos when downloaded as a playlist. Defaults to `%(playlist_title)s/%(title)s.%(ext)s`. When empty, then `OUTPUT_TEMPLATE` is used.
|
||||
* __OUTPUT_TEMPLATE_CHANNEL__: The template for the filenames of the downloaded videos when downloaded as a channel. Defaults to `%(channel)s/%(title)s.%(ext)s`. When empty, then `OUTPUT_TEMPLATE` is used.
|
||||
* __OUTPUT_TEMPLATE_PLAYLIST__: The template for the filenames of the downloaded videos when downloaded as a playlist. Defaults to `%(playlist_title)s/%(title)s.%(ext)s`. Set to empty to use `OUTPUT_TEMPLATE` instead.
|
||||
* __OUTPUT_TEMPLATE_CHANNEL__: The template for the filenames of the downloaded videos when downloaded as a channel. Defaults to `%(channel)s/%(title)s.%(ext)s`. Set to empty to use `OUTPUT_TEMPLATE` instead.
|
||||
* __YTDL_OPTIONS__: Additional options to pass to yt-dlp in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L222). They roughly correspond to command-line options, though some do not have exact equivalents here. For example, `--recode-video` has to be specified via `postprocessors`. Also note that dashes are replaced with underscores. You may find [this script](https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py) helpful for converting from command-line options to `YTDL_OPTIONS`.
|
||||
* __YTDL_OPTIONS_FILE__: A path to a JSON file that will be loaded and used for populating `YTDL_OPTIONS` above. Please note that if both `YTDL_OPTIONS_FILE` and `YTDL_OPTIONS` are specified, the options in `YTDL_OPTIONS` take precedence. The file will be monitored for changes and reloaded automatically when changes are detected.
|
||||
|
||||
@@ -89,21 +90,13 @@ The project's Wiki contains examples of useful configurations contributed by use
|
||||
|
||||
In case you need to use your browser's cookies with MeTube, for example to download restricted or private videos:
|
||||
|
||||
* Add the following to your docker-compose.yml:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /path/to/cookies:/cookies
|
||||
environment:
|
||||
- YTDL_OPTIONS={"cookiefile":"/cookies/cookies.txt"}
|
||||
```
|
||||
|
||||
* Install in your browser an extension to extract cookies:
|
||||
* [Firefox](https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/)
|
||||
* [Chrome](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
|
||||
* Extract the cookies you need with the extension and rename the file `cookies.txt`
|
||||
* Drop the file in the folder you configured in the docker-compose.yml above
|
||||
* Restart the container
|
||||
* Extract the cookies you need with the extension and save/export them as `cookies.txt`.
|
||||
* In MeTube, open **Advanced Options** and use the **Upload Cookies** button to upload the file.
|
||||
* After upload, the cookie indicator should show as active.
|
||||
* Use **Delete Cookies** in the same section to remove uploaded cookies.
|
||||
|
||||
## 🔌 Browser extensions
|
||||
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import copy
|
||||
|
||||
AUDIO_FORMATS = ("m4a", "mp3", "opus", "wav", "flac")
|
||||
CAPTION_MODES = ("auto_only", "manual_only", "prefer_manual", "prefer_auto")
|
||||
|
||||
|
||||
def _normalize_caption_mode(mode: str) -> str:
|
||||
mode = (mode or "").strip()
|
||||
return mode if mode in CAPTION_MODES else "prefer_manual"
|
||||
|
||||
|
||||
def _normalize_subtitle_language(language: str) -> str:
|
||||
language = (language or "").strip()
|
||||
return language or "en"
|
||||
|
||||
|
||||
def get_format(format: str, quality: str) -> str:
|
||||
@@ -26,6 +37,10 @@ def get_format(format: str, quality: str) -> str:
|
||||
# Quality is irrelevant in this case since we skip the download
|
||||
return "bestaudio/best"
|
||||
|
||||
if format == "captions":
|
||||
# Quality is irrelevant in this case since we skip the download
|
||||
return "bestaudio/best"
|
||||
|
||||
if format in AUDIO_FORMATS:
|
||||
# Audio quality needs to be set post-download, set in opts
|
||||
return f"bestaudio[ext={format}]/bestaudio/best"
|
||||
@@ -51,7 +66,14 @@ def get_format(format: str, quality: str) -> str:
|
||||
raise Exception(f"Unkown format {format}")
|
||||
|
||||
|
||||
def get_opts(format: str, quality: str, ytdl_opts: dict) -> dict:
|
||||
def get_opts(
|
||||
format: str,
|
||||
quality: str,
|
||||
ytdl_opts: dict,
|
||||
subtitle_format: str = "srt",
|
||||
subtitle_language: str = "en",
|
||||
subtitle_mode: str = "prefer_manual",
|
||||
) -> dict:
|
||||
"""
|
||||
Returns extra download options
|
||||
Mostly postprocessing options
|
||||
@@ -98,6 +120,34 @@ def get_opts(format: str, quality: str, ytdl_opts: dict) -> dict:
|
||||
{"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"}
|
||||
)
|
||||
|
||||
if format == "captions":
|
||||
mode = _normalize_caption_mode(subtitle_mode)
|
||||
language = _normalize_subtitle_language(subtitle_language)
|
||||
opts["skip_download"] = True
|
||||
requested_subtitle_format = (subtitle_format or "srt").lower()
|
||||
# txt is a derived, non-timed format produced from SRT after download.
|
||||
if requested_subtitle_format == "txt":
|
||||
requested_subtitle_format = "srt"
|
||||
opts["subtitlesformat"] = requested_subtitle_format
|
||||
if mode == "manual_only":
|
||||
opts["writesubtitles"] = True
|
||||
opts["writeautomaticsub"] = False
|
||||
opts["subtitleslangs"] = [language]
|
||||
elif mode == "auto_only":
|
||||
opts["writesubtitles"] = False
|
||||
opts["writeautomaticsub"] = True
|
||||
# `-orig` captures common YouTube auto-sub tags. The plain language
|
||||
# fallback keeps behavior useful across other extractors.
|
||||
opts["subtitleslangs"] = [f"{language}-orig", language]
|
||||
elif mode == "prefer_auto":
|
||||
opts["writesubtitles"] = True
|
||||
opts["writeautomaticsub"] = True
|
||||
opts["subtitleslangs"] = [f"{language}-orig", language]
|
||||
else:
|
||||
opts["writesubtitles"] = True
|
||||
opts["writeautomaticsub"] = True
|
||||
opts["subtitleslangs"] = [language, f"{language}-orig"]
|
||||
|
||||
opts["postprocessors"] = postprocessors + (
|
||||
opts["postprocessors"] if "postprocessors" in opts else []
|
||||
)
|
||||
|
||||
152
app/main.py
152
app/main.py
@@ -60,6 +60,7 @@ class Config:
|
||||
'OUTPUT_TEMPLATE_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s',
|
||||
'OUTPUT_TEMPLATE_CHANNEL': '%(channel)s/%(title)s.%(ext)s',
|
||||
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0',
|
||||
'CLEAR_COMPLETED_AFTER': '0',
|
||||
'YTDL_OPTIONS': '{}',
|
||||
'YTDL_OPTIONS_FILE': '',
|
||||
'ROBOTS_TXT': '',
|
||||
@@ -97,10 +98,42 @@ class Config:
|
||||
if self.YTDL_OPTIONS_FILE and self.YTDL_OPTIONS_FILE.startswith('.'):
|
||||
self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve())
|
||||
|
||||
self._runtime_overrides = {}
|
||||
|
||||
success,_ = self.load_ytdl_options()
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
|
||||
def set_runtime_override(self, key, value):
|
||||
self._runtime_overrides[key] = value
|
||||
self.YTDL_OPTIONS[key] = value
|
||||
|
||||
def remove_runtime_override(self, key):
|
||||
self._runtime_overrides.pop(key, None)
|
||||
self.YTDL_OPTIONS.pop(key, None)
|
||||
|
||||
def _apply_runtime_overrides(self):
|
||||
self.YTDL_OPTIONS.update(self._runtime_overrides)
|
||||
|
||||
# Keys sent to the browser. Sensitive or server-only keys (YTDL_OPTIONS,
|
||||
# paths, TLS config, etc.) are intentionally excluded.
|
||||
_FRONTEND_KEYS = (
|
||||
'CUSTOM_DIRS',
|
||||
'CREATE_CUSTOM_DIRS',
|
||||
'OUTPUT_TEMPLATE_CHAPTER',
|
||||
'PUBLIC_HOST_URL',
|
||||
'PUBLIC_HOST_AUDIO_URL',
|
||||
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT',
|
||||
)
|
||||
|
||||
def frontend_safe(self) -> dict:
|
||||
"""Return only the config keys that are safe to expose to browser clients.
|
||||
|
||||
Sensitive or server-only keys (YTDL_OPTIONS, file-system paths, TLS
|
||||
settings, etc.) are intentionally excluded.
|
||||
"""
|
||||
return {k: getattr(self, k) for k in self._FRONTEND_KEYS}
|
||||
|
||||
def load_ytdl_options(self) -> tuple[bool, str]:
|
||||
try:
|
||||
self.YTDL_OPTIONS = json.loads(os.environ.get('YTDL_OPTIONS', '{}'))
|
||||
@@ -111,6 +144,7 @@ class Config:
|
||||
return (False, msg)
|
||||
|
||||
if not self.YTDL_OPTIONS_FILE:
|
||||
self._apply_runtime_overrides()
|
||||
return (True, '')
|
||||
|
||||
log.info(f'Loading yt-dlp custom options from "{self.YTDL_OPTIONS_FILE}"')
|
||||
@@ -128,6 +162,7 @@ class Config:
|
||||
return (False, msg)
|
||||
|
||||
self.YTDL_OPTIONS.update(opts)
|
||||
self._apply_runtime_overrides()
|
||||
return (True, '')
|
||||
|
||||
config = Config()
|
||||
@@ -156,6 +191,9 @@ app = web.Application()
|
||||
sio = socketio.AsyncServer(cors_allowed_origins='*')
|
||||
sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io')
|
||||
routes = web.RouteTableDef()
|
||||
VALID_SUBTITLE_FORMATS = {'srt', 'txt', 'vtt', 'ttml', 'sbv', 'scc', 'dfxp'}
|
||||
VALID_SUBTITLE_MODES = {'auto_only', 'manual_only', 'prefer_manual', 'prefer_auto'}
|
||||
SUBTITLE_LANGUAGE_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9-]{0,34}$')
|
||||
|
||||
class Notifier(DownloadQueueNotifier):
|
||||
async def added(self, dl):
|
||||
@@ -247,6 +285,9 @@ async def add(request):
|
||||
auto_start = post.get('auto_start')
|
||||
split_by_chapters = post.get('split_by_chapters')
|
||||
chapter_template = post.get('chapter_template')
|
||||
subtitle_format = post.get('subtitle_format')
|
||||
subtitle_language = post.get('subtitle_language')
|
||||
subtitle_mode = post.get('subtitle_mode')
|
||||
|
||||
if custom_name_prefix is None:
|
||||
custom_name_prefix = ''
|
||||
@@ -260,14 +301,47 @@ async def add(request):
|
||||
split_by_chapters = False
|
||||
if chapter_template is None:
|
||||
chapter_template = config.OUTPUT_TEMPLATE_CHAPTER
|
||||
if subtitle_format is None:
|
||||
subtitle_format = 'srt'
|
||||
if subtitle_language is None:
|
||||
subtitle_language = 'en'
|
||||
if subtitle_mode is None:
|
||||
subtitle_mode = 'prefer_manual'
|
||||
subtitle_format = str(subtitle_format).strip().lower()
|
||||
subtitle_language = str(subtitle_language).strip()
|
||||
subtitle_mode = str(subtitle_mode).strip()
|
||||
if chapter_template and ('..' in chapter_template or chapter_template.startswith('/') or chapter_template.startswith('\\')):
|
||||
raise web.HTTPBadRequest(reason='chapter_template must not contain ".." or start with a path separator')
|
||||
if subtitle_format not in VALID_SUBTITLE_FORMATS:
|
||||
raise web.HTTPBadRequest(reason=f'subtitle_format must be one of {sorted(VALID_SUBTITLE_FORMATS)}')
|
||||
if not SUBTITLE_LANGUAGE_RE.fullmatch(subtitle_language):
|
||||
raise web.HTTPBadRequest(reason='subtitle_language must match pattern [A-Za-z0-9-] and be at most 35 characters')
|
||||
if subtitle_mode not in VALID_SUBTITLE_MODES:
|
||||
raise web.HTTPBadRequest(reason=f'subtitle_mode must be one of {sorted(VALID_SUBTITLE_MODES)}')
|
||||
|
||||
playlist_item_limit = int(playlist_item_limit)
|
||||
|
||||
status = await dqueue.add(url, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template)
|
||||
status = await dqueue.add(
|
||||
url,
|
||||
quality,
|
||||
format,
|
||||
folder,
|
||||
custom_name_prefix,
|
||||
playlist_item_limit,
|
||||
auto_start,
|
||||
split_by_chapters,
|
||||
chapter_template,
|
||||
subtitle_format,
|
||||
subtitle_language,
|
||||
subtitle_mode,
|
||||
)
|
||||
return web.Response(text=serializer.encode(status))
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'cancel-add')
|
||||
async def cancel_add(request):
|
||||
dqueue.cancel_add()
|
||||
return web.Response(text=serializer.encode({'status': 'ok'}), content_type='application/json')
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'delete')
|
||||
async def delete(request):
|
||||
post = await request.json()
|
||||
@@ -288,6 +362,65 @@ async def start(request):
|
||||
status = await dqueue.start_pending(ids)
|
||||
return web.Response(text=serializer.encode(status))
|
||||
|
||||
|
||||
COOKIES_PATH = os.path.join(config.STATE_DIR, 'cookies.txt')
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'upload-cookies')
|
||||
async def upload_cookies(request):
|
||||
reader = await request.multipart()
|
||||
field = await reader.next()
|
||||
if field is None or field.name != 'cookies':
|
||||
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'No cookies file provided'}))
|
||||
size = 0
|
||||
with open(COOKIES_PATH, 'wb') as f:
|
||||
while True:
|
||||
chunk = await field.read_chunk()
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > 1_000_000: # 1MB limit
|
||||
os.remove(COOKIES_PATH)
|
||||
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'Cookie file too large (max 1MB)'}))
|
||||
f.write(chunk)
|
||||
config.set_runtime_override('cookiefile', COOKIES_PATH)
|
||||
log.info(f'Cookies file uploaded ({size} bytes)')
|
||||
return web.Response(text=serializer.encode({'status': 'ok', 'msg': f'Cookies uploaded ({size} bytes)'}))
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'delete-cookies')
|
||||
async def delete_cookies(request):
|
||||
has_uploaded_cookies = os.path.exists(COOKIES_PATH)
|
||||
configured_cookiefile = config.YTDL_OPTIONS.get('cookiefile')
|
||||
has_manual_cookiefile = isinstance(configured_cookiefile, str) and configured_cookiefile and configured_cookiefile != COOKIES_PATH
|
||||
|
||||
if not has_uploaded_cookies:
|
||||
if has_manual_cookiefile:
|
||||
return web.Response(
|
||||
status=400,
|
||||
text=serializer.encode({
|
||||
'status': 'error',
|
||||
'msg': 'Cookies are configured manually via YTDL_OPTIONS (cookiefile). Remove or change that setting manually; UI delete only removes uploaded cookies.'
|
||||
})
|
||||
)
|
||||
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'No uploaded cookies to delete'}))
|
||||
|
||||
os.remove(COOKIES_PATH)
|
||||
config.remove_runtime_override('cookiefile')
|
||||
success, msg = config.load_ytdl_options()
|
||||
if not success:
|
||||
log.error(f'Cookies file deleted, but failed to reload YTDL_OPTIONS: {msg}')
|
||||
return web.Response(status=500, text=serializer.encode({'status': 'error', 'msg': f'Cookies file deleted, but failed to reload YTDL_OPTIONS: {msg}'}))
|
||||
|
||||
log.info('Cookies file deleted')
|
||||
return web.Response(text=serializer.encode({'status': 'ok'}))
|
||||
|
||||
@routes.get(config.URL_PREFIX + 'cookie-status')
|
||||
async def cookie_status(request):
|
||||
configured_cookiefile = config.YTDL_OPTIONS.get('cookiefile')
|
||||
has_configured_cookies = isinstance(configured_cookiefile, str) and os.path.exists(configured_cookiefile)
|
||||
has_uploaded_cookies = os.path.exists(COOKIES_PATH)
|
||||
exists = has_uploaded_cookies or has_configured_cookies
|
||||
return web.Response(text=serializer.encode({'status': 'ok', 'has_cookies': exists}))
|
||||
|
||||
@routes.get(config.URL_PREFIX + 'history')
|
||||
async def history(request):
|
||||
history = { 'done': [], 'queue': [], 'pending': []}
|
||||
@@ -306,7 +439,7 @@ async def history(request):
|
||||
async def connect(sid, environ):
|
||||
log.info(f"Client connected: {sid}")
|
||||
await sio.emit('all', serializer.encode(dqueue.get()), to=sid)
|
||||
await sio.emit('configuration', serializer.encode(config), to=sid)
|
||||
await sio.emit('configuration', serializer.encode(config.frontend_safe()), to=sid)
|
||||
if config.CUSTOM_DIRS:
|
||||
await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid)
|
||||
if config.YTDL_OPTIONS_FILE:
|
||||
@@ -334,8 +467,12 @@ def get_custom_dirs():
|
||||
else:
|
||||
return re.search(config.CUSTOM_DIRS_EXCLUDE_REGEX, d) is None
|
||||
|
||||
# Recursively lists all subdirectories of DOWNLOAD_DIR
|
||||
# Recursively lists all subdirectories of DOWNLOAD_DIR.
|
||||
# Always include '' (the base directory itself) even when the
|
||||
# directory is empty or does not yet exist.
|
||||
dirs = list(filter(include_dir, map(convert, path.glob('**/'))))
|
||||
if '' not in dirs:
|
||||
dirs.insert(0, '')
|
||||
|
||||
return dirs
|
||||
|
||||
@@ -399,6 +536,9 @@ async def add_cors(request):
|
||||
return web.Response(text=serializer.encode({"status": "ok"}))
|
||||
|
||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'add', add_cors)
|
||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'cancel-add', add_cors)
|
||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'upload-cookies', add_cors)
|
||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'delete-cookies', add_cors)
|
||||
|
||||
async def on_prepare(request, response):
|
||||
if 'Origin' in request.headers:
|
||||
@@ -426,6 +566,12 @@ if __name__ == '__main__':
|
||||
logging.getLogger().setLevel(parseLogLevel(config.LOGLEVEL) or logging.INFO)
|
||||
log.info(f"Listening on {config.HOST}:{config.PORT}")
|
||||
|
||||
|
||||
# Auto-detect cookie file on startup
|
||||
if os.path.exists(COOKIES_PATH):
|
||||
config.set_runtime_override('cookiefile', COOKIES_PATH)
|
||||
log.info(f'Cookie file detected at {COOKIES_PATH}')
|
||||
|
||||
if config.HTTPS:
|
||||
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||||
ssl_context.load_cert_chain(certfile=config.CERTFILE, keyfile=config.KEYFILE)
|
||||
|
||||
301
app/ytdl.py
301
app/ytdl.py
@@ -29,6 +29,26 @@ def _compile_outtmpl_pattern(field: str) -> re.Pattern:
|
||||
return re.compile(STR_FORMAT_RE_TMPL.format(re.escape(field), conversion_types))
|
||||
|
||||
|
||||
# Characters that are invalid in Windows/NTFS path components. These are pre-
|
||||
# sanitised when substituting playlist/channel titles into output templates so
|
||||
# that downloads do not fail on NTFS-mounted volumes or Windows Docker hosts.
|
||||
_WINDOWS_INVALID_PATH_CHARS = re.compile(r'[\\:*?"<>|]')
|
||||
|
||||
|
||||
def _sanitize_path_component(value: Any) -> Any:
|
||||
"""Replace characters that are invalid in Windows path components with '_'.
|
||||
|
||||
Non-string values (int, float, None, …) are passed through unchanged so
|
||||
that ``_outtmpl_substitute_field`` can still coerce them with format specs
|
||||
(e.g. ``%(playlist_index)02d``). Only string values are sanitised because
|
||||
Windows-invalid characters are only a concern for human-readable strings
|
||||
(titles, channel names, etc.) that may end up as directory names.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
return _WINDOWS_INVALID_PATH_CHARS.sub('_', value)
|
||||
|
||||
|
||||
def _outtmpl_substitute_field(template: str, field: str, value: Any) -> str:
|
||||
"""Substitute a single field in an output template, applying any format specifiers to the value."""
|
||||
pattern = _compile_outtmpl_pattern(field)
|
||||
@@ -69,6 +89,45 @@ def _convert_generators_to_lists(obj):
|
||||
else:
|
||||
return obj
|
||||
|
||||
|
||||
def _convert_srt_to_txt_file(subtitle_path: str):
|
||||
"""Convert an SRT subtitle file into plain text by stripping cue numbers/timestamps."""
|
||||
txt_path = os.path.splitext(subtitle_path)[0] + ".txt"
|
||||
try:
|
||||
with open(subtitle_path, "r", encoding="utf-8", errors="replace") as infile:
|
||||
content = infile.read()
|
||||
|
||||
# Normalize newlines so cue splitting is consistent across platforms.
|
||||
content = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
cues = []
|
||||
for block in re.split(r"\n{2,}", content):
|
||||
lines = [line.strip() for line in block.split("\n") if line.strip()]
|
||||
if not lines:
|
||||
continue
|
||||
if re.fullmatch(r"\d+", lines[0]):
|
||||
lines = lines[1:]
|
||||
if lines and "-->" in lines[0]:
|
||||
lines = lines[1:]
|
||||
|
||||
text_lines = []
|
||||
for line in lines:
|
||||
if "-->" in line:
|
||||
continue
|
||||
clean_line = re.sub(r"<[^>]+>", "", line).strip()
|
||||
if clean_line:
|
||||
text_lines.append(clean_line)
|
||||
if text_lines:
|
||||
cues.append(" ".join(text_lines))
|
||||
|
||||
with open(txt_path, "w", encoding="utf-8") as outfile:
|
||||
if cues:
|
||||
outfile.write("\n".join(cues))
|
||||
outfile.write("\n")
|
||||
return txt_path
|
||||
except OSError as exc:
|
||||
log.warning(f"Failed to convert subtitle file {subtitle_path} to txt: {exc}")
|
||||
return None
|
||||
|
||||
class DownloadQueueNotifier:
|
||||
async def added(self, dl):
|
||||
raise NotImplementedError
|
||||
@@ -86,7 +145,24 @@ class DownloadQueueNotifier:
|
||||
raise NotImplementedError
|
||||
|
||||
class DownloadInfo:
|
||||
def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit, split_by_chapters, chapter_template):
|
||||
def __init__(
|
||||
self,
|
||||
id,
|
||||
title,
|
||||
url,
|
||||
quality,
|
||||
format,
|
||||
folder,
|
||||
custom_name_prefix,
|
||||
error,
|
||||
entry,
|
||||
playlist_item_limit,
|
||||
split_by_chapters,
|
||||
chapter_template,
|
||||
subtitle_format="srt",
|
||||
subtitle_language="en",
|
||||
subtitle_mode="prefer_manual",
|
||||
):
|
||||
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
|
||||
self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
|
||||
self.url = url
|
||||
@@ -104,6 +180,10 @@ class DownloadInfo:
|
||||
self.playlist_item_limit = playlist_item_limit
|
||||
self.split_by_chapters = split_by_chapters
|
||||
self.chapter_template = chapter_template
|
||||
self.subtitle_format = subtitle_format
|
||||
self.subtitle_language = subtitle_language
|
||||
self.subtitle_mode = subtitle_mode
|
||||
self.subtitle_files = []
|
||||
|
||||
class Download:
|
||||
manager = None
|
||||
@@ -113,11 +193,18 @@ class Download:
|
||||
self.temp_dir = temp_dir
|
||||
self.output_template = output_template
|
||||
self.output_template_chapter = output_template_chapter
|
||||
self.info = info
|
||||
self.format = get_format(format, quality)
|
||||
self.ytdl_opts = get_opts(format, quality, ytdl_opts)
|
||||
self.ytdl_opts = get_opts(
|
||||
format,
|
||||
quality,
|
||||
ytdl_opts,
|
||||
subtitle_format=getattr(info, 'subtitle_format', 'srt'),
|
||||
subtitle_language=getattr(info, 'subtitle_language', 'en'),
|
||||
subtitle_mode=getattr(info, 'subtitle_mode', 'prefer_manual'),
|
||||
)
|
||||
if "impersonate" in self.ytdl_opts:
|
||||
self.ytdl_opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.ytdl_opts["impersonate"])
|
||||
self.info = info
|
||||
self.canceled = False
|
||||
self.tmpfilename = None
|
||||
self.status_queue = None
|
||||
@@ -147,19 +234,18 @@ class Download:
|
||||
filepath = d['info_dict']['filepath']
|
||||
if '__finaldir' in d['info_dict']:
|
||||
finaldir = d['info_dict']['__finaldir']
|
||||
# Compute relative path from temp dir to preserve
|
||||
# subdirectory structure from the output template.
|
||||
try:
|
||||
rel_path = os.path.relpath(filepath, self.temp_dir)
|
||||
except ValueError:
|
||||
rel_path = os.path.basename(filepath)
|
||||
if rel_path.startswith('..'):
|
||||
# filepath is not under temp_dir, fall back to basename
|
||||
rel_path = os.path.basename(filepath)
|
||||
filename = os.path.join(finaldir, rel_path)
|
||||
filename = os.path.join(finaldir, os.path.basename(filepath))
|
||||
else:
|
||||
filename = filepath
|
||||
self.status_queue.put({'status': 'finished', 'filename': filename})
|
||||
# For captions-only downloads, yt-dlp may still report a media-like
|
||||
# filepath in MoveFiles. Capture subtitle outputs explicitly so the
|
||||
# UI can link to real caption files.
|
||||
if self.info.format == 'captions':
|
||||
requested_subtitles = d.get('info_dict', {}).get('requested_subtitles', {}) or {}
|
||||
for subtitle in requested_subtitles.values():
|
||||
if isinstance(subtitle, dict) and subtitle.get('filepath'):
|
||||
self.status_queue.put({'subtitle_file': subtitle['filepath']})
|
||||
|
||||
# Capture all chapter files when SplitChapters finishes
|
||||
elif d.get('postprocessor') == 'SplitChapters' and d.get('status') == 'finished':
|
||||
@@ -260,7 +346,15 @@ class Download:
|
||||
self.tmpfilename = status.get('tmpfilename')
|
||||
if 'filename' in status:
|
||||
fileName = status.get('filename')
|
||||
self.info.filename = os.path.relpath(fileName, self.download_dir)
|
||||
rel_name = os.path.relpath(fileName, self.download_dir)
|
||||
# For captions mode, ignore media-like placeholders and let subtitle_file
|
||||
# statuses define the final file shown in the UI.
|
||||
if self.info.format == 'captions':
|
||||
requested_subtitle_format = str(getattr(self.info, 'subtitle_format', '')).lower()
|
||||
allowed_caption_exts = ('.txt',) if requested_subtitle_format == 'txt' else ('.vtt', '.srt', '.sbv', '.scc', '.ttml', '.dfxp')
|
||||
if not rel_name.lower().endswith(allowed_caption_exts):
|
||||
continue
|
||||
self.info.filename = rel_name
|
||||
self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None
|
||||
if self.info.format == 'thumbnail':
|
||||
self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename)
|
||||
@@ -280,6 +374,37 @@ class Download:
|
||||
# Skip the rest of status processing for chapter files
|
||||
continue
|
||||
|
||||
if 'subtitle_file' in status:
|
||||
subtitle_file = status.get('subtitle_file')
|
||||
if not subtitle_file:
|
||||
continue
|
||||
subtitle_output_file = subtitle_file
|
||||
|
||||
# txt mode is derived from SRT by stripping cue metadata.
|
||||
if self.info.format == 'captions' and str(getattr(self.info, 'subtitle_format', '')).lower() == 'txt':
|
||||
converted_txt = _convert_srt_to_txt_file(subtitle_file)
|
||||
if converted_txt:
|
||||
subtitle_output_file = converted_txt
|
||||
if converted_txt != subtitle_file:
|
||||
try:
|
||||
os.remove(subtitle_file)
|
||||
except OSError as exc:
|
||||
log.debug(f"Could not remove temporary SRT file {subtitle_file}: {exc}")
|
||||
|
||||
rel_path = os.path.relpath(subtitle_output_file, self.download_dir)
|
||||
file_size = os.path.getsize(subtitle_output_file) if os.path.exists(subtitle_output_file) else None
|
||||
existing = next((sf for sf in self.info.subtitle_files if sf['filename'] == rel_path), None)
|
||||
if not existing:
|
||||
self.info.subtitle_files.append({'filename': rel_path, 'size': file_size})
|
||||
# Prefer first subtitle file as the primary result link in captions mode.
|
||||
if self.info.format == 'captions' and (
|
||||
not getattr(self.info, 'filename', None) or
|
||||
str(getattr(self.info, 'subtitle_format', '')).lower() == 'txt'
|
||||
):
|
||||
self.info.filename = rel_path
|
||||
self.info.size = file_size
|
||||
continue
|
||||
|
||||
self.info.status = status['status']
|
||||
self.info.msg = status.get('msg')
|
||||
if 'downloaded_bytes' in status:
|
||||
@@ -421,6 +546,12 @@ class DownloadQueue:
|
||||
self.active_downloads = set()
|
||||
self.semaphore = asyncio.Semaphore(int(self.config.MAX_CONCURRENT_DOWNLOADS))
|
||||
self.done.load()
|
||||
self._add_generation = 0
|
||||
self._canceled_urls = set() # URLs canceled during current playlist add
|
||||
|
||||
def cancel_add(self):
|
||||
self._add_generation += 1
|
||||
log.info('Playlist add operation canceled by user')
|
||||
|
||||
async def __import_queue(self):
|
||||
for k, v in self.queue.saved_items():
|
||||
@@ -462,6 +593,20 @@ class DownloadQueue:
|
||||
else:
|
||||
self.done.put(download)
|
||||
asyncio.create_task(self.notifier.completed(download.info))
|
||||
try:
|
||||
clear_after = int(self.config.CLEAR_COMPLETED_AFTER)
|
||||
except ValueError:
|
||||
log.error(f'CLEAR_COMPLETED_AFTER is set to an invalid value "{self.config.CLEAR_COMPLETED_AFTER}", expected an integer number of seconds')
|
||||
clear_after = 0
|
||||
if clear_after > 0:
|
||||
task = asyncio.create_task(self.__auto_clear_after_delay(download.info.url, clear_after))
|
||||
task.add_done_callback(lambda t: log.error(f'Auto-clear task failed: {t.exception()}') if not t.cancelled() and t.exception() else None)
|
||||
|
||||
async def __auto_clear_after_delay(self, url, delay_seconds):
|
||||
await asyncio.sleep(delay_seconds)
|
||||
if self.done.exists(url):
|
||||
log.debug(f'Auto-clearing completed download: {url}')
|
||||
await self.clear([url])
|
||||
|
||||
def __extract_info(self, url):
|
||||
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
|
||||
@@ -506,13 +651,13 @@ class DownloadQueue:
|
||||
output = self.config.OUTPUT_TEMPLATE_PLAYLIST
|
||||
for property, value in entry.items():
|
||||
if property.startswith("playlist"):
|
||||
output = _outtmpl_substitute_field(output, property, value)
|
||||
output = _outtmpl_substitute_field(output, property, _sanitize_path_component(value))
|
||||
if entry is not None and entry.get('channel_index') is not None:
|
||||
if len(self.config.OUTPUT_TEMPLATE_CHANNEL):
|
||||
output = self.config.OUTPUT_TEMPLATE_CHANNEL
|
||||
for property, value in entry.items():
|
||||
if property.startswith("channel"):
|
||||
output = _outtmpl_substitute_field(output, property, value)
|
||||
output = _outtmpl_substitute_field(output, property, _sanitize_path_component(value))
|
||||
ytdl_options = dict(self.config.YTDL_OPTIONS)
|
||||
playlist_item_limit = getattr(dl, 'playlist_item_limit', 0)
|
||||
if playlist_item_limit > 0:
|
||||
@@ -526,7 +671,23 @@ class DownloadQueue:
|
||||
self.pending.put(download)
|
||||
await self.notifier.added(dl)
|
||||
|
||||
async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already):
|
||||
async def __add_entry(
|
||||
self,
|
||||
entry,
|
||||
quality,
|
||||
format,
|
||||
folder,
|
||||
custom_name_prefix,
|
||||
playlist_item_limit,
|
||||
auto_start,
|
||||
split_by_chapters,
|
||||
chapter_template,
|
||||
subtitle_format,
|
||||
subtitle_language,
|
||||
subtitle_mode,
|
||||
already,
|
||||
_add_gen=None,
|
||||
):
|
||||
if not entry:
|
||||
return {'status': 'error', 'msg': "Invalid/empty data was given."}
|
||||
|
||||
@@ -542,7 +703,22 @@ class DownloadQueue:
|
||||
|
||||
if etype.startswith('url'):
|
||||
log.debug('Processing as a url')
|
||||
return await self.add(entry['url'], quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already)
|
||||
return await self.add(
|
||||
entry['url'],
|
||||
quality,
|
||||
format,
|
||||
folder,
|
||||
custom_name_prefix,
|
||||
playlist_item_limit,
|
||||
auto_start,
|
||||
split_by_chapters,
|
||||
chapter_template,
|
||||
subtitle_format,
|
||||
subtitle_language,
|
||||
subtitle_mode,
|
||||
already,
|
||||
_add_gen,
|
||||
)
|
||||
elif etype == 'playlist' or etype == 'channel':
|
||||
log.debug(f'Processing as a {etype}')
|
||||
entries = entry['entries']
|
||||
@@ -556,27 +732,89 @@ class DownloadQueue:
|
||||
log.info(f'Item limit is set. Processing only first {playlist_item_limit} entries')
|
||||
entries = entries[:playlist_item_limit]
|
||||
for index, etr in enumerate(entries, start=1):
|
||||
if _add_gen is not None and self._add_generation != _add_gen:
|
||||
log.info(f'Playlist add canceled after processing {len(already)} entries')
|
||||
return {'status': 'ok', 'msg': f'Canceled - added {len(already)} items before cancel'}
|
||||
etr["_type"] = "video"
|
||||
etr[etype] = entry.get("id") or entry.get("channel_id") or entry.get("channel")
|
||||
etr[f"{etype}_index"] = '{{0:0{0:d}d}}'.format(index_digits).format(index)
|
||||
for property in ("id", "title", "uploader", "uploader_id"):
|
||||
if property in entry:
|
||||
etr[f"{etype}_{property}"] = entry[property]
|
||||
results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already))
|
||||
results.append(
|
||||
await self.__add_entry(
|
||||
etr,
|
||||
quality,
|
||||
format,
|
||||
folder,
|
||||
custom_name_prefix,
|
||||
playlist_item_limit,
|
||||
auto_start,
|
||||
split_by_chapters,
|
||||
chapter_template,
|
||||
subtitle_format,
|
||||
subtitle_language,
|
||||
subtitle_mode,
|
||||
already,
|
||||
_add_gen,
|
||||
)
|
||||
)
|
||||
if any(res['status'] == 'error' for res in results):
|
||||
return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)}
|
||||
return {'status': 'ok'}
|
||||
elif etype == 'video' or (etype.startswith('url') and 'id' in entry and 'title' in entry):
|
||||
log.debug('Processing as a video')
|
||||
key = entry.get('webpage_url') or entry['url']
|
||||
if key in self._canceled_urls:
|
||||
log.info(f'Skipping canceled URL: {entry.get("title") or key}')
|
||||
return {'status': 'ok'}
|
||||
if not self.queue.exists(key):
|
||||
dl = DownloadInfo(entry['id'], entry.get('title') or entry['id'], key, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit, split_by_chapters, chapter_template)
|
||||
dl = DownloadInfo(
|
||||
entry['id'],
|
||||
entry.get('title') or entry['id'],
|
||||
key,
|
||||
quality,
|
||||
format,
|
||||
folder,
|
||||
custom_name_prefix,
|
||||
error,
|
||||
entry,
|
||||
playlist_item_limit,
|
||||
split_by_chapters,
|
||||
chapter_template,
|
||||
subtitle_format,
|
||||
subtitle_language,
|
||||
subtitle_mode,
|
||||
)
|
||||
await self.__add_download(dl, auto_start)
|
||||
return {'status': 'ok'}
|
||||
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
|
||||
|
||||
async def add(self, url, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start=True, split_by_chapters=False, chapter_template=None, already=None):
|
||||
log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} {playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=}')
|
||||
async def add(
|
||||
self,
|
||||
url,
|
||||
quality,
|
||||
format,
|
||||
folder,
|
||||
custom_name_prefix,
|
||||
playlist_item_limit,
|
||||
auto_start=True,
|
||||
split_by_chapters=False,
|
||||
chapter_template=None,
|
||||
subtitle_format="srt",
|
||||
subtitle_language="en",
|
||||
subtitle_mode="prefer_manual",
|
||||
already=None,
|
||||
_add_gen=None,
|
||||
):
|
||||
log.info(
|
||||
f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} '
|
||||
f'{playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=} '
|
||||
f'{subtitle_format=} {subtitle_language=} {subtitle_mode=}'
|
||||
)
|
||||
if already is None:
|
||||
_add_gen = self._add_generation
|
||||
self._canceled_urls.clear()
|
||||
already = set() if already is None else already
|
||||
if url in already:
|
||||
log.info('recursion detected, skipping')
|
||||
@@ -587,7 +825,22 @@ class DownloadQueue:
|
||||
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url)
|
||||
except yt_dlp.utils.YoutubeDLError as exc:
|
||||
return {'status': 'error', 'msg': str(exc)}
|
||||
return await self.__add_entry(entry, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already)
|
||||
return await self.__add_entry(
|
||||
entry,
|
||||
quality,
|
||||
format,
|
||||
folder,
|
||||
custom_name_prefix,
|
||||
playlist_item_limit,
|
||||
auto_start,
|
||||
split_by_chapters,
|
||||
chapter_template,
|
||||
subtitle_format,
|
||||
subtitle_language,
|
||||
subtitle_mode,
|
||||
already,
|
||||
_add_gen,
|
||||
)
|
||||
|
||||
async def start_pending(self, ids):
|
||||
for id in ids:
|
||||
@@ -602,6 +855,8 @@ class DownloadQueue:
|
||||
|
||||
async def cancel(self, ids):
|
||||
for id in ids:
|
||||
# Track URL so playlist add loop won't re-queue it
|
||||
self._canceled_urls.add(id)
|
||||
if self.pending.exists(id):
|
||||
self.pending.delete(id)
|
||||
await self.notifier.canceled(id)
|
||||
|
||||
@@ -23,21 +23,21 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.1.5",
|
||||
"@angular/common": "^21.1.5",
|
||||
"@angular/compiler": "^21.1.5",
|
||||
"@angular/core": "^21.1.5",
|
||||
"@angular/forms": "^21.1.5",
|
||||
"@angular/platform-browser": "^21.1.5",
|
||||
"@angular/platform-browser-dynamic": "^21.1.5",
|
||||
"@angular/service-worker": "^21.1.5",
|
||||
"@angular/animations": "^21.2.1",
|
||||
"@angular/common": "^21.2.1",
|
||||
"@angular/compiler": "^21.2.1",
|
||||
"@angular/core": "^21.2.1",
|
||||
"@angular/forms": "^21.2.1",
|
||||
"@angular/platform-browser": "^21.2.1",
|
||||
"@angular/platform-browser-dynamic": "^21.2.1",
|
||||
"@angular/service-worker": "^21.2.1",
|
||||
"@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.4.0",
|
||||
"@ng-select/ng-select": "^21.5.2",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
"ngx-cookie-service": "^21.1.0",
|
||||
@@ -48,13 +48,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-eslint/builder": "21.1.0",
|
||||
"@angular/build": "^21.1.4",
|
||||
"@angular/cli": "^21.1.4",
|
||||
"@angular/compiler-cli": "^21.1.5",
|
||||
"@angular/localize": "^21.1.5",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@angular/build": "^21.2.1",
|
||||
"@angular/cli": "^21.2.1",
|
||||
"@angular/compiler-cli": "^21.2.1",
|
||||
"@angular/localize": "^21.2.1",
|
||||
"@eslint/js": "^9.39.3",
|
||||
"angular-eslint": "21.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint": "^9.39.3",
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "8.47.0",
|
||||
|
||||
2277
ui/pnpm-lock.yaml
generated
2277
ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -98,15 +98,22 @@
|
||||
name="addUrl"
|
||||
[(ngModel)]="addUrl"
|
||||
[disabled]="addInProgress || downloads.loading">
|
||||
<button class="btn btn-primary btn-lg px-4"
|
||||
type="submit"
|
||||
(click)="addDownload()"
|
||||
[disabled]="addInProgress || downloads.loading">
|
||||
@if (addInProgress) {
|
||||
<span class="spinner-border spinner-border-sm" role="status" id="add-spinner"></span>
|
||||
}
|
||||
{{ addInProgress ? "Adding..." : "Download" }}
|
||||
</button>
|
||||
@if (addInProgress && cancelRequested) {
|
||||
<button class="btn btn-warning btn-lg px-3" type="button" disabled>
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
Canceling...
|
||||
</button>
|
||||
} @else if (addInProgress) {
|
||||
<button class="btn btn-danger btn-lg px-3" type="button" (click)="cancelAdding()">
|
||||
<fa-icon [icon]="faTimesCircle" class="me-1" /> Cancel
|
||||
</button>
|
||||
} @else {
|
||||
<button class="btn btn-primary btn-lg px-4" type="submit"
|
||||
(click)="addDownload()"
|
||||
[disabled]="downloads.loading">
|
||||
Download
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,6 +225,60 @@
|
||||
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
|
||||
</div>
|
||||
</div>
|
||||
@if (format === 'captions') {
|
||||
<div class="col-md-4">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Subtitles</span>
|
||||
<select class="form-select"
|
||||
name="subtitleFormat"
|
||||
[(ngModel)]="subtitleFormat"
|
||||
(change)="subtitleFormatChanged()"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
ngbTooltip="Subtitle output format for captions mode">
|
||||
@for (fmt of subtitleFormats; track fmt.id) {
|
||||
<option [ngValue]="fmt.id">{{ fmt.text }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (subtitleFormat === 'txt') {
|
||||
<div class="form-text">TXT is generated from SRT by stripping timestamps and cue numbers.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Language</span>
|
||||
<input class="form-control"
|
||||
type="text"
|
||||
list="subtitleLanguageOptions"
|
||||
name="subtitleLanguage"
|
||||
[(ngModel)]="subtitleLanguage"
|
||||
(change)="subtitleLanguageChanged()"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
placeholder="e.g. en, es, zh-Hans"
|
||||
ngbTooltip="Subtitle language (you can type any language code)">
|
||||
<datalist id="subtitleLanguageOptions">
|
||||
@for (lang of subtitleLanguages; track lang.id) {
|
||||
<option [value]="lang.id">{{ lang.text }}</option>
|
||||
}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Subtitle Source</span>
|
||||
<select class="form-select"
|
||||
name="subtitleMode"
|
||||
[(ngModel)]="subtitleMode"
|
||||
(change)="subtitleModeChanged()"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
ngbTooltip="Choose manual, auto, or fallback preference for captions mode">
|
||||
@for (mode of subtitleModes; track mode.id) {
|
||||
<option [ngValue]="mode.id">{{ mode.text }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="col-12">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-auto">
|
||||
@@ -247,30 +308,71 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<hr class="my-3">
|
||||
<div class="row g-2">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
(click)="openBatchImportModal()">
|
||||
<fa-icon [icon]="faFileImport" class="me-2" />
|
||||
Import URLs
|
||||
</button>
|
||||
<div class="action-group-label">Cookies</div>
|
||||
<input type="file" id="cookie-upload" class="d-none" accept=".txt"
|
||||
(change)="onCookieFileSelect($event)"
|
||||
[disabled]="cookieUploadInProgress || addInProgress">
|
||||
<div class="btn-group w-100" role="group">
|
||||
<label class="btn mb-0"
|
||||
[class]="hasCookies ? 'btn cookie-active-btn mb-0' : 'btn cookie-btn mb-0'"
|
||||
[class.disabled]="cookieUploadInProgress || addInProgress"
|
||||
for="cookie-upload"
|
||||
ngbTooltip="Upload a cookies.txt file for authenticated downloads">
|
||||
@if (cookieUploadInProgress) {
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
} @else {
|
||||
<fa-icon [icon]="faUpload" class="me-2" />
|
||||
}
|
||||
{{ hasCookies ? 'Replace Cookies' : 'Upload Cookies' }}
|
||||
</label>
|
||||
@if (hasCookies) {
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
(click)="deleteCookies()"
|
||||
[disabled]="cookieUploadInProgress || addInProgress"
|
||||
ngbTooltip="Remove uploaded cookies">
|
||||
<fa-icon [icon]="faTrashAlt" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="cookie-status" [class.active]="hasCookies">
|
||||
@if (hasCookies) {
|
||||
<fa-icon [icon]="faCheckCircle" class="me-1" />
|
||||
Cookies active
|
||||
} @else {
|
||||
No cookies configured
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
(click)="exportBatchUrls('all')">
|
||||
<fa-icon [icon]="faFileExport" class="me-2" />
|
||||
Export URLs
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
(click)="copyBatchUrls('all')">
|
||||
<fa-icon [icon]="faCopy" class="me-2" />
|
||||
Copy URLs
|
||||
</button>
|
||||
<div class="col-md-8">
|
||||
<div class="action-group-label">Bulk Actions</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-4">
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
(click)="openBatchImportModal()">
|
||||
<fa-icon [icon]="faFileImport" class="me-2" />
|
||||
Import URLs
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
(click)="exportBatchUrls('all')">
|
||||
<fa-icon [icon]="faFileExport" class="me-2" />
|
||||
Export URLs
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
(click)="copyBatchUrls('all')">
|
||||
<fa-icon [icon]="faCopy" class="me-2" />
|
||||
Copy URLs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -372,6 +474,7 @@
|
||||
|
||||
<div class="metube-section-header">Completed</div>
|
||||
<div class="px-2 py-3 border-bottom">
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="toggleSortOrder()" ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}"><fa-icon [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" /> {{ sortAscending ? 'Oldest first' : 'Newest first' }}</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" /> Clear selected</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" /> Clear completed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearFailed (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle" /> Clear failed</button>
|
||||
@@ -387,62 +490,99 @@
|
||||
</th>
|
||||
<th scope="col">Video</th>
|
||||
<th scope="col">File Size</th>
|
||||
<th scope="col">Downloaded</th>
|
||||
<th scope="col" style="width: 8rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (download of downloads.done | keyvalue: asIsOrder; track download.value.id) {
|
||||
<tr [class.disabled]='download.value.deleting'>
|
||||
@for (entry of cachedSortedDone; track entry[1].id) {
|
||||
<tr [class.disabled]='entry[1].deleting'>
|
||||
<td>
|
||||
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckboxRef" [checkable]="download.value" />
|
||||
<app-slave-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" />
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: inline-block; width: 1.5rem;">
|
||||
@if (download.value.status === 'finished') {
|
||||
@if (entry[1].status === 'finished') {
|
||||
<fa-icon [icon]="faCheckCircle" class="text-success" />
|
||||
}
|
||||
@if (download.value.status === 'error') {
|
||||
<fa-icon [icon]="faTimesCircle" class="text-danger" />
|
||||
@if (entry[1].status === 'error') {
|
||||
<button type="button" class="btn btn-link p-0"
|
||||
(click)="toggleErrorDetail(entry[0])"
|
||||
[attr.aria-label]="'Toggle error details for ' + entry[1].title"
|
||||
[attr.aria-expanded]="isErrorExpanded(entry[0])">
|
||||
<fa-icon [icon]="faTimesCircle" class="text-danger" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span ngbTooltip="{{buildResultItemTooltip(download.value)}}">@if (!!download.value.filename) {
|
||||
<a href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.title }}</a>
|
||||
<span ngbTooltip="{{buildResultItemTooltip(entry[1])}}">@if (!!entry[1].filename) {
|
||||
<a href="{{buildDownloadLink(entry[1])}}" target="_blank">{{ entry[1].title }}</a>
|
||||
} @else {
|
||||
{{download.value.title}}
|
||||
@if (download.value.msg) {
|
||||
<span><br>{{download.value.msg}}</span>
|
||||
}
|
||||
@if (download.value.error) {
|
||||
<span><br>Error: {{download.value.error}}</span>
|
||||
}
|
||||
<span [style.cursor]="entry[1].status === 'error' ? 'pointer' : 'default'"
|
||||
(click)="entry[1].status === 'error' ? toggleErrorDetail(entry[0]) : null">
|
||||
{{entry[1].title}}
|
||||
@if (entry[1].status === 'error' && !isErrorExpanded(entry[0])) {
|
||||
<small class="text-danger ms-2">
|
||||
<fa-icon [icon]="faChevronRight" size="xs" class="me-1" />Click for details
|
||||
</small>
|
||||
}
|
||||
</span>
|
||||
}</span>
|
||||
@if (entry[1].status === 'error' && isErrorExpanded(entry[0])) {
|
||||
<div class="alert alert-danger py-2 px-3 mt-2 mb-0 small" style="border-left: 4px solid var(--bs-danger);">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
@if (entry[1].msg) {
|
||||
<div class="mb-1"><strong>Message:</strong> {{entry[1].msg}}</div>
|
||||
}
|
||||
@if (entry[1].error) {
|
||||
<div class="mb-1"><strong>Error:</strong> {{entry[1].error}}</div>
|
||||
}
|
||||
<div class="text-muted" style="word-break: break-all;"><strong>URL:</strong> {{entry[1].url}}</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger ms-2 flex-shrink-0"
|
||||
(click)="copyErrorMessage(entry[0], entry[1]); $event.stopPropagation()"
|
||||
ngbTooltip="Copy error details to clipboard">
|
||||
@if (lastCopiedErrorId === entry[0]) {
|
||||
<span class="text-success">Copied!</span>
|
||||
} @else {
|
||||
<fa-icon [icon]="faCopy" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (download.value.size) {
|
||||
<span>{{ download.value.size | fileSize }}</span>
|
||||
@if (entry[1].size) {
|
||||
<span>{{ entry[1].size | fileSize }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
@if (entry[1].timestamp) {
|
||||
<span>{{ entry[1].timestamp / 1000000 | date:'yyyy-MM-dd HH:mm' }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
@if (download.value.status === 'error') {
|
||||
<button type="button" class="btn btn-link" (click)="retryDownload(download.key, download.value)"><fa-icon [icon]="faRedoAlt" /></button>
|
||||
@if (entry[1].status === 'error') {
|
||||
<button type="button" class="btn btn-link" (click)="retryDownload(entry[0], entry[1])"><fa-icon [icon]="faRedoAlt" /></button>
|
||||
}
|
||||
@if (download.value.filename) {
|
||||
<a href="{{buildDownloadLink(download.value)}}" download class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
|
||||
@if (entry[1].filename) {
|
||||
<a href="{{buildDownloadLink(entry[1])}}" download class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
|
||||
}
|
||||
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
||||
<button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
|
||||
<a href="{{entry[1].url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
||||
<button type="button" class="btn btn-link" (click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@if (download.value.chapter_files && download.value.chapter_files.length > 0) {
|
||||
@for (chapterFile of download.value.chapter_files; track chapterFile.filename) {
|
||||
<tr [class.disabled]='download.value.deleting'>
|
||||
@if (entry[1].chapter_files && entry[1].chapter_files.length > 0) {
|
||||
@for (chapterFile of entry[1].chapter_files; track chapterFile.filename) {
|
||||
<tr [class.disabled]='entry[1].deleting'>
|
||||
<td></td>
|
||||
<td>
|
||||
<div style="padding-left: 2rem;">
|
||||
<fa-icon [icon]="faCheckCircle" class="text-success me-2" />
|
||||
<a href="{{buildChapterDownloadLink(download.value, chapterFile.filename)}}" target="_blank">{{
|
||||
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" target="_blank">{{
|
||||
getChapterFileName(chapterFile.filename) }}</a>
|
||||
</div>
|
||||
</td>
|
||||
@@ -451,9 +591,10 @@
|
||||
<span>{{ chapterFile.size | fileSize }}</span>
|
||||
}
|
||||
</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
<a href="{{buildChapterDownloadLink(download.value, chapterFile.filename)}}" download
|
||||
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" download
|
||||
class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -209,3 +209,48 @@ main
|
||||
|
||||
span
|
||||
white-space: nowrap
|
||||
|
||||
.cookie-btn
|
||||
flex: 1 1 auto
|
||||
background-color: var(--bs-secondary-bg)
|
||||
border-color: var(--bs-border-color)
|
||||
color: var(--bs-emphasis-color)
|
||||
|
||||
&:hover
|
||||
background-color: var(--bs-tertiary-bg)
|
||||
border-color: var(--bs-secondary)
|
||||
color: var(--bs-emphasis-color)
|
||||
|
||||
&.disabled
|
||||
opacity: 0.65
|
||||
pointer-events: none
|
||||
|
||||
.cookie-active-btn
|
||||
flex: 1 1 auto
|
||||
background-color: var(--bs-success-bg-subtle)
|
||||
border-color: var(--bs-success-border-subtle)
|
||||
color: var(--bs-success-text-emphasis)
|
||||
|
||||
&:hover
|
||||
background-color: var(--bs-success-bg-subtle)
|
||||
border-color: var(--bs-success)
|
||||
color: var(--bs-success-text-emphasis)
|
||||
|
||||
&.disabled
|
||||
opacity: 0.65
|
||||
pointer-events: none
|
||||
|
||||
.action-group-label
|
||||
font-size: 0.7rem
|
||||
text-transform: uppercase
|
||||
letter-spacing: 0.05em
|
||||
color: var(--bs-secondary-color)
|
||||
margin-bottom: 0.4rem
|
||||
|
||||
.cookie-status
|
||||
font-size: 0.8rem
|
||||
margin-top: 0.35rem
|
||||
color: var(--bs-secondary-color)
|
||||
|
||||
&.active
|
||||
color: var(--bs-success-text-emphasis)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AsyncPipe, KeyValuePipe } from '@angular/common';
|
||||
import { AsyncPipe, DatePipe, KeyValuePipe } from '@angular/common';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { AfterViewInit, Component, ElementRef, viewChild, inject, OnInit } from '@angular/core';
|
||||
import { Observable, map, distinctUntilChanged } from 'rxjs';
|
||||
@@ -6,7 +6,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faUpload } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faGithub } from '@fortawesome/free-brands-svg-icons';
|
||||
import { CookieService } from 'ngx-cookie-service';
|
||||
import { DownloadsService } from './services/downloads.service';
|
||||
@@ -21,6 +21,7 @@ import { MasterCheckboxComponent , SlaveCheckboxComponent} from './components/';
|
||||
FormsModule,
|
||||
KeyValuePipe,
|
||||
AsyncPipe,
|
||||
DatePipe,
|
||||
FontAwesomeModule,
|
||||
NgbModule,
|
||||
NgSelectModule,
|
||||
@@ -49,7 +50,13 @@ export class App implements AfterViewInit, OnInit {
|
||||
playlistItemLimit!: number;
|
||||
splitByChapters: boolean;
|
||||
chapterTemplate: string;
|
||||
subtitleFormat: string;
|
||||
subtitleLanguage: string;
|
||||
subtitleMode: string;
|
||||
addInProgress = false;
|
||||
cancelRequested = false;
|
||||
hasCookies = false;
|
||||
cookieUploadInProgress = false;
|
||||
themes: Theme[] = Themes;
|
||||
activeTheme: Theme | undefined;
|
||||
customDirs$!: Observable<string[]>;
|
||||
@@ -63,6 +70,10 @@ export class App implements AfterViewInit, OnInit {
|
||||
ytDlpVersion: string | null = null;
|
||||
metubeVersion: string | null = null;
|
||||
isAdvancedOpen = false;
|
||||
sortAscending = false;
|
||||
expandedErrors: Set<string> = new Set();
|
||||
cachedSortedDone: [string, Download][] = [];
|
||||
lastCopiedErrorId: string | null = null;
|
||||
|
||||
// Download metrics
|
||||
activeDownloads = 0;
|
||||
@@ -97,6 +108,67 @@ export class App implements AfterViewInit, OnInit {
|
||||
faGithub = faGithub;
|
||||
faClock = faClock;
|
||||
faTachometerAlt = faTachometerAlt;
|
||||
faSortAmountDown = faSortAmountDown;
|
||||
faSortAmountUp = faSortAmountUp;
|
||||
faChevronRight = faChevronRight;
|
||||
faUpload = faUpload;
|
||||
subtitleFormats = [
|
||||
{ id: 'srt', text: 'SRT' },
|
||||
{ id: 'txt', text: 'TXT (Text only)' },
|
||||
{ id: 'vtt', text: 'VTT' },
|
||||
{ id: 'ttml', text: 'TTML' }
|
||||
];
|
||||
subtitleLanguages = [
|
||||
{ id: 'en', text: 'English' },
|
||||
{ id: 'ar', text: 'Arabic' },
|
||||
{ id: 'bn', text: 'Bengali' },
|
||||
{ id: 'bg', text: 'Bulgarian' },
|
||||
{ id: 'ca', text: 'Catalan' },
|
||||
{ id: 'cs', text: 'Czech' },
|
||||
{ id: 'da', text: 'Danish' },
|
||||
{ id: 'nl', text: 'Dutch' },
|
||||
{ id: 'es', text: 'Spanish' },
|
||||
{ id: 'et', text: 'Estonian' },
|
||||
{ id: 'fi', text: 'Finnish' },
|
||||
{ id: 'fr', text: 'French' },
|
||||
{ id: 'de', text: 'German' },
|
||||
{ id: 'el', text: 'Greek' },
|
||||
{ id: 'he', text: 'Hebrew' },
|
||||
{ id: 'hi', text: 'Hindi' },
|
||||
{ id: 'hu', text: 'Hungarian' },
|
||||
{ id: 'id', text: 'Indonesian' },
|
||||
{ id: 'it', text: 'Italian' },
|
||||
{ id: 'lt', text: 'Lithuanian' },
|
||||
{ id: 'lv', text: 'Latvian' },
|
||||
{ id: 'ms', text: 'Malay' },
|
||||
{ id: 'no', text: 'Norwegian' },
|
||||
{ id: 'pl', text: 'Polish' },
|
||||
{ id: 'pt', text: 'Portuguese' },
|
||||
{ id: 'pt-BR', text: 'Portuguese (Brazil)' },
|
||||
{ id: 'ro', text: 'Romanian' },
|
||||
{ id: 'ru', text: 'Russian' },
|
||||
{ id: 'sk', text: 'Slovak' },
|
||||
{ id: 'sl', text: 'Slovenian' },
|
||||
{ id: 'sr', text: 'Serbian' },
|
||||
{ id: 'sv', text: 'Swedish' },
|
||||
{ id: 'ta', text: 'Tamil' },
|
||||
{ id: 'te', text: 'Telugu' },
|
||||
{ id: 'th', text: 'Thai' },
|
||||
{ id: 'tr', text: 'Turkish' },
|
||||
{ id: 'uk', text: 'Ukrainian' },
|
||||
{ id: 'ur', text: 'Urdu' },
|
||||
{ id: 'vi', text: 'Vietnamese' },
|
||||
{ id: 'ja', text: 'Japanese' },
|
||||
{ id: 'ko', text: 'Korean' },
|
||||
{ id: 'zh-Hans', text: 'Chinese (Simplified)' },
|
||||
{ id: 'zh-Hant', text: 'Chinese (Traditional)' },
|
||||
];
|
||||
subtitleModes = [
|
||||
{ id: 'prefer_manual', text: 'Prefer Manual' },
|
||||
{ id: 'prefer_auto', text: 'Prefer Auto' },
|
||||
{ id: 'manual_only', text: 'Manual Only' },
|
||||
{ id: 'auto_only', text: 'Auto Only' },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.format = this.cookieService.get('metube_format') || 'any';
|
||||
@@ -107,6 +179,18 @@ export class App implements AfterViewInit, OnInit {
|
||||
this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
|
||||
// Will be set from backend configuration, use empty string as placeholder
|
||||
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '';
|
||||
this.subtitleFormat = this.cookieService.get('metube_subtitle_format') || 'srt';
|
||||
this.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en';
|
||||
this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual';
|
||||
const allowedSubtitleFormats = new Set(this.subtitleFormats.map(fmt => fmt.id));
|
||||
const allowedSubtitleModes = new Set(this.subtitleModes.map(mode => mode.id));
|
||||
if (!allowedSubtitleFormats.has(this.subtitleFormat)) {
|
||||
this.subtitleFormat = 'srt';
|
||||
}
|
||||
if (!allowedSubtitleModes.has(this.subtitleMode)) {
|
||||
this.subtitleMode = 'prefer_manual';
|
||||
}
|
||||
this.sortAscending = this.cookieService.get('metube_sort_ascending') === 'true';
|
||||
|
||||
this.activeTheme = this.getPreferredTheme(this.cookieService);
|
||||
|
||||
@@ -116,6 +200,7 @@ export class App implements AfterViewInit, OnInit {
|
||||
});
|
||||
this.downloads.doneChanged.subscribe(() => {
|
||||
this.updateMetrics();
|
||||
this.rebuildSortedDone();
|
||||
});
|
||||
// Subscribe to real-time updates
|
||||
this.downloads.updated.subscribe(() => {
|
||||
@@ -124,6 +209,9 @@ export class App implements AfterViewInit, OnInit {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.downloads.getCookieStatus().subscribe(data => {
|
||||
this.hasCookies = data?.has_cookies || false;
|
||||
});
|
||||
this.getConfiguration();
|
||||
this.getYtdlOptionsUpdateTime();
|
||||
this.customDirs$ = this.getMatchingCustomDir();
|
||||
@@ -279,6 +367,18 @@ export class App implements AfterViewInit, OnInit {
|
||||
this.cookieService.set('metube_chapter_template', this.chapterTemplate, { expires: 3650 });
|
||||
}
|
||||
|
||||
subtitleFormatChanged() {
|
||||
this.cookieService.set('metube_subtitle_format', this.subtitleFormat, { expires: 3650 });
|
||||
}
|
||||
|
||||
subtitleLanguageChanged() {
|
||||
this.cookieService.set('metube_subtitle_language', this.subtitleLanguage, { expires: 3650 });
|
||||
}
|
||||
|
||||
subtitleModeChanged() {
|
||||
this.cookieService.set('metube_subtitle_mode', this.subtitleMode, { expires: 3650 });
|
||||
}
|
||||
|
||||
queueSelectionChanged(checked: number) {
|
||||
this.queueDelSelected().nativeElement.disabled = checked == 0;
|
||||
this.queueDownloadSelected().nativeElement.disabled = checked == 0;
|
||||
@@ -299,7 +399,20 @@ export class App implements AfterViewInit, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
addDownload(url?: string, quality?: string, format?: string, folder?: string, customNamePrefix?: string, playlistItemLimit?: number, autoStart?: boolean, splitByChapters?: boolean, chapterTemplate?: string) {
|
||||
addDownload(
|
||||
url?: string,
|
||||
quality?: string,
|
||||
format?: string,
|
||||
folder?: string,
|
||||
customNamePrefix?: string,
|
||||
playlistItemLimit?: number,
|
||||
autoStart?: boolean,
|
||||
splitByChapters?: boolean,
|
||||
chapterTemplate?: string,
|
||||
subtitleFormat?: string,
|
||||
subtitleLanguage?: string,
|
||||
subtitleMode?: string,
|
||||
) {
|
||||
url = url ?? this.addUrl
|
||||
quality = quality ?? this.quality
|
||||
format = format ?? this.format
|
||||
@@ -309,6 +422,9 @@ export class App implements AfterViewInit, OnInit {
|
||||
autoStart = autoStart ?? this.autoStart
|
||||
splitByChapters = splitByChapters ?? this.splitByChapters
|
||||
chapterTemplate = chapterTemplate ?? this.chapterTemplate
|
||||
subtitleFormat = subtitleFormat ?? this.subtitleFormat
|
||||
subtitleLanguage = subtitleLanguage ?? this.subtitleLanguage
|
||||
subtitleMode = subtitleMode ?? this.subtitleMode
|
||||
|
||||
// Validate chapter template if chapter splitting is enabled
|
||||
if (splitByChapters && !chapterTemplate.includes('%(section_number)')) {
|
||||
@@ -316,15 +432,26 @@ export class App implements AfterViewInit, OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('Downloading: url=' + url + ' quality=' + quality + ' format=' + format + ' folder=' + folder + ' customNamePrefix=' + customNamePrefix + ' playlistItemLimit=' + playlistItemLimit + ' autoStart=' + autoStart + ' splitByChapters=' + splitByChapters + ' chapterTemplate=' + chapterTemplate);
|
||||
console.debug('Downloading: url=' + url + ' quality=' + quality + ' format=' + format + ' folder=' + folder + ' customNamePrefix=' + customNamePrefix + ' playlistItemLimit=' + playlistItemLimit + ' autoStart=' + autoStart + ' splitByChapters=' + splitByChapters + ' chapterTemplate=' + chapterTemplate + ' subtitleFormat=' + subtitleFormat + ' subtitleLanguage=' + subtitleLanguage + ' subtitleMode=' + subtitleMode);
|
||||
this.addInProgress = true;
|
||||
this.downloads.add(url, quality, format, folder, customNamePrefix, playlistItemLimit, autoStart, splitByChapters, chapterTemplate).subscribe((status: Status) => {
|
||||
if (status.status === 'error') {
|
||||
this.cancelRequested = false;
|
||||
this.downloads.add(url, quality, format, folder, customNamePrefix, playlistItemLimit, autoStart, splitByChapters, chapterTemplate, subtitleFormat, subtitleLanguage, subtitleMode).subscribe((status: Status) => {
|
||||
if (status.status === 'error' && !this.cancelRequested) {
|
||||
alert(`Error adding URL: ${status.msg}`);
|
||||
} else {
|
||||
} else if (status.status !== 'error') {
|
||||
this.addUrl = '';
|
||||
}
|
||||
this.addInProgress = false;
|
||||
this.cancelRequested = false;
|
||||
});
|
||||
}
|
||||
|
||||
cancelAdding() {
|
||||
this.cancelRequested = true;
|
||||
this.downloads.cancelAdd().subscribe({
|
||||
error: (err) => {
|
||||
console.error('Failed to cancel adding:', err?.message || err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -333,7 +460,20 @@ export class App implements AfterViewInit, OnInit {
|
||||
}
|
||||
|
||||
retryDownload(key: string, download: Download) {
|
||||
this.addDownload(download.url, download.quality, download.format, download.folder, download.custom_name_prefix, download.playlist_item_limit, true, download.split_by_chapters, download.chapter_template);
|
||||
this.addDownload(
|
||||
download.url,
|
||||
download.quality,
|
||||
download.format,
|
||||
download.folder,
|
||||
download.custom_name_prefix,
|
||||
download.playlist_item_limit,
|
||||
true,
|
||||
download.split_by_chapters,
|
||||
download.chapter_template,
|
||||
download.subtitle_format,
|
||||
download.subtitle_language,
|
||||
download.subtitle_mode,
|
||||
);
|
||||
this.downloads.delById('done', [key]).subscribe();
|
||||
}
|
||||
|
||||
@@ -479,7 +619,8 @@ export class App implements AfterViewInit, OnInit {
|
||||
this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`;
|
||||
// Now pass the selected quality, format, folder, etc. to the add() method
|
||||
this.downloads.add(url, this.quality, this.format, this.folder, this.customNamePrefix,
|
||||
this.playlistItemLimit, this.autoStart, this.splitByChapters, this.chapterTemplate)
|
||||
this.playlistItemLimit, this.autoStart, this.splitByChapters, this.chapterTemplate,
|
||||
this.subtitleFormat, this.subtitleLanguage, this.subtitleMode)
|
||||
.subscribe({
|
||||
next: (status: Status) => {
|
||||
if (status.status === 'error') {
|
||||
@@ -586,6 +727,136 @@ export class App implements AfterViewInit, OnInit {
|
||||
this.isAdvancedOpen = !this.isAdvancedOpen;
|
||||
}
|
||||
|
||||
toggleSortOrder() {
|
||||
this.sortAscending = !this.sortAscending;
|
||||
this.cookieService.set('metube_sort_ascending', this.sortAscending ? 'true' : 'false', { expires: 3650 });
|
||||
this.rebuildSortedDone();
|
||||
}
|
||||
|
||||
private rebuildSortedDone() {
|
||||
const result: [string, Download][] = [];
|
||||
this.downloads.done.forEach((dl, key) => {
|
||||
result.push([key, dl]);
|
||||
});
|
||||
if (!this.sortAscending) {
|
||||
result.reverse();
|
||||
}
|
||||
this.cachedSortedDone = result;
|
||||
}
|
||||
|
||||
toggleErrorDetail(id: string) {
|
||||
if (this.expandedErrors.has(id)) this.expandedErrors.delete(id);
|
||||
else this.expandedErrors.add(id);
|
||||
}
|
||||
|
||||
copyErrorMessage(id: string, download: Download) {
|
||||
const parts: string[] = [];
|
||||
if (download.title) parts.push(`Title: ${download.title}`);
|
||||
if (download.url) parts.push(`URL: ${download.url}`);
|
||||
if (download.msg) parts.push(`Message: ${download.msg}`);
|
||||
if (download.error) parts.push(`Error: ${download.error}`);
|
||||
const text = parts.join('\n');
|
||||
if (!text.trim()) return;
|
||||
const done = () => {
|
||||
this.lastCopiedErrorId = id;
|
||||
setTimeout(() => { this.lastCopiedErrorId = null; }, 1500);
|
||||
};
|
||||
const fail = (err?: unknown) => {
|
||||
console.error('Clipboard write failed:', err);
|
||||
alert('Failed to copy to clipboard. Your browser may require HTTPS for clipboard access.');
|
||||
};
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done).catch(fail);
|
||||
} else {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
done();
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isErrorExpanded(id: string): boolean {
|
||||
return this.expandedErrors.has(id);
|
||||
}
|
||||
|
||||
onCookieFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input.files?.length) return;
|
||||
this.cookieUploadInProgress = true;
|
||||
this.downloads.uploadCookies(input.files[0]).subscribe({
|
||||
next: (response) => {
|
||||
if (response?.status === 'ok') {
|
||||
this.hasCookies = true;
|
||||
} else {
|
||||
this.refreshCookieStatus();
|
||||
alert(`Error uploading cookies: ${this.formatErrorMessage(response?.msg)}`);
|
||||
}
|
||||
this.cookieUploadInProgress = false;
|
||||
input.value = '';
|
||||
},
|
||||
error: () => {
|
||||
this.refreshCookieStatus();
|
||||
this.cookieUploadInProgress = false;
|
||||
input.value = '';
|
||||
alert('Error uploading cookies.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private formatErrorMessage(error: unknown): string {
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
if (error && typeof error === 'object') {
|
||||
const obj = error as Record<string, unknown>;
|
||||
for (const key of ['msg', 'reason', 'error', 'detail']) {
|
||||
const value = obj[key];
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return 'Unknown error';
|
||||
}
|
||||
}
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
deleteCookies() {
|
||||
this.downloads.deleteCookies().subscribe({
|
||||
next: (response) => {
|
||||
if (response?.status === 'ok') {
|
||||
this.refreshCookieStatus();
|
||||
return;
|
||||
}
|
||||
this.refreshCookieStatus();
|
||||
alert(`Error deleting cookies: ${this.formatErrorMessage(response?.msg)}`);
|
||||
},
|
||||
error: () => {
|
||||
this.refreshCookieStatus();
|
||||
alert('Error deleting cookies.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private refreshCookieStatus() {
|
||||
this.downloads.getCookieStatus().subscribe(data => {
|
||||
this.hasCookies = data?.has_cookies || false;
|
||||
});
|
||||
}
|
||||
|
||||
private updateMetrics() {
|
||||
this.activeDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'downloading' || d.status === 'preparing').length;
|
||||
this.queuedDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'pending').length;
|
||||
|
||||
@@ -10,6 +10,9 @@ export interface Download {
|
||||
playlist_item_limit: number;
|
||||
split_by_chapters?: boolean;
|
||||
chapter_template?: string;
|
||||
subtitle_format?: string;
|
||||
subtitle_language?: string;
|
||||
subtitle_mode?: string;
|
||||
status: string;
|
||||
msg: string;
|
||||
percent: number;
|
||||
@@ -17,6 +20,7 @@ export interface Download {
|
||||
eta: number;
|
||||
filename: string;
|
||||
checked: boolean;
|
||||
timestamp?: number;
|
||||
size?: number;
|
||||
error?: string;
|
||||
deleting?: boolean;
|
||||
|
||||
@@ -73,4 +73,9 @@ export const Formats: Format[] = [
|
||||
text: 'Thumbnail',
|
||||
qualities: [{ id: 'best', text: 'Best' }],
|
||||
},
|
||||
{
|
||||
id: 'captions',
|
||||
text: 'Captions',
|
||||
qualities: [{ id: 'best', text: 'Best' }],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -107,8 +107,34 @@ export class DownloadsService {
|
||||
return of({status: 'error', msg: msg})
|
||||
}
|
||||
|
||||
public add(url: string, quality: string, format: string, folder: string, customNamePrefix: string, playlistItemLimit: number, autoStart: boolean, splitByChapters: boolean, chapterTemplate: string) {
|
||||
return this.http.post<Status>('add', { url: url, quality: quality, format: format, folder: folder, custom_name_prefix: customNamePrefix, playlist_item_limit: playlistItemLimit, auto_start: autoStart, split_by_chapters: splitByChapters, chapter_template: chapterTemplate }).pipe(
|
||||
public add(
|
||||
url: string,
|
||||
quality: string,
|
||||
format: string,
|
||||
folder: string,
|
||||
customNamePrefix: string,
|
||||
playlistItemLimit: number,
|
||||
autoStart: boolean,
|
||||
splitByChapters: boolean,
|
||||
chapterTemplate: string,
|
||||
subtitleFormat: string,
|
||||
subtitleLanguage: string,
|
||||
subtitleMode: string,
|
||||
) {
|
||||
return this.http.post<Status>('add', {
|
||||
url: url,
|
||||
quality: quality,
|
||||
format: format,
|
||||
folder: folder,
|
||||
custom_name_prefix: customNamePrefix,
|
||||
playlist_item_limit: playlistItemLimit,
|
||||
auto_start: autoStart,
|
||||
split_by_chapters: splitByChapters,
|
||||
chapter_template: chapterTemplate,
|
||||
subtitle_format: subtitleFormat,
|
||||
subtitle_language: subtitleLanguage,
|
||||
subtitle_mode: subtitleMode
|
||||
}).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
@@ -154,9 +180,25 @@ export class DownloadsService {
|
||||
const defaultAutoStart = true;
|
||||
const defaultSplitByChapters = false;
|
||||
const defaultChapterTemplate = this.configuration['OUTPUT_TEMPLATE_CHAPTER'];
|
||||
const defaultSubtitleFormat = 'srt';
|
||||
const defaultSubtitleLanguage = 'en';
|
||||
const defaultSubtitleMode = 'prefer_manual';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.add(url, defaultQuality, defaultFormat, defaultFolder, defaultCustomNamePrefix, defaultPlaylistItemLimit, defaultAutoStart, defaultSplitByChapters, defaultChapterTemplate)
|
||||
this.add(
|
||||
url,
|
||||
defaultQuality,
|
||||
defaultFormat,
|
||||
defaultFolder,
|
||||
defaultCustomNamePrefix,
|
||||
defaultPlaylistItemLimit,
|
||||
defaultAutoStart,
|
||||
defaultSplitByChapters,
|
||||
defaultChapterTemplate,
|
||||
defaultSubtitleFormat,
|
||||
defaultSubtitleLanguage,
|
||||
defaultSubtitleMode,
|
||||
)
|
||||
.subscribe({
|
||||
next: (response) => resolve(response),
|
||||
error: (error) => reject(error)
|
||||
@@ -166,6 +208,29 @@ export class DownloadsService {
|
||||
public exportQueueUrls(): string[] {
|
||||
return Array.from(this.queue.values()).map(download => download.url);
|
||||
}
|
||||
|
||||
|
||||
public cancelAdd() {
|
||||
return this.http.post<Status>('cancel-add', {}).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
|
||||
uploadCookies(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('cookies', file);
|
||||
return this.http.post<any>('upload-cookies', formData).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
|
||||
deleteCookies() {
|
||||
return this.http.post<any>('delete-cookies', {}).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
|
||||
getCookieStatus() {
|
||||
return this.http.get<any>('cookie-status').pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
180
uv.lock
generated
180
uv.lock
generated
@@ -176,11 +176,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.1.4"
|
||||
version = "2026.2.25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -303,15 +303,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "deno"
|
||||
version = "2.6.10"
|
||||
version = "2.7.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/a8/74db2941f56186028b6e91c6116a3e52c8459dfa28a5f990f578be2b69eb/deno-2.6.10.tar.gz", hash = "sha256:41c12a75197da6d9db20120eee7585c27766af0ac62c817c085c93dfc4081428", size = 8128, upload-time = "2026-02-17T16:09:30.841Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/29/b2941d53d94094e20e52def86956528140dbe60b49d715803f7e9799d42f/deno-2.7.2.tar.gz", hash = "sha256:3dc9461ac4dd0d6661769f03460861709e17c4e516dfce14676e6a3146824b7b", size = 8167, upload-time = "2026-03-03T16:10:51.429Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/2f/030638bfabbd63df9562e3900b791d6c07e5a30346d359cf45a5e1fc4097/deno-2.6.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c381b068b6f58ca1c19a0cf062c8489ee1c7fe430d3ebc48db944f5c80beb2c2", size = 45118320, upload-time = "2026-02-17T16:09:16.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/dc/0f71ff9dd513c8cc094dc76640f80088a27ea9a3b1fc2decd60a8a27148a/deno-2.6.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2da72aa734463600a5a16efd786a4aecd22ddcd6ea907f56fca15f9eb3ca794", size = 42060974, upload-time = "2026-02-17T16:09:19.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/f7/3774540e14111026251aef134bf3de3ae1c0591866f05ce5debc97248127/deno-2.6.10-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b98d6fc98a9cbc10219f6c6d6f2a6e10d1954416bbcd4371d317fc9c20050576", size = 45789105, upload-time = "2026-02-17T16:09:22.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/80/a2b5827f4715f0bcb5a550728ac98a32258f31e2a6f1c63803a078a425b0/deno-2.6.10-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:d1600f4ee7b4a9699b7057841bdb5d7b7eac3fb073df072540064166841c2f4c", size = 47737107, upload-time = "2026-02-17T16:09:25.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/60/75fbdc63d97f381182be1eeebe4bd1630d9819d05ae7c20143f700856a3a/deno-2.6.10-py3-none-win_amd64.whl", hash = "sha256:dc0b6b7a3e558b159c6e599eab6556766c96fe1d75d59383ce291ebf0083fcfd", size = 47088397, upload-time = "2026-02-17T16:09:28.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/a0/9e6f45c25ef36db827e75bd35bf9378c196a6bed2804a8259d1d63bab84f/deno-2.7.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:67509919fa9df639d9375e441648ae5a3ab9bb1ce6fcddc21c49c08368af4d68", size = 46325714, upload-time = "2026-03-03T16:10:35.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/ce/085c3002cdfc0d33b30896b3d1469024c23e3971cba4a15ae3983c48d2e4/deno-2.7.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a19f75d7a148a2d030543db88734f03648e31dc7385a9c62aa1d975e2b0df8d9", size = 43264279, upload-time = "2026-03-03T16:10:39.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/f0/c415c08ca30fb084887a96b88df7f6511c98575b365db87b0fac76a82773/deno-2.7.2-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:f7b63f13c9fdeb18d0435e80aa4677878ac1b9ac23a49c7570958b9d81772e06", size = 47024484, upload-time = "2026-03-03T16:10:42.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/14/bfac1928082f78f120aaff7608f211a8beab8f66e72defc0ac85d6f52f84/deno-2.7.2-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:bded39ebc9d19748a13a4c046a715f12c445a3e15c0b4cde6d42cc47793efcf0", size = 48981918, upload-time = "2026-03-03T16:10:45.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/07/b332f98969937d435ba2905195a0b3dd2162f192659595dde88c615b04e1/deno-2.7.2-py3-none-win_amd64.whl", hash = "sha256:5d525d270e16d5ea22ad90a65e1ebc0dff8b83068d698f6bad138bfa857e4d28", size = 48330774, upload-time = "2026-03-03T16:10:49.209Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -416,11 +416,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "isort"
|
||||
version = "7.0.0"
|
||||
version = "8.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -672,7 +672,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pylint"
|
||||
version = "4.0.4"
|
||||
version = "4.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "astroid" },
|
||||
@@ -683,9 +683,9 @@ dependencies = [
|
||||
{ name = "platformdirs" },
|
||||
{ name = "tomlkit" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/d2/b081da1a8930d00e3fc06352a1d449aaf815d4982319fab5d8cdb2e9ab35/pylint-4.0.4.tar.gz", hash = "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2", size = 1571735, upload-time = "2025-11-30T13:29:04.315Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/92/d40f5d937517cc489ad848fc4414ecccc7592e4686b9071e09e64f5e378e/pylint-4.0.4-py3-none-any.whl", hash = "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0", size = 536425, upload-time = "2025-11-30T13:29:02.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -865,89 +865,97 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "yarl"
|
||||
version = "1.22.0"
|
||||
version = "1.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "multidict" },
|
||||
{ name = "propcache" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yt-dlp"
|
||||
version = "2026.2.21"
|
||||
version = "2026.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/55ffff25204733e94a507552ad984d5a8a8e4f9d1f0d91763e6b1a41c79b/yt_dlp-2026.2.21.tar.gz", hash = "sha256:4407dfc1a71fec0dee5ef916a8d4b66057812939b509ae45451fa8fb4376b539", size = 3116630, upload-time = "2026-02-21T20:40:53.522Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/6f/7427d23609353e5ef3470ff43ef551b8bd7b166dd4fef48957f0d0e040fe/yt_dlp-2026.3.3.tar.gz", hash = "sha256:3db7969e3a8964dc786bdebcffa2653f31123bf2a630f04a17bdafb7bbd39952", size = 3118658, upload-time = "2026-03-03T16:54:53.909Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/40/664c99ee36d80d84ce7a96cd98aebcb3d16c19e6c3ad3461d2cf5424040e/yt_dlp-2026.2.21-py3-none-any.whl", hash = "sha256:0d8408f5b6d20487f5caeb946dfd04f9bcd2f1a3a125b744a0a982b590e449f7", size = 3313392, upload-time = "2026-02-21T20:40:51.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/a4/8b5cd28ab87aef48ef15e74241befec3445496327db028f34147a9e0f14f/yt_dlp-2026.3.3-py3-none-any.whl", hash = "sha256:166c6e68c49ba526474bd400e0129f58aa522c2896204aa73be669c3d2f15e63", size = 3315599, upload-time = "2026-03-03T16:54:51.899Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
||||
Reference in New Issue
Block a user