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:
2026-05-27 00:03:58 +02:00
parent 65bc199366
commit 001d2ddcf0
3 changed files with 90 additions and 1 deletions

View File

@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
plot = (video.description or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
studio = (channel.name if channel else "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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):
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),