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 && (