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 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,32 @@ def _on_progress(download_id: int, pct: float):
|
|||||||
db.close()
|
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"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<movie>
|
||||||
|
<title>{title}</title>
|
||||||
|
<plot>{plot}</plot>
|
||||||
|
<year>{year}</year>
|
||||||
|
<releasedate>{date}</releasedate>
|
||||||
|
<studio>{studio}</studio>
|
||||||
|
<thumb aspect="poster">{thumb_url}</thumb>
|
||||||
|
<thumb aspect="backdrop">{thumb_url}</thumb>
|
||||||
|
<uniqueid type="youtube" default="true">{video.youtube_video_id}</uniqueid>
|
||||||
|
</movie>""", encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _on_complete(download_id: int, file_path: Optional[str], resolution: Optional[str] = None):
|
def _on_complete(download_id: int, file_path: Optional[str], resolution: Optional[str] = None):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -72,6 +98,12 @@ def _on_complete(download_id: int, file_path: Optional[str], resolution: Optiona
|
|||||||
uv.downloaded = True
|
uv.downloaded = True
|
||||||
uv.downloaded_at = datetime.utcnow()
|
uv.downloaded_at = datetime.utcnow()
|
||||||
db.commit()
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -294,6 +326,7 @@ def _purge_expired_trash(db: Session):
|
|||||||
|
|
||||||
|
|
||||||
def _delete_download_record(db: Session, dl: "Download", user_id: int):
|
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()
|
video = db.query(Video).filter_by(id=dl.video_id).first()
|
||||||
if video:
|
if video:
|
||||||
fp = ytdlp.predicted_file_path(video.youtube_video_id)
|
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)
|
os.remove(fp)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
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()
|
uv = db.query(UserVideo).filter_by(user_id=user_id, video_id=dl.video_id).first()
|
||||||
if uv:
|
if uv:
|
||||||
uv.downloaded = False
|
uv.downloaded = False
|
||||||
@@ -309,6 +349,38 @@ def _delete_download_record(db: Session, dl: "Download", user_id: int):
|
|||||||
db.delete(dl)
|
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)
|
@router.delete("/all", status_code=204)
|
||||||
def delete_all_downloads(
|
def delete_all_downloads(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
@@ -167,3 +167,4 @@ export const fetchChannelPlaylists = (channelId) => api.post(`/playlists/channel
|
|||||||
export const getPlaylistVideos = (playlistId, offset = 0, limit = 60) =>
|
export const getPlaylistVideos = (playlistId, offset = 0, limit = 60) =>
|
||||||
api.get(`/playlists/${playlistId}/videos`, { params: { offset, limit } });
|
api.get(`/playlists/${playlistId}/videos`, { params: { offset, limit } });
|
||||||
export const indexPlaylist = (playlistId) => api.post(`/playlists/${playlistId}/index`);
|
export const indexPlaylist = (playlistId) => api.post(`/playlists/${playlistId}/index`);
|
||||||
|
export const generateNfoFiles = () => api.post("/downloads/nfo/generate");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
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";
|
import SortPicker from "../components/SortPicker";
|
||||||
|
|
||||||
const HISTORY_SORTS = [
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
@@ -104,6 +110,15 @@ export default function DownloadsPage() {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="font-display font-bold text-2xl text-zinc-100">Downloads</h1>
|
<h1 className="font-display font-bold text-2xl text-zinc-100">Downloads</h1>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => { setNfoResult(null); nfoMut.mutate(); }}
|
||||||
|
disabled={nfoMut.isPending}
|
||||||
|
title="Generate Jellyfin .nfo sidecar files for all completed downloads"
|
||||||
|
className="text-sm text-zinc-500 hover:text-zinc-300 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{nfoMut.isPending ? "Generating…" : nfoResult != null ? `Generated ${nfoResult} NFO` : "Generate NFO"}
|
||||||
|
</button>
|
||||||
{hasRemovable && (
|
{hasRemovable && (
|
||||||
confirmClear ? (
|
confirmClear ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -131,6 +146,7 @@ export default function DownloadsPage() {
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTasks.length > 0 && (
|
{activeTasks.length > 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user