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 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).
- 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
docker buildx build --platform linux/amd64 \
--build-arg VERSION=1.17 \
-t 192.168.2.212:3000/tigeren/metube:1.17 \
--build-arg VERSION=1.19 \
-t 192.168.2.212:3000/tigeren/metube:1.19 \
--push .
```

View File

@ -21,7 +21,7 @@ import aiohttp
from urllib.parse import urlparse
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
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:
continue
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())
return entries
@ -476,6 +484,11 @@ async def library_delete(request):
try:
os.remove(path)
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
parent = os.path.dirname(path)
while parent != root and parent.startswith(root + os.sep):

View File

@ -1,6 +1,7 @@
import os
import shutil
import yt_dlp
import json
from collections import OrderedDict
from mark_watched import mark_watched
import shelve
@ -19,6 +20,13 @@ from datetime import datetime
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:
async def added(self, dl):
raise NotImplementedError
@ -1004,6 +1012,18 @@ class DownloadQueue:
errors.append(f'{dl.info.title}: {e}')
continue
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)
await self.notifier.cleared(id)
moved += 1

View File

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

View File

@ -52,6 +52,9 @@
</div>
</div>
<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="在线播放">
<fa-icon [icon]="faPlay"></fa-icon>
</button>
@ -74,6 +77,9 @@
<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>
</a>
<button type="button" class="icon-btn neutral" (click)="play.emit(file)" title="在线播放" aria-label="在线播放">
<fa-icon [icon]="faPlay"></fa-icon>
</button>

View File

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