diff --git a/AGENTS.md b/AGENTS.md index d67759b..70952e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `..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. diff --git a/DEPLOY.md b/DEPLOY.md index 0596e7b..4f62867 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -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 . ``` diff --git a/app/main.py b/app/main.py index df8735b..5900976 100644 --- a/app/main.py +++ b/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): diff --git a/app/ytdl.py b/app/ytdl.py index f90ae3a..1cf252b 100644 --- a/app/ytdl.py +++ b/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 diff --git a/ui/src/app/downloads.service.ts b/ui/src/app/downloads.service.ts index 6e269ca..d763da4 100644 --- a/ui/src/app/downloads.service.ts +++ b/ui/src/app/downloads.service.ts @@ -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 { diff --git a/ui/src/app/library-browser.component.html b/ui/src/app/library-browser.component.html index 443947f..45e9eca 100644 --- a/ui/src/app/library-browser.component.html +++ b/ui/src/app/library-browser.component.html @@ -52,6 +52,9 @@
+ + + @@ -74,6 +77,9 @@ {{ fileTime(file) }}
+ + + diff --git a/ui/src/app/library-browser.component.ts b/ui/src/app/library-browser.component.ts index 1bb9e08..631cbd5 100644 --- a/ui/src/app/library-browser.component.ts +++ b/ui/src/app/library-browser.component.ts @@ -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) {}