Publish v1.10: group-by-folder view toggle, versioning rule, publish script

This commit is contained in:
tigeren 2026-08-07 14:15:04 +00:00
parent f3be397a96
commit b1cfc10517
9 changed files with 728 additions and 394 deletions

13
AGENTS.md Normal file
View File

@ -0,0 +1,13 @@
# MeTube project notes
## Versioning rule
- The version number checked into git (recorded in `DEPLOY.md`) is the **current online version** — the version of the latest published Docker image.
- When any changes are made, the new Docker image version must **bump the minor number of the latest git version** (e.g. latest is `1.9` → new image is `1.10`).
- The version is injected into the image at build time via `--build-arg VERSION=<version>` (exposed as `METUBE_VERSION` and served by the `/version` endpoint).
- Whenever a new image is built and pushed, update the build/push commands in `DEPLOY.md` in the same commit so the checked-in version always matches the published image.
- Use `./publish.sh` to publish: it bumps the minor version from `DEPLOY.md`, updates `DEPLOY.md`, builds and pushes the image. Pass an explicit version (`./publish.sh 2.0`) to override. Do not run it unless the user asked to publish.
## UI
- Angular app in `ui/`. Build with `cd ui && npm run build`. The Docker image builds the UI itself (see `Dockerfile`), so no need to commit `ui/dist`.

View File

@ -1,5 +1,29 @@
docker build -t 192.168.2.212:3000/tigeren/metube:1.9 .
# Deployment
docker push 192.168.2.212:3000/tigeren/metube:1.9
## Versioning rule
- The version number recorded in this file (i.e. checked into git) is the **current online version** — the version of the latest published Docker image.
- When any changes are made, the new Docker image version must **bump the minor number of the latest git version** (e.g. latest is `1.9` → new image is `1.10`).
- Whenever a new image is built and pushed, update the commands below in the same commit, so the checked-in version always matches the published image.
## Publishing (automated)
Use `publish.sh` — it reads the current version from this file, bumps the minor, updates this file, builds the image with the version build-arg and pushes it:
```sh
./publish.sh # bump minor of the current version (e.g. 1.10 -> 1.11)
./publish.sh 2.0 # publish an explicit version instead
```
## Build & push (manual)
```sh
docker build --build-arg VERSION=1.10 -t 192.168.2.212:3000/tigeren/metube:1.10 .
docker push 192.168.2.212:3000/tigeren/metube:1.10
```
## Run
```sh
docker compose up -d --build --force-recreate
```

View File

