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:
parent
17438665be
commit
e30ae019e8
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 .
|
||||
```
|
||||
|
||||
|
|
|
|||
17
app/main.py
17
app/main.py
|
|
@ -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):
|
||||
|
|
|
|||
20
app/ytdl.py
20
app/ytdl.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue