Compare commits

...

2 Commits

Author SHA1 Message Date
tigerenwork 6fd0c6e7f6 feat: 恢复下载与媒体库来源网站标签 2026-08-15 02:41:12 +08:00
tigerenwork d0ae78e9de feat: 镜像构建时打包本地修补的 yt-dlp wheel 2026-08-15 02:03:59 +08:00
18 changed files with 261 additions and 9 deletions

3
.gitignore vendored
View File

@ -55,6 +55,9 @@ __pycache__
# Downloaded media # Downloaded media
/downloads /downloads
# Private yt-dlp wheel (built by build-ytdlp.sh and baked into the image)
/vendor/yt_dlp-*.whl
# Runtime data # Runtime data
/library /library
/metube-config /metube-config

View File

@ -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 buildx builder with amd64 support (OrbStack / Docker Desktop provide one by
default, e.g. via QEMU emulation). 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) ## Build & push (manual)
```sh ```sh
# Optional: stage the private yt-dlp wheel (see "Private yt-dlp build")
./build-ytdlp.sh
docker buildx build --platform linux/amd64 \ docker buildx build --platform linux/amd64 \
--build-arg VERSION=1.22 \ --build-arg VERSION=1.25 \
-t 192.168.2.212:3000/tigeren/metube:1.22 \ -t 192.168.2.212:3000/tigeren/metube:1.25 \
--push . --push .
``` ```

View File

@ -34,6 +34,17 @@ COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
UV_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode --extra headless 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) # Use sed to strip carriage-return characters from the entrypoint script (in case building on Windows)
COPY docker-entrypoint.sh ./ COPY docker-entrypoint.sh ./
RUN sed -i 's/\r$//g' docker-entrypoint.sh && chmod +x docker-entrypoint.sh RUN sed -i 's/\r$//g' docker-entrypoint.sh && chmod +x docker-entrypoint.sh

126
YTDLP_PRIVATE_BUILD.md Normal file
View File

@ -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())"
```

31
build-ytdlp.sh Executable file
View File

@ -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

View File

@ -52,6 +52,10 @@ fi
echo "Publishing $IMAGE:$NEW_VERSION (current: $CURRENT_VERSION)" 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`, # 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 # so require a non-digit (or end of line) after the version to avoid matching
# e.g. 1.1 inside 1.10. # e.g. 1.1 inside 1.10.

View File

@ -10,6 +10,7 @@
<div class="card-body"> <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-title" [title]="row.value.title" [class.clickable]="!!downloadUrl" (click)="downloadUrl && play.emit()">{{ row.value.title }}</div>
<div class="card-meta"> <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 folder">{{ folderLabel() }}</span>
<span class="badge fmt">{{ qualityLabel(row.value.quality) }} · {{ formatLabel(row.value.format) }}</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> <span class="card-added" [title]="fullTime()">{{ relativeTime(row.value.timestamp) }}</span>

View File

@ -114,6 +114,11 @@
color: var(--fg) color: var(--fg)
background: var(--surface) background: var(--surface)
&.site
color: var(--accent-strong)
background: var(--accent-tint)
border-color: oklch(85% 0.05 145)
&.fmt &.fmt
font-family: var(--font-mono) font-family: var(--font-mono)
font-weight: 500 font-weight: 500

View File