@ -6,7 +6,7 @@ RUN npm ci && \
node_modules/.bin/ng build --configuration production
FROM python:3.13-alpine
FROM python:3.13-slim
WORKDIR /app
@ -16,11 +16,15 @@ COPY pyproject.toml uv.lock docker-entrypoint.sh ./
# Install dependencies
RUN sed -i 's/\r$//g' docker-entrypoint.sh && \
chmod +x docker-entrypoint.sh && \
apk add --update ffmpeg aria2 coreutils shadow su-exec curl tini deno chromium nss freetype harfbuzz ca-certificates && \
apk add --update --virtual .build-deps gcc g++ musl-dev uv && \
apt-get update && \
apt-get install -y --no-install-recommends ffmpeg aria2 curl tini gosu ca-certificates unzip \
chromium libnss3 libfreetype6 libharfbuzz0b fonts-freefont-ttf && \
ln -s /usr/sbin/gosu /usr/local/bin/su-exec && \
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh && \
pip install --no-cache-dir uv && \
UV_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode --extra headless && \
apk del .build-deps && \
rm -rf /var/cache/apk/* && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
mkdir /.cache && chmod 777 /.cache
COPY app ./app
@ -30,9 +34,9 @@ ENV UID=0
ENV GID=0
ENV UMASK=022
# Playwright settings for Alpine Chromium
# Playwright settings for system Chromium
ENV PLAYWRIGHT_BROWSERS_PATH=0
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
ENV DOWNLOAD_DIR /downloads
ENV STATE_DIR /downloads/.metube
@ -44,4 +48,4 @@ EXPOSE 8081
ARG VERSION=dev
ENV METUBE_VERSION=$VERSION
ENTRYPOINT ["/sbin/tini", "-g", "--", "./docker-entrypoint.sh"]
ENTRYPOINT ["tini", "-g", "--", "./docker-entrypoint.sh"]

59
publish.sh Executable file
View File

@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Publish a new MeTube docker image.
#
# Implements the versioning rule (see DEPLOY.md / AGENTS.md):
# - The version recorded in DEPLOY.md (checked into git) is the current
# online version.
# - Publishing bumps the minor number of that version, updates DEPLOY.md,
# builds the image with --build-arg VERSION=<new> and pushes it.
#
# Usage:
# ./publish.sh # bump minor of the current version (e.g. 1.10 -> 1.11)
# ./publish.sh 2.0 # publish an explicit version instead
set -euo pipefail
cd "$(dirname "$0")"
DEPLOY_FILE="DEPLOY.md"
# Current image reference (with tag), taken from the build command in DEPLOY.md
CURRENT_REF=$(grep -oP '(?<=-t )\S+:[0-9]+\.[0-9]+' "$DEPLOY_FILE" | head -1)
if [[ -z "$CURRENT_REF" ]]; then
echo "Error: could not find an image reference like '-t <registry>/<image>:<major>.<minor>' in $DEPLOY_FILE" >&2
exit 1
fi
IMAGE="${CURRENT_REF%:*}" # e.g. 192.168.2.212:3000/tigeren/metube
CURRENT_VERSION="${CURRENT_REF##*:}" # e.g. 1.10
if [[ $# -ge 1 ]]; then
NEW_VERSION="$1"
else
MAJOR="${CURRENT_VERSION%%.*}"
MINOR="${CURRENT_VERSION##*.}"
NEW_VERSION="$MAJOR.$((MINOR + 1))"
fi
if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+$ ]]; then
echo "Error: version must be in <major>.<minor> form, got '$NEW_VERSION'" >&2
exit 1
fi
if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then
echo "Error: new version $NEW_VERSION equals current version" >&2
exit 1
fi
echo "Publishing $IMAGE:$NEW_VERSION (current: $CURRENT_VERSION)"
# Record the new version as the current online version
sed -i "s/VERSION=${CURRENT_VERSION}\b/VERSION=${NEW_VERSION}/g; s/:${CURRENT_VERSION}\b/:${NEW_VERSION}/g" "$DEPLOY_FILE"
docker build --build-arg "VERSION=$NEW_VERSION" -t "$IMAGE:$NEW_VERSION" .
docker push "$IMAGE:$NEW_VERSION"
echo
echo "Published $IMAGE:$NEW_VERSION"
echo "DEPLOY.md updated to the new current online version."
echo "Suggested next step:"
echo " git add -A && git commit -m 'Publish v$NEW_VERSION'"

View File

@ -317,6 +317,7 @@
<div class="px-2 py-3 border-bottom">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt"></fa-icon>&nbsp; Cancel selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDownloadSelected (click)="startSelectedDownloads('queue')"><fa-icon [icon]="faDownload"></fa-icon>&nbsp; Download selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [ngClass]="{'fw-bold': groupByFolder}" (click)="toggleGroupByFolder()" ngbTooltip="Group downloads by folder"><fa-icon [icon]="faFolderOpen"></fa-icon>&nbsp; Group by folder</button>
</div>
<div class="overflow-auto">
<table class="table">
@ -326,35 +327,50 @@
<app-master-checkbox #queueMasterCheckbox [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)"></app-master-checkbox>
</th>
<th scope="col">Video</th>
<th scope="col" class="sortable-header" (click)="toggleSortOrder()" ngbTooltip="Sort by creation time">
Created
<fa-icon [icon]="sortAscending ? faSortUp : faSortDown"></fa-icon>
</th>
<th scope="col">Folder</th>
<th scope="col" style="width: 8rem;">Speed</th>
<th scope="col" style="width: 7rem;">ETA</th>
<th scope="col" style="width: 6rem;"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let download of downloads.queue | keyvalue: asIsOrder; trackBy: identifyDownloadRow" [class.disabled]='download.value.deleting'>
<td>
<app-slave-checkbox [id]="download.key" [master]="queueMasterCheckbox" [checkable]="download.value"></app-slave-checkbox>
<ng-container *ngFor="let row of displayRows('queue'); trackBy: identifyDisplayRow">
<tr *ngIf="row.type === 'header'" class="folder-group-header">
<td colspan="7">
<fa-icon [icon]="faFolderOpen"></fa-icon>&nbsp; {{ row.folder }}
<span class="folder-group-count">{{ row.count }}</span>
</td>
<td title="{{ download.value.filename }}">
</tr>
<tr *ngIf="row.type === 'item'" [class.disabled]='row.value.deleting'>
<td>
<app-slave-checkbox [id]="row.key" [master]="queueMasterCheckbox" [checkable]="row.value"></app-slave-checkbox>
</td>
<td title="{{ row.value.filename }}">
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
<div>
<span class="website-tag" *ngIf="download.value.website" [title]="download.value.website">{{ getSimplifiedDomain(download.value.website) }}</span>
{{ download.value.title }}
<span class="website-tag" *ngIf="row.value.website" [title]="row.value.website">{{ getSimplifiedDomain(row.value.website) }}</span>
{{ row.value.title }}
</div>
<ngb-progressbar height="1.5rem" [showValue]="download.value.status != 'preparing'" [striped]="download.value.status == 'preparing'" [animated]="download.value.status == 'preparing'" type="success" [value]="download.value.status == 'preparing' ? 100 : download.value.percent | number:'1.0-0'" class="download-progressbar"></ngb-progressbar>
<ngb-progressbar height="1.5rem" [showValue]="row.value.status != 'preparing'" [striped]="row.value.status == 'preparing'" [animated]="row.value.status == 'preparing'" type="success" [value]="row.value.status == 'preparing' ? 100 : row.value.percent | number:'1.0-0'" class="download-progressbar"></ngb-progressbar>
</div>
</td>
<td>{{ download.value.speed | speed }}</td>
<td>{{ download.value.eta | eta }}</td>
<td>{{ formatTimestamp(row.value.timestamp) }}</td>
<td>{{ row.value.folder || 'Default' }}</td>
<td>{{ row.value.speed | speed }}</td>
<td>{{ row.value.eta | eta }}</td>
<td>
<div class="d-flex">
<button *ngIf="download.value.status === 'pending'" type="button" class="btn btn-link" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload"></fa-icon></button>
<button type="button" class="btn btn-link" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
<button *ngIf="row.value.status === 'pending'" type="button" class="btn btn-link" (click)="downloadItemByKey(row.key)"><fa-icon [icon]="faDownload"></fa-icon></button>
<button type="button" class="btn btn-link" (click)="delDownload('queue', row.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
<a href="{{row.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
</div>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
@ -366,6 +382,7 @@
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearFailed (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle"></fa-icon>&nbsp; Clear failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneRetryFailed (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt"></fa-icon>&nbsp; Retry failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected (click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload"></fa-icon>&nbsp; Download Selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [ngClass]="{'fw-bold': groupByFolder}" (click)="toggleGroupByFolder()" ngbTooltip="Group downloads by folder"><fa-icon [icon]="faFolderOpen"></fa-icon>&nbsp; Group by folder</button>
</div>
<div class="overflow-auto">
<table class="table">
@ -375,46 +392,61 @@
<app-master-checkbox #doneMasterCheckbox [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)"></app-master-checkbox>
</th>
<th scope="col">Video</th>
<th scope="col" class="sortable-header" (click)="toggleSortOrder()" ngbTooltip="Sort by creation time">
Created
<fa-icon [icon]="sortAscending ? faSortUp : faSortDown"></fa-icon>
</th>
<th scope="col">Folder</th>
<th scope="col">File Size</th>
<th scope="col" style="width: 8rem;"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let download of downloads.done | keyvalue: asIsOrder; trackBy: identifyDownloadRow" [class.disabled]='download.value.deleting'>
<ng-container *ngFor="let row of displayRows('done'); trackBy: identifyDisplayRow">
<tr *ngIf="row.type === 'header'" class="folder-group-header">
<td colspan="6">
<fa-icon [icon]="faFolderOpen"></fa-icon>&nbsp; {{ row.folder }}
<span class="folder-group-count">{{ row.count }}</span>
</td>
</tr>
<tr *ngIf="row.type === 'item'" [class.disabled]='row.value.deleting'>
<td>
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckbox" [checkable]="download.value"></app-slave-checkbox>
<app-slave-checkbox [id]="row.key" [master]="doneMasterCheckbox" [checkable]="row.value"></app-slave-checkbox>
</td>
<td>
<div style="display: inline-block; width: 1.5rem;">
<fa-icon *ngIf="download.value.status == 'finished'" [icon]="faCheckCircle" class="text-success"></fa-icon>
<fa-icon *ngIf="download.value.status == 'error'" [icon]="faTimesCircle" class="text-danger"></fa-icon>
<fa-icon *ngIf="row.value.status == 'finished'" [icon]="faCheckCircle" class="text-success"></fa-icon>
<fa-icon *ngIf="row.value.status == 'error'" [icon]="faTimesCircle" class="text-danger"></fa-icon>
</div>
<span class="website-tag" *ngIf="download.value.website" [title]="download.value.website">{{ getSimplifiedDomain(download.value.website) }}</span>
<span class="file-exists-indicator" *ngIf="download.value.filename" [ngClass]="{'file-exists': download.value.file_exists, 'file-missing': download.value.file_exists === false}" [title]="download.value.file_exists ? 'File exists' : 'File not found'">
<fa-icon *ngIf="download.value.file_exists" [icon]="faCheckCircle" class="text-success"></fa-icon>
<fa-icon *ngIf="download.value.file_exists === false" [icon]="faTimesCircle" class="text-warning"></fa-icon>
<span class="website-tag" *ngIf="row.value.website" [title]="row.value.website">{{ getSimplifiedDomain(row.value.website) }}</span>
<span class="file-exists-indicator" *ngIf="row.value.filename" [ngClass]="{'file-exists': row.value.file_exists, 'file-missing': row.value.file_exists === false}" [title]="row.value.file_exists ? 'File exists' : 'File not found'">
<fa-icon *ngIf="row.value.file_exists" [icon]="faCheckCircle" class="text-success"></fa-icon>
<fa-icon *ngIf="row.value.file_exists === false" [icon]="faTimesCircle" class="text-warning"></fa-icon>
</span>
<span ngbTooltip="{{download.value.msg}} | {{download.value.error}}">
<a *ngIf="!!download.value.filename && download.value.file_exists !== false; else noDownloadLink" href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.title }}</a>
<span ngbTooltip="{{row.value.msg}} | {{row.value.error}}">
<a *ngIf="!!row.value.filename && row.value.file_exists !== false; else noDownloadLink" href="{{buildDownloadLink(row.value)}}" target="_blank">{{ row.value.title }}</a>
</span>
<ng-template #noDownloadLink>
{{download.value.title}}
<span *ngIf="download.value.msg"><br>{{download.value.msg}}</span>
<span *ngIf="download.value.error"><br>Error: {{download.value.error}}</span>
{{row.value.title}}
<span *ngIf="row.value.msg"><br>{{row.value.msg}}</span>
<span *ngIf="row.value.error"><br>Error: {{row.value.error}}</span>
</ng-template>
</td>
<td>{{ formatTimestamp(row.value.timestamp) }}</td>
<td>{{ row.value.folder || 'Default' }}</td>
<td>
<span *ngIf="download.value.size">{{ download.value.size | fileSize }}</span>
<span *ngIf="row.value.size">{{ row.value.size | fileSize }}</span>
</td>
<td>
<div class="d-flex">
<button *ngIf="download.value.status == 'error'" type="button" class="btn btn-link" (click)="retryDownload(download.key, download.value)"><fa-icon [icon]="faRedoAlt"></fa-icon></button>
<a *ngIf="download.value.filename" href="{{buildDownloadLink(download.value)}}" download class="btn btn-link"><fa-icon [icon]="faDownload"></fa-icon></a>
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
<button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
<button *ngIf="row.value.status == 'error'" type="button" class="btn btn-link" (click)="retryDownload(row.key, row.value)"><fa-icon [icon]="faRedoAlt"></fa-icon></button>
<a *ngIf="row.value.filename" href="{{buildDownloadLink(row.value)}}" download class="btn btn-link"><fa-icon [icon]="faDownload"></fa-icon></a>
<a href="{{row.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
<button type="button" class="btn btn-link" (click)="delDownload('done', row.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
</div>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>

View File

@ -24,6 +24,10 @@ button.add-url
padding-left: 5px
padding-right: 5px
.sortable-header
cursor: pointer
user-select: none
.metube-section-header
font-size: 1.8rem
font-weight: 300
@ -58,6 +62,23 @@ td
opacity: 0.5
pointer-events: none
.folder-group-header
td
background: var(--bs-secondary-bg)
font-weight: 600
color: var(--bs-secondary-color)
padding-top: 0.4rem
padding-bottom: 0.4rem
.folder-group-count
display: inline-block
margin-left: 0.5rem
padding: 0.1rem 0.5rem
border-radius: 1rem
background: var(--bs-tertiary-bg)
font-size: 0.75rem
font-weight: 500
.form-switch
input
margin-top: 5px

View File

@ -1,7 +1,7 @@
import { Component, ViewChild, ElementRef, AfterViewInit, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { faTrashAlt, faCheckCircle, faTimesCircle, IconDefinition } from '@fortawesome/free-regular-svg-icons';
import { faRedoAlt, faSun, faMoon, faCircleHalfStroke, faCheck, faExternalLinkAlt, faDownload, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt } from '@fortawesome/free-solid-svg-icons';
import { faRedoAlt, faSun, faMoon, faCircleHalfStroke, faCheck, faExternalLinkAlt, faDownload, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSort, faSortUp, faSortDown, faFolderOpen } from '@fortawesome/free-solid-svg-icons';
import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { CookieService } from 'ngx-cookie-service';
import { map, Observable, of, distinctUntilChanged } from 'rxjs';
@ -12,6 +12,11 @@ import { Formats, Format, Quality } from './formats';
import { Theme, Themes } from './theme';
import {KeyValue} from "@angular/common";
// A row in the download tables: either a folder group header or a download item
type DisplayRow =
| { type: 'header'; key: string; folder: string; count: number }
| { type: 'item'; key: string; value: Download };
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
@ -44,6 +49,12 @@ export class AppComponent implements OnInit, AfterViewInit {
metubeVersion: string | null = null;
isAdvancedOpen = false;
// Download list sorting by creation time (default: newest first)
sortAscending = false;
// Group downloads by folder instead of showing a flat list
groupByFolder = false;
// Download metrics
activeDownloads = 0;
queuedDownloads = 0;
@ -77,6 +88,10 @@ export class AppComponent implements OnInit, AfterViewInit {
faGithub = faGithub;
faClock = faClock;
faTachometerAlt = faTachometerAlt;
faSort = faSort;
faSortUp = faSortUp;
faSortDown = faSortDown;
faFolderOpen = faFolderOpen;
// Events from backend (last 5 events)
events: Array<{type: string, message: string, timestamp: number, url: string}> = [];
@ -87,6 +102,8 @@ export class AppComponent implements OnInit, AfterViewInit {
this.setQualities()
this.quality = cookieService.get('metube_quality') || 'best';
this.autoStart = cookieService.get('metube_auto_start') !== 'false';
this.sortAscending = cookieService.get('metube_sort_order') === 'asc';
this.groupByFolder = cookieService.get('metube_group_by_folder') === 'true';
this.activeTheme = this.getPreferredTheme(cookieService);
@ -154,6 +171,69 @@ export class AppComponent implements OnInit, AfterViewInit {
return 1;
}
// Sort downloads by creation time (timestamp is in nanoseconds)
compareByTimestamp = (a: KeyValue<string, Download>, b: KeyValue<string, Download>): number => {
const dir = this.sortAscending ? 1 : -1;
return ((a.value.timestamp ?? 0) - (b.value.timestamp ?? 0)) * dir;
}
toggleSortOrder() {
this.sortAscending = !this.sortAscending;
this.cookieService.set('metube_sort_order', this.sortAscending ? 'asc' : 'desc', { expires: 3650 });
}
toggleGroupByFolder() {
this.groupByFolder = !this.groupByFolder;
this.cookieService.set('metube_group_by_folder', this.groupByFolder ? 'true' : 'false', { expires: 3650 });
}
// Build the rows for a download table: a flat list, or folder header rows
// interleaved with the items of each folder when group-by-folder is enabled.
displayRows(where: 'queue' | 'done'): DisplayRow[] {
const items: KeyValue<string, Download>[] = [];
this.downloads[where].forEach((value, key) => items.push({ key, value }));
items.sort(this.compareByTimestamp);
if (!this.groupByFolder) {
return items.map(item => ({ type: 'item', key: item.key, value: item.value }));
}
const groups = new Map<string, KeyValue<string, Download>[]>();
for (const item of items) {
const folder = item.value.folder || '';
if (!groups.has(folder)) {
groups.set(folder, []);
}
groups.get(folder).push(item);
}
// Sort folders alphabetically, keeping the default (folderless) group first
const folders = Array.from(groups.keys()).sort((a, b) => {
if (a === '') return -1;
if (b === '') return 1;
return a.localeCompare(b);
});
const rows: DisplayRow[] = [];
for (const folder of folders) {
const groupItems = groups.get(folder);
rows.push({ type: 'header', key: 'header:' + folder, folder: folder || 'Default', count: groupItems.length });
for (const item of groupItems) {
rows.push({ type: 'item', key: item.key, value: item.value });
}
}
return rows;
}
identifyDisplayRow(index: number, row: DisplayRow) {
return row.key;
}
formatTimestamp(timestamp: number): string {
if (!timestamp) return '';
return new Date(timestamp / 1e6).toLocaleString();
}
qualityChanged() {
this.cookieService.set('metube_quality', this.quality, { expires: 3650 });
// Re-trigger custom directory change
@ -380,10 +460,6 @@ export class AppComponent implements OnInit, AfterViewInit {
return baseDir + encodeURIComponent(download.filename);
}
identifyDownloadRow(index: number, row: KeyValue<string, Download>) {
return row.key;
}
isNumber(event) {
const charCode = (event.which) ? event.which : event.keyCode;
if (charCode > 31 && (charCode < 48 || charCode > 57)) {

View File

@ -25,6 +25,7 @@ export interface Download {
speed: number;
eta: number;
filename: string;
timestamp?: number;
website?: string;
file_exists?: boolean;
checked?: boolean;

104
uv.lock
View File

@ -301,6 +301,63 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" },
]
[[package]]
name = "greenlet"
version = "3.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" },
{ url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" },
{ url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845, upload-time = "2026-07-22T12:43:45.19Z" },
{ url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" },
{ url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053, upload-time = "2026-07-22T12:39:52.715Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" },
{ url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" },
{ url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" },
{ url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" },
{ url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" },
{ url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" },
{ url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" },
{ url = "https://files.pythonhosted.org/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c", size = 677968, upload-time = "2026-07-22T12:43:46.788Z" },
{ url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" },
{ url = "https://files.pythonhosted.org/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c", size = 480643, upload-time = "2026-07-22T12:39:54.149Z" },
{ url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" },
{ url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" },
{ url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" },
{ url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" },
{ url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" },
{ url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" },
{ url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" },
{ url = "https://files.pythonhosted.org/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8", size = 681690, upload-time = "2026-07-22T12:43:48.109Z" },
{ url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" },
{ url = "https://files.pythonhosted.org/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd", size = 510263, upload-time = "2026-07-22T12:39:55.678Z" },
{ url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" },
{ url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" },
{ url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" },
{ url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" },
{ url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" },
{ url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" },
{ url = "https://files.pythonhosted.org/packages/51/a7/dafc7415d430b0a43a16396eb49ecb3b62fd720877fb259cc4dcfaf5f31e/greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3", size = 681428, upload-time = "2026-07-22T12:43:49.623Z" },
{ url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" },
{ url = "https://files.pythonhosted.org/packages/2e/d9/6298f3432de301d4718766cf934bd73c418c73f81fbb77247319364b0d96/greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb", size = 487446, upload-time = "2026-07-22T12:39:57.044Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" },
{ url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" },
{ url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" },
{ url = "https://files.pythonhosted.org/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667", size = 247423, upload-time = "2026-07-22T11:44:00.764Z" },
{ url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" },
{ url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" },
{ url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" },
{ url = "https://files.pythonhosted.org/packages/88/15/0b167aeea95285b0e654ddce651922f666c089363c2ec528ca8b9a9ba74f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d", size = 685995, upload-time = "2026-07-22T12:43:50.993Z" },
{ url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" },
{ url = "https://files.pythonhosted.org/packages/de/90/c023ec337f32ff505be7db759c80d98f0532bb94d0c6fa13645efe9bee2e/greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05", size = 516928, upload-time = "2026-07-22T12:39:58.359Z" },
{ url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" },
{ url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" },
{ url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@ -350,6 +407,11 @@ dependencies = [
{ name = "yt-dlp", extra = ["curl-cffi", "default"] },
]
[package.optional-dependencies]
headless = [
{ name = "playwright" },
]
[package.dev-dependencies]
dev = [
{ name = "pylint" },
@ -360,10 +422,12 @@ requires-dist = [
{ name = "aiohttp" },
{ name = "curl-cffi" },
{ name = "mutagen" },
{ name = "playwright", marker = "extra == 'headless'" },
{ name = "python-socketio", specifier = ">=5.0,<6.0" },
{ name = "watchfiles" },
{ name = "yt-dlp", extras = ["default", "curl-cffi"] },
]
provides-extras = ["headless"]
[package.metadata.requires-dev]
dev = [{ name = "pylint" }]
@ -431,6 +495,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" },
]
[[package]]
name = "playwright"
version = "1.62.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/5b/ca2abcf3aa69f9fb510215e3064f30b57fe57657c8d04ede45bb966d5606/playwright-1.62.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d8da938f3748841a8754f2e1f0216902c1c8f8ae3720de8b32ccf8e6913a7c4f", size = 43732091, upload-time = "2026-07-31T17:00:44.178Z" },
{ url = "https://files.pythonhosted.org/packages/af/1a/0bfbe9904350961f4dbb713f04342e40d548c5fc26c8157bd13617c81492/playwright-1.62.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:db755ab27db21a04186f1fe8169888e42356086e439b1059b923ef417f0b6034", size = 42510842, upload-time = "2026-07-31T17:00:48.596Z" },
{ url = "https://files.pythonhosted.org/packages/66/dc/c0486b407ad0699a250f6bbe3066fca95344009a99ca66e88ca175c69dc1/playwright-1.62.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:5108bd5b3e87169ddf269feee097da5893af7f8aea4634dfc840518d64c1f1da", size = 43732093, upload-time = "2026-07-31T17:00:52.218Z" },
{ url = "https://files.pythonhosted.org/packages/43/6b/b24aebc2b04bffcb342bccf96e287c78b363e1615bed5cea97500cc0393a/playwright-1.62.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ba33bae6a13b3d9d354c751cb618af357d20fe1d57767cbcce52079bbef17ad3", size = 47748926, upload-time = "2026-07-31T17:00:56.438Z" },
{ url = "https://files.pythonhosted.org/packages/36/43/b4b18bdc87e1949568fffdcde3ff9a0456266b2d0c6d4432cc34d89ea6eb/playwright-1.62.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db2d76613a57ad844362ce42f7d0c2fa26b19a4f7a46d4f76b891c631e6e5aff", size = 47441423, upload-time = "2026-07-31T17:01:00.404Z" },
{ url = "https://files.pythonhosted.org/packages/81/22/af5d926fc2c32a339eec00a443644bc40ab9db1dd2dd9017873c59773c0c/playwright-1.62.0-py3-none-win32.whl", hash = "sha256:e5614fa89355d7081457680324bb219f79f69c423c5cb6fa250e30b0d8aebf1c", size = 38164450, upload-time = "2026-07-31T17:01:04.187Z" },
{ url = "https://files.pythonhosted.org/packages/2b/a9/4160c1033c07af98bf841ad079457dd78408a5ee0dd56cbfe50b8b6a1c22/playwright-1.62.0-py3-none-win_amd64.whl", hash = "sha256:92c0d98ed04eb35af557b709875edba415b1f548bdb22ddb5bb3e1e6c835c2f1", size = 38164458, upload-time = "2026-07-31T17:01:08.459Z" },
{ url = "https://files.pythonhosted.org/packages/6c/ec/06b55d619a7082a766aa04f2c6bb31435c87f02930087d8a0517119408fa/playwright-1.62.0-py3-none-win_arm64.whl", hash = "sha256:ea8d3055aa9d5a9f1832ac82517bd8b42c78fac7ebcbebb0107116735c8cb6a1", size = 34208868, upload-time = "2026-07-31T17:01:11.818Z" },
]
[[package]]
name = "propcache"
version = "0.3.2"
@ -511,6 +594,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" },
]
[[package]]
name = "pyee"
version = "13.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
]
[[package]]
name = "pylint"
version = "3.3.8"
@ -599,6 +694,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"