Preserve source metadata for library-organized files

Organizing a completed download deleted its done entry, losing the
original video URL. Now a hidden sidecar '.<filename>.metube.json' is
written next to the video at organize time with url/title/uploader/
website/downloaded_at. /library/files attaches it as 'meta', library
delete removes it, and the library browser shows an external-link icon
opening the original page for files that have it. No backfill for
previously organized files.

Also records the published 1.18 image in DEPLOY.md.
This commit is contained in:
tigerenwork 2026-08-13 01:32:13 +08:00
parent 17438665be
commit e30ae019e8
7 changed files with 55 additions and 5 deletions

View File

@ -17,3 +17,4 @@
- `LIBRARY_DIR` (empty = feature off) points to a separate media library directory, mounted as its own volume. See `LIBRARY_ORGANIZE_DESIGN.md` for the full design. - `LIBRARY_DIR` (empty = feature off) points to a separate media library directory, mounted as its own volume. See `LIBRARY_ORGANIZE_DESIGN.md` for the full design.
- Library state is read from disk on demand — no DB/index. `/library/folders` is cached ~60s in memory; organize/delete invalidate it. - Library state is read from disk on demand — no DB/index. `/library/folders` is cached ~60s in memory; organize/delete invalidate it.
- Organizing a completed download moves its file into the library and removes the done entry (a `cleared` socket event updates the UI). - Organizing a completed download moves its file into the library and removes the done entry (a `cleared` socket event updates the UI).
- At organize time a hidden sidecar `.<filename>.metube.json` is written next to the video with source metadata (`url`, `title`, `uploader`, `website`, `downloaded_at`) — see `library_meta_path()` in `app/ytdl.py`. `/library/files` attaches it as `meta`; library delete removes it. Files organized before this existed have no sidecar.

View File

@ -27,8 +27,8 @@ default, e.g. via QEMU emulation).
```sh ```sh
docker buildx build --platform linux/amd64 \ docker buildx build --platform linux/amd64 \
--build-arg VERSION=1.17 \ --build-arg VERSION=1.19 \
-t 192.168.2.212:3000/tigeren/metube:1.17 \ -t 192.168.2.212:3000/tigeren/metube:1.19 \
--push . --push .
``` ```

View File

@ -21,7 +21,7 @@ import aiohttp
from urllib.parse import urlparse from urllib.parse import urlparse
from watchfiles import DefaultFilter, Change, awatch from watchfiles import DefaultFilter, Change, awatch
from ytdl import DownloadQueueNotifier, DownloadQueue from ytdl import DownloadQueueNotifier, DownloadQueue, library_meta_path
from yt_dlp.version import __version__ as yt_dlp_version from yt_dlp.version import __version__ as yt_dlp_version
log = logging.getLogger('main') log = logging.getLogger('main')
@ -434,7 +434,15 @@ async def library_files(request):
if os.path.splitext(e.name)[1].lower() not in VIDEO_EXTENSIONS: if os.path.splitext(e.name)[1].lower() not in VIDEO_EXTENSIONS:
continue continue
st = e.stat() st = e.stat()
entries.append({'name': e.name, 'size': st.st_size, 'mtime': st.st_mtime}) entry = {'name': e.name, 'size': st.st_size, 'mtime': st.st_mtime}
meta_file = library_meta_path(e.path)
if os.path.isfile(meta_file):
try:
with open(meta_file, encoding='utf-8') as f:
entry['meta'] = json.load(f)
except (OSError, ValueError) as ex:
log.debug(f'Ignoring unreadable library metadata sidecar {meta_file}: {ex!r}')
entries.append(entry)
entries.sort(key=lambda f: f['name'].lower()) entries.sort(key=lambda f: f['name'].lower())
return entries return entries
@ -476,6 +484,11 @@ async def library_delete(request):
try: try:
os.remove(path) os.remove(path)
deleted += 1 deleted += 1
# Drop the source-metadata sidecar too, if one was written at organize time
try:
os.remove(library_meta_path(path))
except OSError:
pass
# Clean up emptied parent directories, never the library root itself # Clean up emptied parent directories, never the library root itself
parent = os.path.dirname(path) parent = os.path.dirname(path)
while parent != root and parent.startswith(root + os.sep): while parent != root and parent.startswith(root + os.sep):

