/* 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}, source_rank::ObjectImportSourceExt, }; use anyhow::{Context, Result, bail}; use jellycache::HashKey; use jellycommon::*; use jellydb::RowNum; 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", tag: MSOURCE_EXPLICIT, 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(()), }; let asset = ct.ic.cache.store( format!("media/literal/{}-{slot}.image", HashKey(path)), || { info!("import {slot} at {path:?}"); let mut data = Vec::new(); File::open(path)?.read_to_end(&mut data)?; Ok(data) }, )?; ct.ic.db.transaction(&mut |txn| { let mut node = txn.get(row)?.unwrap(); node = node .as_object() .update(NO_PICTURES, |picts| picts.insert_s(ct.is, slot, &asset)); txn.update(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", tag: MSOURCE_IMAGE_ATT, 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.ic.update_node(row, |node| { node.as_object().update(NO_PICTURES, |picts| { picts.insert_s(ct.is, PICT_COVER, &cover) }) })?; Ok(()) } } pub struct General; impl ImportPlugin for General { fn info(&self) -> PluginInfo { PluginInfo { name: "general", tag: MSOURCE_EXPLICIT, handle_instruction: true, ..Default::default() } } fn instruction(&self, ct: &PluginContext, node: RowNum, line: &str) -> Result<()> { if line == "hidden" { ct.ic.update_node(node, |node| { node.as_object().insert_s(ct.is, NO_VISIBILITY, VISI_HIDDEN) })?; } if line == "reduced" { ct.ic.update_node(node, |node| { node.as_object() .insert_s(ct.is, 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.ic .update_node(node, |node| node.as_object().insert_s(ct.is, NO_KIND, kind))?; } if let Some(title) = line.strip_prefix("title=") { ct.ic.update_node(node, |node| { node.as_object().insert_s(ct.is, NO_TITLE, title) })?; } if let Some(index) = line.strip_prefix("index=") { let index = index.parse().context("parse index")?; ct.ic.update_node(node, |node| { node.as_object().insert_s(ct.is, NO_INDEX, index) })?; } Ok(()) } } pub struct Children; impl ImportPlugin for Children { fn info(&self) -> PluginInfo { PluginInfo { name: "children", tag: MSOURCE_EXPLICIT, handle_file: true, ..Default::default() } } fn file(&self, ct: &PluginContext, parent: RowNum, 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.ic .update_node_slug(line, |n| n.as_object().extend(NO_PARENT, [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", tag: MSOURCE_IMAGE_ATT, 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.ic.update_node(node, |mut node| { node = node.as_object().insert_s(ct.is, NO_SEASON_INDEX, season); node = node.as_object().insert_s(ct.is, NO_INDEX, episode); node = node.as_object().insert_s(ct.is, NO_KIND, KIND_EPISODE); node })?; } } Ok(()) } }