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