/* This file is part of jellything (https://codeberg.org/metamuffin/jellything) which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin */ use crate::plugins::{ImportContext, ImportPlugin, PluginInfo}; use anyhow::{Context, Result, bail}; use jellycache::{HashKey, cache_store}; use jellycommon::{Asset, NodeID, NodeKind, PictureSlot, Visibility}; use jellyremuxer::matroska::{AttachedFile, Segment}; use log::info; use regex::Regex; use std::{ fs::{File, read_to_string}, io::Read, path::Path, sync::LazyLock, }; pub struct ImageFiles; impl ImportPlugin for ImageFiles { fn info(&self) -> PluginInfo { PluginInfo { name: "image-files", handle_file: true, ..Default::default() } } fn file(&self, ct: &ImportContext, parent: NodeID, path: &Path) -> Result<()> { let filename = path.file_name().unwrap().to_string_lossy(); let slot = match filename.as_ref() { "poster.jpeg" | "poster.webp" | "poster.png" => PictureSlot::Cover, "backdrop.jpeg" | "backdrop.webp" | "backdrop.png" => PictureSlot::Backdrop, _ => return Ok(()), }; info!("import {slot:?} at {path:?}"); let asset = Asset(cache_store( format!("media/literal/{}-poster.image", HashKey(path)), || { let mut data = Vec::new(); File::open(path)?.read_to_end(&mut data)?; Ok(data) }, )?); ct.db.update_node_init(parent, |node| { node.pictures.insert(PictureSlot::Cover, asset); Ok(()) })?; Ok(()) } } pub fn is_cover(a: &&AttachedFile) -> bool { a.name.starts_with("cover") && a.media_type.starts_with("image/") } pub struct ImageAttachments; impl ImportPlugin for ImageAttachments { fn info(&self) -> PluginInfo { PluginInfo { name: "image-attachments", handle_media: true, ..Default::default() } } fn media(&self, ct: &ImportContext, node: NodeID, _path: &Path, seg: &Segment) -> Result<()> { let Some(cover) = seg .attachments .iter() .flat_map(|a| &a.files) .find(is_cover) .map(|att| Asset(att.data.clone().try_into().unwrap())) else { return Ok(()); }; ct.db.update_node_init(node, |node| { node.pictures.insert(PictureSlot::Cover, cover); Ok(()) })?; Ok(()) } } pub struct General; impl ImportPlugin for General { fn info(&self) -> PluginInfo { PluginInfo { name: "general", handle_instruction: true, ..Default::default() } } fn instruction(&self, ct: &ImportContext, node: NodeID, line: &str) -> Result<()> { if line == "hidden" { ct.db.update_node_init(node, |node| { node.visibility = node.visibility.min(Visibility::Hidden); Ok(()) })?; } if line == "reduced" { ct.db.update_node_init(node, |node| { node.visibility = node.visibility.min(Visibility::Reduced); Ok(()) })?; } if let Some(kind) = line.strip_prefix("kind-").or(line.strip_prefix("kind=")) { let kind = match kind { "movie" => NodeKind::Movie, "video" => NodeKind::Video, "music" => NodeKind::Music, "short_form_video" => NodeKind::ShortFormVideo, "collection" => NodeKind::Collection, "channel" => NodeKind::Channel, "show" => NodeKind::Show, "series" => NodeKind::Series, "season" => NodeKind::Season, "episode" => NodeKind::Episode, _ => bail!("unknown node kind"), }; ct.db.update_node_init(node, |node| { node.kind = kind; Ok(()) })?; } Ok(()) } } pub struct Children; impl ImportPlugin for Children { fn info(&self) -> PluginInfo { PluginInfo { name: "children", handle_file: true, ..Default::default() } } fn file(&self, ct: &ImportContext, parent: NodeID, path: &Path) -> Result<()> { let filename = path.file_name().unwrap().to_string_lossy(); if filename.as_ref() == "children" { info!("import children at {path:?}"); for line in read_to_string(path)?.lines() { let line = line.trim(); if line.starts_with("#") || line.is_empty() { continue; } ct.db.update_node_init(NodeID::from_slug(line), |n| { n.slug = line.to_owned(); n.parents.insert(parent); Ok(()) })?; } } Ok(()) } } static RE_EPISODE_FILENAME: LazyLock = LazyLock::new(|| Regex::new(r#"([sS](?\d+))?([eE](?\d+))( (.+))?"#).unwrap()); pub struct EpisodeIndex; impl ImportPlugin for EpisodeIndex { fn info(&self) -> PluginInfo { PluginInfo { name: "episode-info", handle_media: true, ..Default::default() } } fn media(&self, ct: &ImportContext, node: NodeID, path: &Path, _seg: &Segment) -> Result<()> { let filename = path.file_name().unwrap().to_string_lossy(); if let Some(cap) = RE_EPISODE_FILENAME.captures(&filename) { if let Some(episode) = cap.name("episode").map(|m| m.as_str()) { let season = cap.name("season").map(|m| m.as_str()); let episode = episode.parse::().context("parse episode num")?; let season = season .unwrap_or("1") .parse::() .context("parse season num")?; ct.db.update_node_init(node, |node| { node.index = Some(episode); Ok(()) })?; } } Ok(()) } }