@ -3,7 +3,7 @@ import { faDownload, faExternalLinkAlt, faFolderPlus, faPlay, faRedoAlt, faTrash
import { faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons'; import { faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
import { Download, DownloadsService } from './downloads.service'; import { Download, DownloadsService } from './downloads.service';
import { KeyedDownload } from './active-downloads.component'; import { KeyedDownload } from './active-downloads.component';
import { formatLabel, qualityLabel, relativeTime } from './labels'; import { formatLabel, qualityLabel, relativeTime, siteLabel } from './labels';
@Component({ @Component({
selector: 'app-download-card', selector: 'app-download-card',
@ -37,6 +37,7 @@ export class DownloadCardComponent {
qualityLabel = qualityLabel; qualityLabel = qualityLabel;
formatLabel = formatLabel; formatLabel = formatLabel;
relativeTime = relativeTime; relativeTime = relativeTime;
siteLabel = siteLabel;
constructor(private downloads: DownloadsService) {} constructor(private downloads: DownloadsService) {}

View File

@ -10,6 +10,7 @@
<div class="q-main"> <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-title" [title]="row.value.title" [class.clickable]="canPlay()" (click)="canPlay() && play.emit()">{{ row.value.title }}</div>
<div class="q-meta"> <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 folder">{{ folderLabel() }}</span>
<span class="badge fmt">{{ qualityLabel(row.value.quality) }} · {{ formatLabel(row.value.format) }}</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> <span class="q-added" [title]="fullTime()">{{ relativeTime(row.value.timestamp) }}</span>

View File

@ -103,6 +103,11 @@
color: var(--fg) color: var(--fg)
background: var(--surface) background: var(--surface)
&.site
color: var(--accent-strong)
background: var(--accent-tint)
border-color: oklch(85% 0.05 145)
&.fmt &.fmt
font-family: var(--font-mono) font-family: var(--font-mono)
font-weight: 500 font-weight: 500

View File

@ -3,7 +3,7 @@ import { faDownload, faExternalLinkAlt, faFolderPlus, faPlay, faRedoAlt, faTrash
import { faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons'; import { faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
import { Download, DownloadsService } from './downloads.service'; import { Download, DownloadsService } from './downloads.service';
import { KeyedDownload } from './active-downloads.component'; import { KeyedDownload } from './active-downloads.component';
import { formatLabel, qualityLabel, relativeTime } from './labels'; import { formatLabel, qualityLabel, relativeTime, siteLabel } from './labels';
@Component({ @Component({
selector: 'app-download-row', selector: 'app-download-row',
@ -39,6 +39,7 @@ export class DownloadRowComponent {
qualityLabel = qualityLabel; qualityLabel = qualityLabel;
formatLabel = formatLabel; formatLabel = formatLabel;
relativeTime = relativeTime; relativeTime = relativeTime;
siteLabel = siteLabel;
constructor(private downloads: DownloadsService) {} constructor(private downloads: DownloadsService) {}

View File

@ -32,6 +32,28 @@ export function formatLabel(id: string): string {
return FORMAT_LABELS[id] || id; 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). */ /** Relative time for a DownloadInfo timestamp (nanoseconds). */
export function relativeTime(timestampNs: number): string { export function relativeTime(timestampNs: number): string {
if (!timestampNs) return ''; if (!timestampNs) return '';

View File

@ -49,6 +49,7 @@
<div class="lib-main"> <div class="lib-main">
<div class="lib-title clickable" [title]="file.name" (click)="play.emit(file)">{{ file.name }}</div> <div class="lib-title clickable" [title]="file.name" (click)="play.emit(file)">{{ file.name }}</div>
<div class="lib-meta"> <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 class="num">{{ file.size | fileSize }}</span>
<span>{{ fileTime(file) }}</span> <span>{{ fileTime(file) }}</span>
</div> </div>
@ -76,10 +77,11 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="card-title clickable" [title]="file.name" (click)="play.emit(file)">{{ file.name }}</div> <div class="card-title clickable" [title]="file.name" (click)="play.emit(file)">{{ file.name }}</div>
<div class="card-meta"> <div class="card-meta">
<span class="num">{{ file.size | fileSize }}</span> <span *ngIf="file.meta?.website" class="lib-badge site" [title]="file.meta?.website">{{ siteLabel(file.meta?.website) }}</span>
<span>{{ fileTime(file) }}</span> <span class="num">{{ file.size | fileSize }}</span>
</div> <span>{{ fileTime(file) }}</span>
</div>
<div class="card-actions"> <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="打开原始页面"> <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> <fa-icon [icon]="faExternalLinkAlt"></fa-icon>

View File

@ -247,6 +247,22 @@
color: var(--fg) color: var(--fg)
font-family: var(--font-mono) 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 .lib-actions
display: flex display: flex
gap: 2px gap: 2px

View File

@ -1,7 +1,7 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { faExternalLinkAlt, faList, faPlay, faRedoAlt, faThLarge, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { faExternalLinkAlt, faList, faPlay, faRedoAlt, faThLarge, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { DownloadsService, LibraryFile } from './downloads.service'; import { DownloadsService, LibraryFile } from './downloads.service';
import { relativeTime } from './labels'; import { relativeTime, siteLabel } from './labels';
@Component({ @Component({
selector: 'app-library-browser', selector: 'app-library-browser',
@ -35,6 +35,7 @@ export class LibraryBrowserComponent implements OnChanges {
faTrashAlt = faTrashAlt; faTrashAlt = faTrashAlt;
faRedoAlt = faRedoAlt; faRedoAlt = faRedoAlt;
faExternalLinkAlt = faExternalLinkAlt; faExternalLinkAlt = faExternalLinkAlt;
siteLabel = siteLabel;
constructor(public downloads: DownloadsService) {} constructor(public downloads: DownloadsService) {}

2
vendor/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitkeep

0
vendor/.gitkeep vendored Normal file
View File