/* 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) 2026 metamuffin */ use crate::plugins::{ImportPlugin, PluginContext, PluginInfo}; use anyhow::{Context, Result, bail}; use jellycache::HashKey; use jellycommon::{jellyobject::inspect::Inspector, *}; use jellydb::table::RowNum; use jellyremuxer::matroska::{AttachedFile, Segment}; use log::info; use regex::Regex; use std::{fs::File, 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: &PluginContext, row: RowNum, path: &Path) -> Result<()> { let filename = path.file_name().unwrap().to_string_lossy(); let slot = match filename.as_ref() { "poster.jpeg" | "poster.webp" | "poster.png" => PICT_COVER, "backdrop.jpeg" | "backdrop.webp" | "backdrop.png" => PICT_BACKDROP, _ => return Ok(()), }; info!("import {:?} at {path:?}", Inspector(&TAGREG, slot)); let asset = ct.dba.cache.store( format!( "media/literal/{}-{}.image", HashKey(path), TAGREG.name(slot.0) ), || { let mut data = Vec::new(); File::open(path)?.read_to_end(&mut data)?; Ok(data) }, )?; ct.dba.db.write_transaction(&mut |txn| { let mut node = ct.dba.nodes.get(txn, row)?.unwrap(); node = node .as_object() .update(NO_PICTURES, |picts| picts.insert(slot, &asset)); ct.dba.nodes.update(txn, row, node)?; 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: &PluginContext, row: RowNum, _path: &Path, seg: &Segment) -> Result<()> { let Some(cover) = seg .attachments .iter() .flat_map(|a| &a.files) .find(is_cover) .map(|att| String::from_utf8_lossy(&att.data)) else { return Ok(()); }; ct.dba.update_node(row, |node| { node.as_object() .update(NO_PICTURES, |picts| picts.insert(PICT_COVER, &cover)) })?; Ok(()) } } pub struct General; impl ImportPlugin for General { fn info(&self) -> PluginInfo { PluginInfo { name: "general", handle_instruction: true, ..Default::default() } } fn instruction(&self, ct: &PluginContext, node: RowNum, line: &str) -> Result<()> { if line == "hidden" { ct.dba.update_node(node, |node| { node.as_object().insert(NO_VISIBILITY, VISI_HIDDEN) })?; } if line == "reduced" { ct.dba.update_node(node, |node| { node.as_object().insert(NO_VISIBILITY, VISI_REDUCED) })?; } if let Some(kind) = line.strip_prefix("kind-").or(line.strip_prefix("kind=")) { let kind = match kind { "movie" => KIND_MOVIE, "video" => KIND_VIDEO, "music" => KIND_MUSIC, "short_form_video" => KIND_SHORTFORMVIDEO, "collection" => KIND_COLLECTION, "channel" => KIND_CHANNEL, "show" => KIND_SHOW, "series" => KIND_SERIES, "season" => KIND_SEASON, "episode" => KIND_EPISODE, _ => bail!("unknown node kind"), }; ct.dba .update_node(node, |node| node.as_object().insert(NO_KIND, kind))?; } if let Some(title) = line.strip_prefix("title=") { ct.dba .update_node(node, |node| node.as_object().insert(NO_TITLE, title))?; } if let Some(index) = line.strip_prefix("index=") { let index = index.parse().context("parse index")?; ct.dba .update_node(node, |node| node.as_object().insert(NO_INDEX, index))?; } Ok(()) } } pub struct Children; impl ImportPlugin for Children { fn info(&self) -> PluginInfo { PluginInfo { name: "children", handle_file: true, ..Default::default() } } fn file(&self, ct: &PluginContext, parent: RowNum, path: &Path) -> Result<()> { // TODO use idents // 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(()) } } 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: &PluginContext, node: RowNum, 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.dba.update_node(node, |mut node| { node = node.as_object().insert(NO_SEASON_INDEX, season); node = node.as_object().insert(NO_INDEX, episode); node = node.as_object().insert(NO_KIND, KIND_EPISODE); node })?; } } Ok(()) } }