Compare commits
2 Commits
3702e9fb94
...
6fd0c6e7f6
| Author | SHA1 | Date |
|---|---|---|
|
|
6fd0c6e7f6 | |
|
|
d0ae78e9de |
|
|
@ -55,6 +55,9 @@ __pycache__
|
|||
# Downloaded media
|
||||
/downloads
|
||||
|
||||
# Private yt-dlp wheel (built by build-ytdlp.sh and baked into the image)
|
||||
/vendor/yt_dlp-*.whl
|
||||
|
||||
# Runtime data
|
||||
/library
|
||||
/metube-config
|
||||
|
|
|
|||
24
DEPLOY.md
24
DEPLOY.md
|
|
@ -23,12 +23,32 @@ Ubuntu 22.04 x86_64, so images are cross-built with `docker buildx` for
|
|||
buildx builder with amd64 support (OrbStack / Docker Desktop provide one by
|
||||
default, e.g. via QEMU emulation).
|
||||
|
||||
## Private yt-dlp build
|
||||
|
||||
Until upstream yt-dlp fixes the extractor bug, the image bakes a wheel built
|
||||
from a local patched checkout instead of the pinned PyPI release:
|
||||
|
||||
- `./build-ytdlp.sh` builds the wheel from `~/Dev/github/yt-dlp` (override the
|
||||
path with `YTDLP_REPO=...`) into `vendor/yt_dlp-*.whl`.
|
||||
- The Dockerfile force-reinstalls that wheel over the version pinned in
|
||||
`uv.lock` when the wheel is present; otherwise it silently falls back to the
|
||||
pinned release.
|
||||
- The wheel is git-ignored, so it is not committed.
|
||||
- `publish.sh` runs `./build-ytdlp.sh` automatically and aborts if the local
|
||||
checkout is missing.
|
||||
|
||||
See **`YTDLP_PRIVATE_BUILD.md`** for full details, including a step-by-step
|
||||
guide to switching back to the official build once the upstream fix lands.
|
||||
|
||||
## Build & push (manual)
|
||||
|
||||
```sh
|
||||
# Optional: stage the private yt-dlp wheel (see "Private yt-dlp build")
|
||||
./build-ytdlp.sh
|
||||
|
||||
docker buildx build --platform linux/amd64 \
|
||||
--build-arg VERSION=1.22 \
|
||||
-t 192.168.2.212:3000/tigeren/metube:1.22 \
|
||||
--build-arg VERSION=1.25 \
|
||||
-t 192.168.2.212:3000/tigeren/metube:1.25 \
|
||||
--push .
|
||||
```
|
||||
|
||||
|
|
|
|||
11
Dockerfile
11
Dockerfile
|
|
@ -34,6 +34,17 @@ COPY pyproject.toml uv.lock ./
|
|||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
UV_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode --extra headless
|
||||
|
||||
# Private yt-dlp build: bake the locally-patched wheel (built by build-ytdlp.sh
|
||||
# from a local yt-dlp checkout) over the pinned PyPI release until the upstream
|
||||
# fix lands. The wheel is git-ignored; vendor/.gitkeep keeps the dir present so
|
||||
# this COPY always succeeds and the RUN is a no-op when no wheel is staged.
|
||||
COPY vendor ./vendor
|
||||
RUN if [ -n "$(ls /app/vendor/yt_dlp-*.whl 2>/dev/null || true)" ]; then \
|
||||
uv pip install --python /usr/local/bin/python --force-reinstall --no-deps /app/vendor/yt_dlp-*.whl; \
|
||||
else \
|
||||
echo "No private yt-dlp wheel in vendor/ - using pinned yt-dlp from uv.lock"; \
|
||||
fi
|
||||
|
||||
# Use sed to strip carriage-return characters from the entrypoint script (in case building on Windows)
|
||||
COPY docker-entrypoint.sh ./
|
||||
RUN sed -i 's/\r$//g' docker-entrypoint.sh && chmod +x docker-entrypoint.sh
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
# Private yt-dlp build
|
||||
|
||||
MeTube uses [yt-dlp](https://github.com/yt-dlp/yt-dlp) as its core downloader,
|
||||
pinned to the official PyPI release via `pyproject.toml` / `uv.lock`.
|
||||
|
||||
There is currently an extractor bug in the official release that is not yet
|
||||
fixed upstream. Until it is, the Docker image bakes a wheel built from a local,
|
||||
patched yt-dlp checkout (`~/Dev/github/yt-dlp`) instead of the pinned release.
|
||||
|
||||
This document explains how the override works, how to build/update the private
|
||||
wheel, and — most importantly — **how to switch back to the official build once
|
||||
the upstream fix lands**.
|
||||
|
||||
---
|
||||
|
||||
## How the override works
|
||||
|
||||
1. `./build-ytdlp.sh` builds a wheel from a local yt-dlp checkout and stages it
|
||||
as `vendor/yt_dlp-*.whl` (the versioned filename is required by `uv`).
|
||||
2. The `Dockerfile`, right after `uv sync`, force-reinstalls that wheel over the
|
||||
version pinned in `uv.lock`:
|
||||
|
||||
```dockerfile
|
||||
COPY vendor ./vendor
|
||||
RUN if [ -n "$(ls /app/vendor/yt_dlp-*.whl 2>/dev/null || true)" ]; then \
|
||||
uv pip install --python /usr/local/bin/python --force-reinstall --no-deps /app/vendor/yt_dlp-*.whl; \
|
||||
else \
|
||||
echo "No private yt-dlp wheel in vendor/ - using pinned yt-dlp from uv.lock"; \
|
||||
fi
|
||||
```
|
||||
|
||||
3. `publish.sh` runs `./build-ytdlp.sh` before every build, so published images
|
||||
always carry the patched build (and the publish aborts loudly if the local
|
||||
checkout is missing).
|
||||
4. The wheel is **git-ignored** (`/vendor/yt_dlp-*.whl`). `vendor/.gitkeep` and
|
||||
`vendor/.gitignore` are committed so the directory always exists for
|
||||
`COPY vendor ./vendor`.
|
||||
|
||||
## Building / updating the private wheel
|
||||
|
||||
```sh
|
||||
./build-ytdlp.sh # builds from ~/Dev/github/yt-dlp
|
||||
YTDLP_REPO=/path/to/yt-dlp ./build-ytdlp.sh # custom checkout path
|
||||
```
|
||||
|
||||
Rebuild whenever the local checkout changes, then rebuild the image:
|
||||
|
||||
```sh
|
||||
docker compose up -d --build --force-recreate
|
||||
```
|
||||
|
||||
The script fails with a clear error if the configured checkout is missing.
|
||||
|
||||
---
|
||||
|
||||
## Switching back to the official build (when upstream fixes it)
|
||||
|
||||
### 1. Remove the Dockerfile override
|
||||
|
||||
Delete the `COPY vendor` / `RUN uv pip install` block from `Dockerfile`
|
||||
(added in the "Private yt-dlp build" section, right after `uv sync`).
|
||||
|
||||
### 2. Remove the build step from `publish.sh`
|
||||
|
||||
Delete the block:
|
||||
|
||||
```sh
|
||||
# Bake the private yt-dlp build (see build-ytdlp.sh) into the image until the
|
||||
# upstream fix lands. Fails loudly if the local checkout is missing.
|
||||
./build-ytdlp.sh
|
||||
```
|
||||
|
||||
### 3. Update the pinned version
|
||||
|
||||
Bump `yt-dlp` to the fixed release and re-resolve the lockfile:
|
||||
|
||||
```sh
|
||||
uv add "yt-dlp[default,curl-cffi]@latest" # or pin the exact fixed version
|
||||
uv lock
|
||||
```
|
||||
|
||||
Commit the updated `pyproject.toml` and `uv.lock`.
|
||||
|
||||
### 4. Clean up the override artifacts
|
||||
|
||||
```sh
|
||||
rm build-ytdlp.sh # no longer needed
|
||||
rm -f vendor/yt_dlp-*.whl # delete any staged wheel
|
||||
```
|
||||
|
||||
You may also remove the `vendor/` directory (`.gitkeep`, `.gitignore`) and the
|
||||
`/vendor/yt_dlp-*.whl` entry from `.gitignore`.
|
||||
|
||||
### 5. Update the docs
|
||||
|
||||
Remove the "Private yt-dlp build" section from `DEPLOY.md` (and delete this file
|
||||
if you no longer want the guide around).
|
||||
|
||||
### 6. Rebuild and verify
|
||||
|
||||
```sh
|
||||
docker compose up -d --build --force-recreate
|
||||
```
|
||||
|
||||
Confirm the installed version is the official release:
|
||||
|
||||
```sh
|
||||
docker compose exec metube python3 -c "import yt_dlp; print(yt_dlp.version.__version__)"
|
||||
```
|
||||
|
||||
### 7. Publish
|
||||
|
||||
When you next publish, `./publish.sh` will no longer stage a private wheel and
|
||||
the image will use the official yt-dlp release.
|
||||
|
||||
---
|
||||
|
||||
## Verifying which build is running
|
||||
|
||||
The `/version` endpoint reports `METUBE_VERSION`; the yt-dlp version is logged
|
||||
by the app at startup. To check inside a running container:
|
||||
|
||||
```sh
|
||||
docker compose exec metube python3 -c \
|
||||
"import yt_dlp, yt_dlp.extractor.spankbang as s; import inspect; print(yt_dlp.version.__version__, 'patched:', 'spankbang' in inspect.getsource(s).lower())"
|
||||
```
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build the private yt-dlp wheel (locally-patched build) and stage it for the
|
||||
# Docker image.
|
||||
#
|
||||
# MeTube pins the official yt-dlp release via uv.lock. Until the upstream fix
|
||||
# lands, a wheel built from a local yt-dlp checkout is baked into the image
|
||||
# instead (see the Dockerfile). This script produces vendor/yt_dlp.whl.
|
||||
#
|
||||
# Usage:
|
||||
# ./build-ytdlp.sh # uses ~/Dev/github/yt-dlp
|
||||
# YTDLP_REPO=/path/to/yt-dlp ./build-ytdlp.sh
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
YTDLP_REPO="${YTDLP_REPO:-$HOME/Dev/github/yt-dlp}"
|
||||
OUT_DIR="$(pwd)/vendor"
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
if [[ ! -d "$YTDLP_REPO" ]]; then
|
||||
echo "Error: local yt-dlp checkout not found at $YTDLP_REPO" >&2
|
||||
echo "Set YTDLP_REPO to your patched checkout, or drop the override (see DEPLOY.md)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building yt-dlp wheel from $YTDLP_REPO ..."
|
||||
(cd "$YTDLP_REPO" && uv build --wheel -o "$OUT_DIR")
|
||||
|
||||
# Keep the original wheel filename (uv requires it to encode the version).
|
||||
echo "Wheel ready:"
|
||||
ls -1 "$OUT_DIR"/yt_dlp-*.whl
|
||||
|
|
@ -52,6 +52,10 @@ fi
|
|||
|
||||
echo "Publishing $IMAGE:$NEW_VERSION (current: $CURRENT_VERSION)"
|
||||
|
||||
# Bake the private yt-dlp build (see build-ytdlp.sh) into the image until the
|
||||
# upstream fix lands. Fails loudly if the local checkout is missing.
|
||||
./build-ytdlp.sh
|
||||
|
||||
# Record the new version as the current online version. BSD sed has no `\b`,
|
||||
# so require a non-digit (or end of line) after the version to avoid matching
|
||||
# e.g. 1.1 inside 1.10.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
<div class="card-body">
|
||||
<div class="card-title" [title]="row.value.title" [class.clickable]="!!downloadUrl" (click)="downloadUrl && play.emit()">{{ row.value.title }}</div>
|
||||
<div class="card-meta">
|
||||
<span *ngIf="row.value.website" class="badge site" [title]="row.value.website">{{ siteLabel(row.value.website) }}</span>
|
||||
<span class="badge folder">{{ folderLabel() }}</span>
|
||||
<span class="badge fmt">{{ qualityLabel(row.value.quality) }} · {{ formatLabel(row.value.format) }}</span>
|
||||
<span class="card-added" [title]="fullTime()">{{ relativeTime(row.value.timestamp) }}</span>
|
||||
|
|
|
|||
|
|
@ -114,6 +114,11 @@
|
|||
color: var(--fg)
|
||||
background: var(--surface)
|
||||
|
||||
&.site
|
||||
color: var(--accent-strong)
|
||||
background: var(--accent-tint)
|
||||
border-color: oklch(85% 0.05 145)
|
||||
|
||||
&.fmt
|
||||
font-family: var(--font-mono)
|
||||
font-weight: 500
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { faDownload, faExternalLinkAlt, faFolderPlus, faPlay, faRedoAlt, faTrash
|
|||
import { faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
|
||||
import { Download, DownloadsService } from './downloads.service';
|
||||
import { KeyedDownload } from './active-downloads.component';
|
||||
import { formatLabel, qualityLabel, relativeTime } from './labels';
|
||||
import { formatLabel, qualityLabel, relativeTime, siteLabel } from './labels';
|
||||
|
||||
@Component({
|
||||
selector: 'app-download-card',
|
||||
|
|
@ -37,6 +37,7 @@ export class DownloadCardComponent {
|
|||
qualityLabel = qualityLabel;
|
||||
formatLabel = formatLabel;
|
||||
relativeTime = relativeTime;
|
||||
siteLabel = siteLabel;
|
||||
|
||||
constructor(private downloads: DownloadsService) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
<div class="q-main">
|
||||
<div class="q-title" [title]="row.value.title" [class.clickable]="canPlay()" (click)="canPlay() && play.emit()">{{ row.value.title }}</div>
|
||||
<div class="q-meta">
|
||||
<span *ngIf="row.value.website" class="badge site" [title]="row.value.website">{{ siteLabel(row.value.website) }}</span>
|
||||
<span class="badge folder">{{ folderLabel() }}</span>
|
||||
<span class="badge fmt">{{ qualityLabel(row.value.quality) }} · {{ formatLabel(row.value.format) }}</span>
|
||||
<span class="q-added" [title]="fullTime()">{{ relativeTime(row.value.timestamp) }}</span>
|
||||
|
|
|
|||
|
|
@ -103,6 +103,11 @@
|
|||
color: var(--fg)
|
||||
background: var(--surface)
|
||||
|
||||
&.site
|
||||
color: var(--accent-strong)
|
||||
background: var(--accent-tint)
|
||||
border-color: oklch(85% 0.05 145)
|
||||
|
||||
&.fmt
|
||||
font-family: var(--font-mono)
|
||||
font-weight: 500
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { faDownload, faExternalLinkAlt, faFolderPlus, faPlay, faRedoAlt, faTrash
|
|||
import { faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
|
||||
import { Download, DownloadsService } from './downloads.service';
|
||||
import { KeyedDownload } from './active-downloads.component';
|
||||
import { formatLabel, qualityLabel, relativeTime } from './labels';
|
||||
import { formatLabel, qualityLabel, relativeTime, siteLabel } from './labels';
|
||||
|
||||
@Component({
|
||||
selector: 'app-download-row',
|
||||
|
|
@ -39,6 +39,7 @@ export class DownloadRowComponent {
|
|||
qualityLabel = qualityLabel;
|
||||
formatLabel = formatLabel;
|
||||
relativeTime = relativeTime;
|
||||
siteLabel = siteLabel;
|
||||
|
||||
constructor(private downloads: DownloadsService) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,28 @@ export function formatLabel(id: string): string {
|
|||
return FORMAT_LABELS[id] || id;
|
||||
}
|
||||
|
||||
const SITE_LABELS: { [id: string]: string } = {
|
||||
'youtube.com': 'YouTube',
|
||||
'm.youtube.com': 'YouTube',
|
||||
'youtu.be': 'YouTube',
|
||||
'vimeo.com': 'Vimeo',
|
||||
'dailymotion.com': 'Dailymotion',
|
||||
'twitch.tv': 'Twitch',
|
||||
'twitter.com': 'Twitter',
|
||||
'x.com': 'X',
|
||||
'facebook.com': 'Facebook',
|
||||
'instagram.com': 'Instagram',
|
||||
'tiktok.com': 'TikTok',
|
||||
'reddit.com': 'Reddit'
|
||||
};
|
||||
|
||||
/** Short display name for a source site given its domain (e.g. www.youtube.com -> YouTube). */
|
||||
export function siteLabel(website: string): string {
|
||||
if (!website) return '';
|
||||
const simplified = website.replace(/^www\./i, '').toLowerCase();
|
||||
return SITE_LABELS[simplified] || simplified.split('.')[0];
|
||||
}
|
||||
|
||||
/** Relative time for a DownloadInfo timestamp (nanoseconds). */
|
||||
export function relativeTime(timestampNs: number): string {
|
||||
if (!timestampNs) return '';
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@
|
|||
<div class="lib-main">
|
||||
<div class="lib-title clickable" [title]="file.name" (click)="play.emit(file)">{{ file.name }}</div>
|
||||
<div class="lib-meta">
|
||||
<span *ngIf="file.meta?.website" class="lib-badge site" [title]="file.meta?.website">{{ siteLabel(file.meta?.website) }}</span>
|
||||
<span class="num">{{ file.size | fileSize }}</span>
|
||||
<span>{{ fileTime(file) }}</span>
|
||||
</div>
|
||||
|
|
@ -76,10 +77,11 @@
|
|||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-title clickable" [title]="file.name" (click)="play.emit(file)">{{ file.name }}</div>
|
||||
<div class="card-meta">
|
||||
<span class="num">{{ file.size | fileSize }}</span>
|
||||
<span>{{ fileTime(file) }}</span>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span *ngIf="file.meta?.website" class="lib-badge site" [title]="file.meta?.website">{{ siteLabel(file.meta?.website) }}</span>
|
||||
<span class="num">{{ file.size | fileSize }}</span>
|
||||
<span>{{ fileTime(file) }}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<a *ngIf="file.meta?.url" class="icon-btn neutral" [href]="file.meta?.url" target="_blank" rel="noopener noreferrer" title="打开原始页面" aria-label="打开原始页面">
|
||||
<fa-icon [icon]="faExternalLinkAlt"></fa-icon>
|
||||
|
|
|
|||
|
|
@ -247,6 +247,22 @@
|
|||
color: var(--fg)
|
||||
font-family: var(--font-mono)
|
||||
|
||||
.lib-badge
|
||||
font-size: 10.5px
|
||||
font-weight: 550
|
||||
padding: 1px 7px
|
||||
border-radius: 20px
|
||||
border: 1px solid var(--border)
|
||||
background: var(--bg)
|
||||
color: var(--muted)
|
||||
letter-spacing: 0.02em
|
||||
white-space: nowrap
|
||||
|
||||
&.site
|
||||
color: var(--accent-strong)
|
||||
background: var(--accent-tint)
|
||||
border-color: oklch(85% 0.05 145)
|
||||
|
||||
.lib-actions
|
||||
display: flex
|
||||
gap: 2px
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
|
||||
import { faExternalLinkAlt, faList, faPlay, faRedoAlt, faThLarge, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { DownloadsService, LibraryFile } from './downloads.service';
|
||||
import { relativeTime } from './labels';
|
||||
import { relativeTime, siteLabel } from './labels';
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-browser',
|
||||
|
|
@ -35,6 +35,7 @@ export class LibraryBrowserComponent implements OnChanges {
|
|||
faTrashAlt = faTrashAlt;
|
||||
faRedoAlt = faRedoAlt;
|
||||
faExternalLinkAlt = faExternalLinkAlt;
|
||||
siteLabel = siteLabel;
|
||||
|
||||
constructor(public downloads: DownloadsService) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitkeep
|
||||
Loading…
Reference in New Issue