From 001d2ddcf06360cfd605786c095764b9f20e5815 Mon Sep 17 00:00:00 2001 From: Mattias Thall Date: Wed, 27 May 2026 00:03:58 +0200 Subject: [PATCH] Add Jellyfin NFO sidecar generation for downloaded videos Writes a Kodi/Jellyfin-compatible .nfo XML file next to each .mp4 on download completion, deletes it when the download record is removed, and exposes POST /api/downloads/nfo/generate to backfill NFOs for existing downloads. Frontend adds a "Generate NFO" button in the Downloads header. Co-Authored-By: Claude Sonnet 4.6 --- backend/routers/downloads.py | 72 ++++++++++++++++++++++++++++++++ frontend/src/api/index.js | 1 + frontend/src/pages/Downloads.jsx | 18 +++++++- 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/backend/routers/downloads.py b/backend/routers/downloads.py index 5da82e4..3615428 100644 --- a/backend/routers/downloads.py +++ b/backend/routers/downloads.py @@ -53,6 +53,32 @@ def _on_progress(download_id: int, pct: float): db.close() +def _write_nfo(video: Video, channel: Optional[Channel]) -> None: + """Write a Jellyfin/Kodi-compatible .nfo sidecar next to the video file.""" + from pathlib import Path + try: + nfo_path = Path(settings.download_path) / f"{video.youtube_video_id}.nfo" + title = (video.title or "").replace("&", "&").replace("<", "<").replace(">", ">") + plot = (video.description or "").replace("&", "&").replace("<", "<").replace(">", ">") + studio = (channel.name if channel else "").replace("&", "&").replace("<", "<").replace(">", ">") + year = video.published_at.year if video.published_at else "" + date = video.published_at.strftime("%Y-%m-%d") if video.published_at else "" + thumb_url = f"https://i.ytimg.com/vi/{video.youtube_video_id}/maxresdefault.jpg" + nfo_path.write_text(f""" + + {title} + {plot} + {year} + {date} + {studio} + {thumb_url} + {thumb_url} + {video.youtube_video_id} +""", encoding="utf-8") + except Exception: + pass + + def _on_complete(download_id: int, file_path: Optional[str], resolution: Optional[str] = None): db = SessionLocal() try: @@ -72,6 +98,12 @@ def _on_complete(download_id: int, file_path: Optional[str], resolution: Optiona uv.downloaded = True uv.downloaded_at = datetime.utcnow() db.commit() + + # Write Jellyfin sidecar + video = db.query(Video).filter_by(id=dl.video_id).first() + if video: + channel = db.query(Channel).filter_by(id=video.channel_id).first() if video.channel_id else None + _write_nfo(video, channel) finally: db.close() @@ -294,6 +326,7 @@ def _purge_expired_trash(db: Session): def _delete_download_record(db: Session, dl: "Download", user_id: int): + from pathlib import Path video = db.query(Video).filter_by(id=dl.video_id).first() if video: fp = ytdlp.predicted_file_path(video.youtube_video_id) @@ -302,6 +335,13 @@ def _delete_download_record(db: Session, dl: "Download", user_id: int): os.remove(fp) except OSError: pass + # Remove NFO sidecar if present + nfo = Path(settings.download_path) / f"{video.youtube_video_id}.nfo" + if nfo.exists(): + try: + os.remove(nfo) + except OSError: + pass uv = db.query(UserVideo).filter_by(user_id=user_id, video_id=dl.video_id).first() if uv: uv.downloaded = False @@ -309,6 +349,38 @@ def _delete_download_record(db: Session, dl: "Download", user_id: int): db.delete(dl) +@router.post("/nfo/generate", status_code=200) +def generate_nfo_files( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Generate .nfo sidecar files for all completed downloads that have a file on disk.""" + from pathlib import Path + rows = db.execute( + text(""" + SELECT v.id, v.youtube_video_id, v.channel_id + FROM downloads d + JOIN videos v ON d.video_id = v.id + WHERE d.user_id = :uid AND d.status = 'complete' + """), + {"uid": current_user.id}, + ).mappings().all() + + written = 0 + for row in rows: + fp = Path(settings.download_path) / f"{row['youtube_video_id']}.mp4" + if not fp.exists(): + continue + video = db.query(Video).filter_by(id=row["id"]).first() + if not video: + continue + channel = db.query(Channel).filter_by(id=video.channel_id).first() if video.channel_id else None + _write_nfo(video, channel) + written += 1 + + return {"generated": written} + + @router.delete("/all", status_code=204) def delete_all_downloads( db: Session = Depends(get_db), diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 8109d44..329f88a 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -167,3 +167,4 @@ export const fetchChannelPlaylists = (channelId) => api.post(`/playlists/channel export const getPlaylistVideos = (playlistId, offset = 0, limit = 60) => api.get(`/playlists/${playlistId}/videos`, { params: { offset, limit } }); export const indexPlaylist = (playlistId) => api.post(`/playlists/${playlistId}/index`); +export const generateNfoFiles = () => api.post("/downloads/nfo/generate"); diff --git a/frontend/src/pages/Downloads.jsx b/frontend/src/pages/Downloads.jsx index 084eef9..3aad53f 100644 --- a/frontend/src/pages/Downloads.jsx +++ b/frontend/src/pages/Downloads.jsx @@ -1,7 +1,7 @@ import { useState, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { getDownloads, deleteDownload, deleteAllDownloads, restoreDownload, getActiveTasks } from "../api"; +import { getDownloads, deleteDownload, deleteAllDownloads, restoreDownload, getActiveTasks, generateNfoFiles } from "../api"; import SortPicker from "../components/SortPicker"; const HISTORY_SORTS = [ @@ -80,6 +80,12 @@ export default function DownloadsPage() { }, }); + const [nfoResult, setNfoResult] = useState(null); + const nfoMut = useMutation({ + mutationFn: generateNfoFiles, + onSuccess: (r) => setNfoResult(r.data.generated), + }); + if (isLoading) { return (
@@ -104,6 +110,15 @@ export default function DownloadsPage() {

Downloads

+
+ {hasRemovable && ( confirmClear ? (
@@ -131,6 +146,7 @@ export default function DownloadsPage() { ) )} +
{activeTasks.length > 0 && (