View File

@ -1,6 +1,7 @@
import os import os
import shutil import shutil
import yt_dlp import yt_dlp
import json
from collections import OrderedDict from collections import OrderedDict
from mark_watched import mark_watched from mark_watched import mark_watched
import shelve import shelve
@ -19,6 +20,13 @@ from datetime import datetime
log = logging.getLogger('ytdl') log = logging.getLogger('ytdl')
def library_meta_path(video_path):
"""Hidden sidecar JSON carrying a library file's source metadata.
'Foo.mp4' -> '.Foo.mp4.metube.json' in the same directory."""
return os.path.join(os.path.dirname(video_path), '.' + os.path.basename(video_path) + '.metube.json')
class DownloadQueueNotifier: class DownloadQueueNotifier:
async def added(self, dl): async def added(self, dl):
raise NotImplementedError raise NotImplementedError
@ -1004,6 +1012,18 @@ class DownloadQueue:
errors.append(f'{dl.info.title}: {e}') errors.append(f'{dl.info.title}: {e}')
continue continue
log.info(f"Organized download {id} into library: {src} -> {dst}") log.info(f"Organized download {id} into library: {src} -> {dst}")
try:
meta = {
'url': dl.info.url,
'title': dl.info.title,
'uploader': (dl.info.entry or {}).get('uploader'),
'website': getattr(dl.info, 'website', None),
'downloaded_at': (getattr(dl.info, 'timestamp', 0) or 0) / 1e9 or None,
}
with open(library_meta_path(dst), 'w', encoding='utf-8') as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
except OSError as e:
log.warning(f'Failed to write library metadata sidecar for {dst}: {e!r}')
self.done.delete(id) self.done.delete(id)
await self.notifier.cleared(id) await self.notifier.cleared(id)
moved += 1 moved += 1

View File

@ -14,10 +14,19 @@ export interface LibraryFolder {
count: number; count: number;
} }
export interface LibraryFileMeta {
url?: string;
title?: string;
uploader?: string;
website?: string;
downloaded_at?: number;
}
export interface LibraryFile { export interface LibraryFile {
name: string; name: string;
size: number; size: number;
mtime: number; mtime: number;
meta?: LibraryFileMeta;
} }
export interface Download { export interface Download {

View File

@ -52,6 +52,9 @@
</div> </div>
</div> </div>
<div class="lib-actions"> <div class="lib-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>
</a>
<button type="button" class="icon-btn neutral" (click)="play.emit(file)" title="在线播放" aria-label="在线播放"> <button type="button" class="icon-btn neutral" (click)="play.emit(file)" title="在线播放" aria-label="在线播放">
<fa-icon [icon]="faPlay"></fa-icon> <fa-icon [icon]="faPlay"></fa-icon>
</button> </button>
@ -74,6 +77,9 @@
<span>{{ fileTime(file) }}</span> <span>{{ fileTime(file) }}</span>
</div> </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="打开原始页面">
<fa-icon [icon]="faExternalLinkAlt"></fa-icon>
</a>
<button type="button" class="icon-btn neutral" (click)="play.emit(file)" title="在线播放" aria-label="在线播放"> <button type="button" class="icon-btn neutral" (click)="play.emit(file)" title="在线播放" aria-label="在线播放">
<fa-icon [icon]="faPlay"></fa-icon> <fa-icon [icon]="faPlay"></fa-icon>
</button> </button>

View File

@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Component, EventEmitter, Input, Output } from '@angular/core';
import { 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 } from './labels';
@ -28,6 +28,7 @@ export class LibraryBrowserComponent {
faPlay = faPlay; faPlay = faPlay;
faTrashAlt = faTrashAlt; faTrashAlt = faTrashAlt;
faRedoAlt = faRedoAlt; faRedoAlt = faRedoAlt;
faExternalLinkAlt = faExternalLinkAlt;
constructor(public downloads: DownloadsService) {} constructor(public downloads: DownloadsService) {}