diff options
| author | metamuffin <metamuffin@disroot.org> | 2025-12-10 16:21:38 +0100 |
|---|---|---|
| committer | metamuffin <metamuffin@disroot.org> | 2025-12-10 16:21:38 +0100 |
| commit | a0cfd77b4d19c43a28c4d82072e6ff136e336af3 (patch) | |
| tree | 05df9f5faa54cef0ae4136fffddea57fbbafee6b /import | |
| parent | 242d5763d451eed2402be7afde50cd9fa0d6bc79 (diff) | |
| download | jellything-a0cfd77b4d19c43a28c4d82072e6ff136e336af3.tar jellything-a0cfd77b4d19c43a28c4d82072e6ff136e336af3.tar.bz2 jellything-a0cfd77b4d19c43a28c4d82072e6ff136e336af3.tar.zst | |
refactor import plugins part 1
Diffstat (limited to 'import')
| -rw-r--r-- | import/Cargo.toml | 2 | ||||
| -rw-r--r-- | import/src/infojson.rs | 146 | ||||
| -rw-r--r-- | import/src/lib.rs | 473 | ||||
| -rw-r--r-- | import/src/plugins/acoustid.rs (renamed from import/src/acoustid.rs) | 28 | ||||
| -rw-r--r-- | import/src/plugins/infojson.rs | 272 | ||||
| -rw-r--r-- | import/src/plugins/media_info.rs | 92 | ||||
| -rw-r--r-- | import/src/plugins/misc.rs | 100 | ||||
| -rw-r--r-- | import/src/plugins/mod.rs | 48 | ||||
| -rw-r--r-- | import/src/plugins/musicbrainz.rs (renamed from import/src/musicbrainz.rs) | 8 | ||||
| -rw-r--r-- | import/src/plugins/tags.rs | 60 | ||||
| -rw-r--r-- | import/src/plugins/tmdb.rs (renamed from import/src/tmdb.rs) | 0 | ||||
| -rw-r--r-- | import/src/plugins/trakt.rs (renamed from import/src/trakt.rs) | 33 | ||||
| -rw-r--r-- | import/src/plugins/vgmdb.rs (renamed from import/src/vgmdb.rs) | 0 | ||||
| -rw-r--r-- | import/src/plugins/wikidata.rs (renamed from import/src/wikidata.rs) | 6 | ||||
| -rw-r--r-- | import/src/plugins/wikimedia_commons.rs (renamed from import/src/wikimedia_commons.rs) | 0 |
15 files changed, 694 insertions, 574 deletions
diff --git a/import/Cargo.toml b/import/Cargo.toml index 4276768..42c1d43 100644 --- a/import/Cargo.toml +++ b/import/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "jellyimport" version = "0.1.0" -edition = "2021" +edition = "2024" [dependencies] jellyremuxer = { path = "../remuxer" } diff --git a/import/src/infojson.rs b/import/src/infojson.rs deleted file mode 100644 index ada6c3a..0000000 --- a/import/src/infojson.rs +++ /dev/null @@ -1,146 +0,0 @@ -/* - 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 <metamuffin.org> -*/ -use anyhow::Context; -use jellycommon::chrono::{format::Parsed, Utc}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct YVideo { - pub id: String, - pub title: String, - pub alt_title: Option<String>, - pub formats: Option<Vec<YFormat>>, - pub thumbnails: Option<Vec<YThumbnail>>, - pub thumbnail: Option<String>, - pub description: Option<String>, - pub channel_id: Option<String>, - pub duration: Option<f64>, - pub view_count: Option<usize>, - pub average_rating: Option<String>, - pub age_limit: Option<usize>, - pub webpage_url: String, - pub categories: Option<Vec<String>>, - pub tags: Option<Vec<String>>, - pub playable_in_embed: Option<bool>, - pub aspect_ratio: Option<f32>, - pub width: Option<i32>, - pub height: Option<i32>, - pub automatic_captions: Option<HashMap<String, Vec<YCaption>>>, - pub comment_count: Option<usize>, - pub chapters: Option<Vec<YChapter>>, - pub heatmap: Option<Vec<YHeatmapSample>>, - pub like_count: Option<usize>, - pub channel: Option<String>, - pub channel_follower_count: Option<usize>, - pub channel_is_verified: Option<bool>, - pub uploader: Option<String>, - pub uploader_id: Option<String>, - pub uploader_url: Option<String>, - pub upload_date: Option<String>, - pub availability: Option<String>, // "public" | "private" | "unlisted", - pub original_url: Option<String>, - pub webpage_url_basename: String, - pub webpage_url_domain: String, - pub extractor: String, - pub extractor_key: String, - pub playlist_count: Option<usize>, - pub playlist: Option<String>, - pub playlist_id: Option<String>, - pub playlist_title: Option<String>, - pub playlist_uploader: Option<String>, - pub playlist_uploader_id: Option<String>, - pub n_entries: Option<usize>, - pub playlist_index: Option<usize>, - pub display_id: Option<String>, - pub fulltitle: Option<String>, - pub duration_string: Option<String>, - pub is_live: Option<bool>, - pub was_live: Option<bool>, - pub epoch: usize, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct YCaption { - pub url: Option<String>, - pub ext: String, //"vtt" | "json3" | "srv1" | "srv2" | "srv3" | "ttml", - pub protocol: Option<String>, - pub name: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct YFormat { - pub format_id: String, - pub format_note: Option<String>, - pub ext: String, - pub protocol: String, - pub acodec: Option<String>, - pub vcodec: Option<String>, - pub url: Option<String>, - pub width: Option<u32>, - pub height: Option<u32>, - pub fps: Option<f64>, - pub columns: Option<u32>, - pub fragments: Option<Vec<YFragment>>, - pub resolution: Option<String>, - pub dynamic_range: Option<String>, - pub aspect_ratio: Option<f64>, - pub http_headers: HashMap<String, String>, - pub audio_ext: String, - pub video_ext: String, - pub vbr: Option<f64>, - pub abr: Option<f64>, - pub format: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct YFragment { - pub url: Option<String>, - pub duration: Option<f64>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct YThumbnail { - pub url: String, - pub preference: Option<i32>, - pub id: String, - pub height: Option<u32>, - pub width: Option<u32>, - pub resolution: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct YChapter { - pub start_time: f64, - pub end_time: f64, - pub title: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct YHeatmapSample { - pub start_time: f64, - pub end_time: f64, - pub value: f64, -} - -pub fn parse_upload_date(d: &str) -> anyhow::Result<i64> { - let (year, month, day) = (&d[0..4], &d[4..6], &d[6..8]); - let (year, month, day) = ( - year.parse().context("parsing year")?, - month.parse().context("parsing month")?, - day.parse().context("parsing day")?, - ); - - let mut p = Parsed::new(); - p.year = Some(year); - p.month = Some(month); - p.day = Some(day); - p.hour_div_12 = Some(0); - p.hour_mod_12 = Some(0); - p.minute = Some(0); - p.second = Some(0); - Ok(p.to_datetime_with_timezone(&Utc)?.timestamp_millis()) -} diff --git a/import/src/lib.rs b/import/src/lib.rs index e31127e..36c65d3 100644 --- a/import/src/lib.rs +++ b/import/src/lib.rs @@ -5,54 +5,47 @@ */ #![feature(duration_constants)] -pub mod acoustid; -pub mod infojson; -pub mod musicbrainz; -pub mod tmdb; -pub mod trakt; -pub mod vgmdb; -pub mod wikidata; -pub mod wikimedia_commons; +pub mod plugins; -use jellydb::Database; - -use crate::{tmdb::TmdbKind, trakt::TraktKind}; -use acoustid::{acoustid_fingerprint, AcoustID}; -use anyhow::{anyhow, bail, Context, Result}; -use infojson::YVideo; -use jellycache::{cache_memory, cache_read, cache_store, HashKey}; +use crate::plugins::{ + acoustid::AcoustID, + infojson::is_info_json, + misc::is_cover, + musicbrainz::{self, MusicBrainz}, + tmdb::{self, Tmdb, TmdbKind}, + trakt::{Trakt, TraktKind}, + vgmdb::Vgmdb, + wikidata::Wikidata, + wikimedia_commons::WikimediaCommons, +}; +use anyhow::{Context, Result, anyhow}; +use jellycache::{HashKey, cache_memory, cache_store}; use jellycommon::{ - Appearance, Asset, Chapter, CreditCategory, IdentifierType, MediaInfo, Node, NodeID, NodeKind, - PictureSlot, RatingType, SourceTrack, SourceTrackKind, TrackSource, Visibility, + Appearance, Asset, CreditCategory, IdentifierType, Node, NodeID, NodeKind, PictureSlot, + RatingType, Visibility, }; +use jellydb::Database; use jellyimport_fallback_generator::generate_fallback; use jellyremuxer::{ demuxers::create_demuxer_autodetect, - matroska::{self, Segment}, + matroska::{self, AttachedFile, Segment}, }; use log::info; -use musicbrainz::MusicBrainz; -use rayon::iter::{ParallelBridge, ParallelIterator}; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; use regex::Regex; use serde::{Deserialize, Serialize}; use std::{ - collections::{BTreeMap, HashMap}, - fs::{read_to_string, File}, - io::{BufReader, Read}, + collections::BTreeMap, + fs::{File, read_to_string}, path::{Path, PathBuf}, sync::{Arc, LazyLock, Mutex}, time::UNIX_EPOCH, }; -use tmdb::Tmdb; use tokio::{ runtime::Handle, sync::{RwLock, Semaphore}, task::spawn_blocking, }; -use trakt::Trakt; -use vgmdb::Vgmdb; -use wikidata::Wikidata; -use wikimedia_commons::WikimediaCommons; #[rustfmt::skip] #[derive(Debug, Deserialize, Serialize, Default)] @@ -89,6 +82,7 @@ pub const USER_AGENT: &str = concat!( static IMPORT_SEM: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(1)); pub static IMPORT_ERRORS: RwLock<Vec<String>> = RwLock::const_new(Vec::new()); +pub static IMPORT_PROGRESS: RwLock<Option<(usize, usize, String)>> = RwLock::const_new(None); static RE_EPISODE_FILENAME: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"([sS](?<season>\d+))?([eE](?<episode>\d+))( (.+))?"#).unwrap()); @@ -117,7 +111,7 @@ pub fn get_trakt() -> Result<Trakt> { } pub async fn import_wrap(db: Database, incremental: bool) -> Result<()> { - let _sem = IMPORT_SEM.try_acquire()?; + let _sem = IMPORT_SEM.try_acquire().context("already importing")?; let jh = spawn_blocking(move || { *IMPORT_ERRORS.blocking_write() = Vec::new(); @@ -144,11 +138,10 @@ fn import(db: &Database, incremental: bool) -> Result<()> { let rthandle = Handle::current(); + let mut files = Vec::new(); import_traverse( &CONF.media_path, db, - &apis, - &rthandle, incremental, NodeID::MIN, "", @@ -156,8 +149,17 @@ fn import(db: &Database, incremental: bool) -> Result<()> { visibility: Visibility::Visible, use_acoustid: false, }, + &mut files, )?; + files.into_par_iter().for_each(|(path, parent, iflags)| { + import_file(db, &apis, &rthandle, &path, parent, iflags); + }); + + // let meta = path.metadata()?; + // let mtime = meta.modified()?.duration_since(UNIX_EPOCH)?.as_secs(); + // db.set_import_file_mtime(path, mtime)?; + Ok(()) } @@ -170,12 +172,11 @@ struct InheritedFlags { fn import_traverse( path: &Path, db: &Database, - apis: &Apis, - rthandle: &Handle, incremental: bool, parent: NodeID, parent_slug_fragment: &str, mut iflags: InheritedFlags, + out: &mut Vec<(PathBuf, NodeID, InheritedFlags)>, ) -> Result<()> { if path.is_dir() { let slug_fragment = if path == CONF.media_path { @@ -211,26 +212,18 @@ fn import_traverse( Ok(()) })?; - path.read_dir()?.par_bridge().try_for_each(|e| { + for e in path.read_dir()? { let path = e?.path(); - if let Err(e) = import_traverse( - &path, - db, - apis, - rthandle, - incremental, - id, - &slug_fragment, - iflags, - ) { + if let Err(e) = import_traverse(&path, db, incremental, id, &slug_fragment, iflags, out) + { IMPORT_ERRORS .blocking_write() .push(format!("{path:?} import failed: {e:#}")); } - Ok::<_, anyhow::Error>(()) - })?; + } return Ok(()); } + if path.is_file() { let meta = path.metadata()?; let mtime = meta.modified()?.duration_since(UNIX_EPOCH)?.as_secs(); @@ -243,8 +236,7 @@ fn import_traverse( } } - import_file(db, apis, rthandle, path, parent, iflags)?; - db.set_import_file_mtime(path, mtime)?; + out.push((path.to_owned(), parent, iflags)); } Ok(()) } @@ -259,36 +251,6 @@ fn import_file( ) -> Result<()> { let filename = path.file_name().unwrap().to_string_lossy(); match filename.as_ref() { - "poster.jpeg" | "poster.webp" | "poster.png" => { - info!("import poster 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) - }, - )?); - db.update_node_init(parent, |node| { - node.pictures.insert(PictureSlot::Cover, asset); - Ok(()) - })?; - } - "backdrop.jpeg" | "backdrop.webp" | "backdrop.png" => { - info!("import backdrop 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) - }, - )?); - db.update_node_init(parent, |node| { - node.pictures.insert(PictureSlot::Backdrop, asset); - Ok(()) - })?; - } "node.yaml" => { info!("import node info at {path:?}"); let data = serde_yaml::from_str::<Node>(&read_to_string(path)?)?; @@ -330,29 +292,6 @@ fn import_file( })?; } } - "channel.info.json" => { - info!("import channel info.json at {path:?}"); - let data = serde_json::from_reader::<_, YVideo>(BufReader::new(File::open(path)?))?; - db.update_node_init(parent, |node| { - node.kind = NodeKind::Channel; - node.title = Some(clean_uploader_name(&data.title).to_owned()); - if let Some(cid) = data.channel_id { - node.identifiers.insert(IdentifierType::YoutubeChannel, cid); - } - if let Some(uid) = data.uploader_id { - node.identifiers - .insert(IdentifierType::YoutubeChannelHandle, uid); - } - if let Some(desc) = data.description { - node.description = Some(desc); - } - if let Some(followers) = data.channel_follower_count { - node.ratings - .insert(RatingType::YoutubeFollowers, followers as f64); - } - Ok(()) - })?; - } _ => import_media_file(db, apis, rthandle, path, parent, iflags).context("media file")?, } @@ -376,7 +315,7 @@ pub fn read_media_metadata(path: &Path) -> Result<Arc<matroska::Segment>> { // Replace data of useful attachments with cache key; delete data of all others if let Some(attachments) = &mut attachments { for att in &mut attachments.files { - if let Some(fname) = attachment_types::is_useful_attachment(&att) { + if let Some(fname) = is_useful_attachment(&att) { let key = cache_store( format!("media/attachment/{}-{fname}", HashKey(path)), || Ok(att.data.clone()), @@ -400,22 +339,11 @@ pub fn read_media_metadata(path: &Path) -> Result<Arc<matroska::Segment>> { ) } -mod attachment_types { - use jellyremuxer::matroska::AttachedFile; - - pub fn is_useful_attachment(a: &AttachedFile) -> Option<&'static str> { - match a { - _ if is_info_json(&a) => Some("info.json"), - _ if is_cover(&a) => Some("cover.image"), - _ => None, - } - } - - pub fn is_info_json(a: &&AttachedFile) -> bool { - a.name == "info.json" && a.media_type == "application/json" - } - pub fn is_cover(a: &&AttachedFile) -> bool { - a.name.starts_with("cover") && a.media_type.starts_with("image/") +pub fn is_useful_attachment(a: &AttachedFile) -> Option<&'static str> { + match a { + _ if is_info_json(&a) => Some("info.json"), + _ if is_cover(&a) => Some("cover.image"), + _ => None, } } @@ -430,38 +358,6 @@ fn import_media_file( info!("media file {path:?}"); let m = read_media_metadata(path)?; - let infojson = m - .attachments - .iter() - .flat_map(|a| &a.files) - .find(attachment_types::is_info_json) - .map(|att| { - let data = cache_read(str::from_utf8(&att.data).unwrap())? - .ok_or(anyhow!("info json cache missing"))?; - anyhow::Ok(serde_json::from_slice::<infojson::YVideo>(&data)?) - }) - .transpose() - .context("infojson parsing")?; - - let cover = m - .attachments - .iter() - .flat_map(|a| &a.files) - .find(attachment_types::is_cover) - .map(|att| Asset(att.data.clone().try_into().unwrap())); - - let mut tags = m - .tags - .first() - .map(|tags| { - tags.tags - .iter() - .flat_map(|t| t.simple_tags.clone()) - .map(|st| (st.name, st.string.unwrap_or_default())) - .collect::<HashMap<_, _>>() - }) - .unwrap_or_default(); - let filename = path.file_name().unwrap().to_string_lossy().to_string(); let mut episode_index = None; @@ -480,215 +376,28 @@ fn import_media_file( let mut filename_toks = filename.split("."); let filepath_stem = filename_toks.next().unwrap(); - let slug = infojson - .as_ref() - // TODO maybe also include the slug after the primary "id" key - .map(|ij| format!("{}-{}", ij.extractor.to_lowercase(), ij.id)) - .unwrap_or_else(|| { - if let Some((s, e)) = episode_index { - format!( - "{}-s{s}e{e}", - make_kebab( - &path - .parent() - .unwrap() - .file_name() - .unwrap_or_default() - .to_string_lossy() - ) - ) - } else { - make_kebab(filepath_stem) - } - }); + let slug = if let Some((s, e)) = episode_index { + format!( + "{}-s{s}e{e}", + make_kebab( + &path + .parent() + .unwrap() + .file_name() + .unwrap_or_default() + .to_string_lossy() + ) + ) + } else { + make_kebab(filepath_stem) + }; let node = NodeID::from_slug(&slug); - let meta = path.metadata()?; - let mut eids = BTreeMap::<IdentifierType, String>::new(); - - for (key, value) in &tags { - match key.as_str() { - "MUSICBRAINZ_TRACKID" => { - eids.insert(IdentifierType::MusicbrainzRecording, value.to_owned()) - } - "MUSICBRAINZ_ARTISTID" => { - eids.insert(IdentifierType::MusicbrainzArtist, value.to_owned()) - } - "MUSICBRAINZ_ALBUMID" => { - eids.insert(IdentifierType::MusicbrainzRelease, value.to_owned()) - } - "MUSICBRAINZ_ALBUMARTISTID" => { - None //? ignore this? - } - "MUSICBRAINZ_RELEASEGROUPID" => { - eids.insert(IdentifierType::MusicbrainzReleaseGroup, value.to_owned()) - } - "ISRC" => eids.insert(IdentifierType::Isrc, value.to_owned()), - "BARCODE" => eids.insert(IdentifierType::Barcode, value.to_owned()), - _ => None, - }; - } - - if iflags.use_acoustid { - let fp = acoustid_fingerprint(path)?; - if let Some((atid, mbid)) = apis - .acoustid - .as_ref() - .ok_or(anyhow!("need acoustid"))? - .get_atid_mbid(&fp, rthandle)? - { - eids.insert(IdentifierType::AcoustIdTrack, atid); - eids.insert(IdentifierType::MusicbrainzRecording, mbid); - }; - } - - let mbrec = eids.get(&IdentifierType::MusicbrainzRecording).cloned(); - db.update_node_init(node, |node| { node.slug = slug; - node.title = m.info.title.clone().or(node.title.clone()); node.visibility = iflags.visibility; - - node.description = tags - .remove("DESCRIPTION") - .or(tags.remove("SYNOPSIS")) - .or(node.description.clone()); - node.tagline = tags.remove("COMMENT").or(node.tagline.clone()); node.parents.insert(parent); - - node.identifiers.extend(eids); - - if let Some(cover) = cover { - node.pictures.insert(PictureSlot::Cover, cover); - } - - if let Some(ct) = tags.get("CONTENT_TYPE") { - node.kind = match ct.to_lowercase().trim() { - "movie" | "documentary" | "film" => NodeKind::Movie, - "music" | "recording" => NodeKind::Music, - _ => NodeKind::Unknown, - } - } - - let tracks = m - .tracks - .as_ref() - .ok_or(anyhow!("no tracks"))? - .entries - .iter() - .map(|track| SourceTrack { - codec: track.codec_id.clone(), - language: track.language.clone(), - name: track.name.clone().unwrap_or_default(), - federated: Vec::new(), - kind: if let Some(video) = &track.video { - SourceTrackKind::Video { - width: video.pixel_width, - height: video.pixel_height, - fps: video.frame_rate, - } - } else if let Some(audio) = &track.audio { - SourceTrackKind::Audio { - channels: audio.channels as usize, - sample_rate: audio.sampling_frequency, - bit_depth: audio.bit_depth.map(|r| r as usize), - } - } else { - SourceTrackKind::Subtitle - }, - source: TrackSource::Local(path.to_owned(), track.track_number), - }) - .collect::<Vec<_>>(); - - if let Some(infojson) = infojson { - node.kind = if !tracks - .iter() - .any(|t| matches!(t.kind, SourceTrackKind::Video { .. })) - { - NodeKind::Music - } else if infojson.duration.unwrap_or(0.) < 600. - && infojson.aspect_ratio.unwrap_or(2.) < 1. - { - NodeKind::ShortFormVideo - } else { - NodeKind::Video - }; - node.title = Some(infojson.title); - node.subtitle = if infojson.alt_title != node.title { - infojson.alt_title - } else { - None - } - .or(infojson - .uploader - .as_ref() - .map(|u| clean_uploader_name(u).to_owned())) - .or(node.subtitle.clone()); - - node.tags.extend(infojson.tags.unwrap_or_default()); - - if let Some(desc) = infojson.description { - node.description = Some(desc) - } - node.tagline = Some(infojson.webpage_url); - if let Some(date) = &infojson.upload_date { - node.release_date = - Some(infojson::parse_upload_date(date).context("parsing upload date")?); - } - match infojson.extractor.as_str() { - "youtube" => { - node.identifiers - .insert(IdentifierType::YoutubeVideo, infojson.id); - node.ratings.insert( - RatingType::YoutubeViews, - infojson.view_count.unwrap_or_default() as f64, - ); - if let Some(lc) = infojson.like_count { - node.ratings.insert(RatingType::YoutubeLikes, lc as f64); - } - } - "Bandcamp" => drop( - node.identifiers - .insert(IdentifierType::Bandcamp, infojson.id), - ), - _ => (), - } - } - - // TODO merge size - node.storage_size = meta.len(); - // TODO merge tracks - node.media = Some(MediaInfo { - chapters: m - .chapters - .clone() - .map(|c| { - let mut chaps = Vec::new(); - if let Some(ee) = c.edition_entries.first() { - for ca in &ee.chapter_atoms { - let mut labels = Vec::new(); - for cd in &ca.displays { - for lang in &cd.languages { - labels.push((lang.to_owned(), cd.string.clone())) - } - } - chaps.push(Chapter { - labels, - time_start: Some(ca.time_start as f64 * 1e-9), - time_end: ca.time_end.map(|ts| ts as f64 * 1e-9), - }) - } - } - chaps - }) - .unwrap_or_default(), - duration: fix_invalid_runtime( - m.info.duration.unwrap_or_default() * m.info.timestamp_scale as f64 * 1e-9, - ), - tracks, - }); - Ok(()) })?; @@ -741,9 +450,6 @@ fn import_media_file( for tok in filename_toks { apply_node_flag(db, rthandle, apis, node, tok)?; } - if let Some(mbid) = mbrec { - apply_musicbrainz_recording(db, rthandle, apis, node, mbid)?; - } Ok(()) } @@ -755,48 +461,6 @@ fn apply_node_flag( node: NodeID, flag: &str, ) -> Result<()> { - if let Some(value) = flag.strip_prefix("trakt-").or(flag.strip_prefix("trakt=")) { - let (kind, id) = value.split_once(":").unwrap_or(("", value)); - let kind = match kind { - "movie" | "" => TraktKind::Movie, - "show" => TraktKind::Show, - "season" => TraktKind::Season, - "episode" => TraktKind::Episode, - _ => bail!("unknown trakt kind"), - }; - apply_trakt_tmdb(db, rthandle, apis, node, kind, id)?; - } - if flag == "hidden" { - db.update_node_init(node, |node| { - node.visibility = node.visibility.min(Visibility::Hidden); - Ok(()) - })?; - } - if flag == "reduced" { - db.update_node_init(node, |node| { - node.visibility = node.visibility.min(Visibility::Reduced); - Ok(()) - })?; - } - if let Some(kind) = flag.strip_prefix("kind-").or(flag.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"), - }; - db.update_node_init(node, |node| { - node.kind = kind; - Ok(()) - })?; - } if let Some(mbid) = flag.strip_prefix("mbrec-").or(flag.strip_prefix("mbrec=")) { apply_musicbrainz_recording(db, rthandle, apis, node, mbid.to_string())? } @@ -1016,18 +680,3 @@ fn make_kebab(i: &str) -> String { } o } - -fn clean_uploader_name(mut s: &str) -> &str { - s = s.strip_suffix(" - Videos").unwrap_or(s); - s = s.strip_suffix(" - Topic").unwrap_or(s); - s = s.strip_prefix("Uploads from ").unwrap_or(s); - s -} - -fn fix_invalid_runtime(d: f64) -> f64 { - match d { - // Broken durations found experimentally - 359999.999 | 359999.000 | 86399.999 | 86399.99900000001 => 0., - x => x, - } -} diff --git a/import/src/acoustid.rs b/import/src/plugins/acoustid.rs index 01adb57..154b0a2 100644 --- a/import/src/acoustid.rs +++ b/import/src/plugins/acoustid.rs @@ -3,13 +3,18 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ -use crate::USER_AGENT; +use crate::{ + USER_AGENT, + plugins::{ImportContext, ImportPlugin}, +}; use anyhow::{Context, Result}; -use jellycache::{cache_memory, HashKey}; +use jellycache::{HashKey, cache_memory}; +use jellycommon::{IdentifierType, NodeID}; +use jellyremuxer::matroska::Segment; use log::info; use reqwest::{ - header::{HeaderMap, HeaderName, HeaderValue}, Client, ClientBuilder, + header::{HeaderMap, HeaderName, HeaderValue}, }; use serde::{Deserialize, Serialize}; use std::{ @@ -22,7 +27,7 @@ use std::{ use tokio::{ runtime::Handle, sync::Semaphore, - time::{sleep_until, Instant}, + time::{Instant, sleep_until}, }; pub(crate) struct AcoustID { @@ -152,3 +157,18 @@ pub(crate) fn acoustid_fingerprint(path: &Path) -> Result<Arc<Fingerprint>> { }, ) } + +impl ImportPlugin for AcoustID { + fn media(&self, ct: &ImportContext, node: NodeID, path: &Path, _seg: &Segment) -> Result<()> { + let fp = acoustid_fingerprint(path)?; + if let Some((atid, mbid)) = self.get_atid_mbid(&fp, &ct.rt)? { + ct.db.update_node_init(node, |n| { + n.identifiers.insert(IdentifierType::AcoustIdTrack, atid); + n.identifiers + .insert(IdentifierType::MusicbrainzRecording, mbid); + Ok(()) + })?; + }; + Ok(()) + } +} diff --git a/import/src/plugins/infojson.rs b/import/src/plugins/infojson.rs new file mode 100644 index 0000000..4dceeb8 --- /dev/null +++ b/import/src/plugins/infojson.rs @@ -0,0 +1,272 @@ +/* + 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 <metamuffin.org> +*/ +use anyhow::{Context, Result, anyhow}; +use jellycache::cache_read; +use jellycommon::{ + IdentifierType, NodeID, NodeKind, RatingType, + chrono::{Utc, format::Parsed}, +}; +use jellyremuxer::matroska::{AttachedFile, Segment}; +use log::info; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, fs::File, io::BufReader, path::Path}; + +use crate::plugins::{ImportContext, ImportPlugin}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct YVideo { + pub album: Option<String>, + pub age_limit: Option<usize>, + pub alt_title: Option<String>, + pub aspect_ratio: Option<f32>, + pub automatic_captions: Option<HashMap<String, Vec<YCaption>>>, + pub availability: Option<String>, // "public" | "private" | "unlisted", + pub average_rating: Option<String>, + pub categories: Option<Vec<String>>, + pub channel_follower_count: Option<usize>, + pub channel_id: Option<String>, + pub channel_is_verified: Option<bool>, + pub channel: Option<String>, + pub chapters: Option<Vec<YChapter>>, + pub comment_count: Option<usize>, + pub description: Option<String>, + pub display_id: Option<String>, + pub duration_string: Option<String>, + pub duration: Option<f64>, + pub epoch: usize, + pub extractor_key: String, + pub extractor: String, + pub formats: Option<Vec<YFormat>>, + pub fulltitle: Option<String>, + pub heatmap: Option<Vec<YHeatmapSample>>, + pub height: Option<i32>, + pub id: String, + pub is_live: Option<bool>, + pub like_count: Option<usize>, + pub media_type: Option<String>, + pub n_entries: Option<usize>, + pub original_url: Option<String>, + pub playable_in_embed: Option<bool>, + pub playlist_count: Option<usize>, + pub playlist_id: Option<String>, + pub playlist_index: Option<usize>, + pub playlist_title: Option<String>, + pub playlist_uploader_id: Option<String>, + pub playlist_uploader: Option<String>, + pub playlist: Option<String>, + pub tags: Option<Vec<String>>, + pub thumbnail: Option<String>, + pub thumbnails: Option<Vec<YThumbnail>>, + pub title: String, + pub upload_date: Option<String>, + pub uploader_id: Option<String>, + pub uploader_url: Option<String>, + pub uploader: Option<String>, + pub view_count: Option<usize>, + pub was_live: Option<bool>, + pub webpage_url_basename: String, + pub webpage_url_domain: String, + pub webpage_url: String, + pub width: Option<i32>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct YCaption { + pub url: Option<String>, + pub ext: String, //"vtt" | "json3" | "srv1" | "srv2" | "srv3" | "ttml", + pub protocol: Option<String>, + pub name: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct YFormat { + pub format_id: String, + pub format_note: Option<String>, + pub ext: String, + pub protocol: String, + pub acodec: Option<String>, + pub vcodec: Option<String>, + pub url: Option<String>, + pub width: Option<u32>, + pub height: Option<u32>, + pub fps: Option<f64>, + pub columns: Option<u32>, + pub fragments: Option<Vec<YFragment>>, + pub resolution: Option<String>, + pub dynamic_range: Option<String>, + pub aspect_ratio: Option<f64>, + pub http_headers: HashMap<String, String>, + pub audio_ext: String, + pub video_ext: String, + pub vbr: Option<f64>, + pub abr: Option<f64>, + pub format: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct YFragment { + pub url: Option<String>, + pub duration: Option<f64>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct YThumbnail { + pub url: String, + pub preference: Option<i32>, + pub id: String, + pub height: Option<u32>, + pub width: Option<u32>, + pub resolution: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct YChapter { + pub start_time: f64, + pub end_time: f64, + pub title: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct YHeatmapSample { + pub start_time: f64, + pub end_time: f64, + pub value: f64, +} + +pub fn parse_upload_date(d: &str) -> anyhow::Result<i64> { + let (year, month, day) = (&d[0..4], &d[4..6], &d[6..8]); + let (year, month, day) = ( + year.parse().context("parsing year")?, + month.parse().context("parsing month")?, + day.parse().context("parsing day")?, + ); + + let mut p = Parsed::new(); + p.year = Some(year); + p.month = Some(month); + p.day = Some(day); + p.hour_div_12 = Some(0); + p.hour_mod_12 = Some(0); + p.minute = Some(0); + p.second = Some(0); + Ok(p.to_datetime_with_timezone(&Utc)?.timestamp_millis()) +} + +pub fn is_info_json(a: &&AttachedFile) -> bool { + a.name == "info.json" && a.media_type == "application/json" +} +pub struct Infojson; +impl ImportPlugin for Infojson { + fn file(&self, ct: &ImportContext, parent: NodeID, path: &Path) -> Result<()> { + let filename = path.file_name().unwrap().to_string_lossy(); + if filename != "channel.info.json" { + return Ok(()); + } + + info!("import channel info.json at {path:?}"); + let data = serde_json::from_reader::<_, YVideo>(BufReader::new(File::open(path)?))?; + ct.db.update_node_init(parent, |node| { + node.kind = NodeKind::Channel; + node.title = Some(clean_uploader_name(&data.title).to_owned()); + if let Some(cid) = data.channel_id { + node.identifiers.insert(IdentifierType::YoutubeChannel, cid); + } + if let Some(uid) = data.uploader_id { + node.identifiers + .insert(IdentifierType::YoutubeChannelHandle, uid); + } + if let Some(desc) = data.description { + node.description = Some(desc); + } + if let Some(followers) = data.channel_follower_count { + node.ratings + .insert(RatingType::YoutubeFollowers, followers as f64); + } + Ok(()) + })?; + + Ok(()) + } + + fn media(&self, ct: &ImportContext, node: NodeID, _path: &Path, seg: &Segment) -> Result<()> { + let infojson = seg + .attachments + .iter() + .flat_map(|a| &a.files) + .find(is_info_json) + .map(|att| { + let data = cache_read(str::from_utf8(&att.data).unwrap())? + .ok_or(anyhow!("info json cache missing"))?; + anyhow::Ok(serde_json::from_slice::<YVideo>(&data)?) + }) + .transpose() + .context("infojson parsing")?; + + if let Some(infojson) = infojson { + ct.db.update_node_init(node, |node| { + node.kind = if let Some(ty) = &infojson.media_type + && ty == "short" + { + NodeKind::ShortFormVideo + } else if infojson.album.is_some() { + NodeKind::Music + } else { + NodeKind::Video + }; + node.title = Some(infojson.title); + node.subtitle = if infojson.alt_title != node.title { + infojson.alt_title + } else { + None + } + .or(infojson + .uploader + .as_ref() + .map(|u| clean_uploader_name(u).to_owned())) + .or(node.subtitle.clone()); + + node.tags.extend(infojson.tags.unwrap_or_default()); + + if let Some(desc) = infojson.description { + node.description = Some(desc) + } + node.tagline = Some(infojson.webpage_url); + if let Some(date) = &infojson.upload_date { + node.release_date = + Some(parse_upload_date(date).context("parsing upload date")?); + } + match infojson.extractor.as_str() { + "youtube" => { + node.identifiers + .insert(IdentifierType::YoutubeVideo, infojson.id); + node.ratings.insert( + RatingType::YoutubeViews, + infojson.view_count.unwrap_or_default() as f64, + ); + if let Some(lc) = infojson.like_count { + node.ratings.insert(RatingType::YoutubeLikes, lc as f64); + } + } + "Bandcamp" => drop( + node.identifiers + .insert(IdentifierType::Bandcamp, infojson.id), + ), + _ => (), + } + + Ok(()) + })?; + } + Ok(()) + } +} + +fn clean_uploader_name(mut s: &str) -> &str { + s = s.strip_suffix(" - Videos").unwrap_or(s); + s = s.strip_suffix(" - Topic").unwrap_or(s); + s = s.strip_prefix("Uploads from ").unwrap_or(s); + s +} diff --git a/import/src/plugins/media_info.rs b/import/src/plugins/media_info.rs new file mode 100644 index 0000000..1d4d627 --- /dev/null +++ b/import/src/plugins/media_info.rs @@ -0,0 +1,92 @@ +/* + 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 <metamuffin.org> +*/ + +use crate::plugins::{ImportContext, ImportPlugin}; +use anyhow::{Result, anyhow}; +use jellycommon::{Chapter, NodeID, SourceTrack, SourceTrackKind, TrackSource}; +use jellyremuxer::matroska::Segment; +use std::path::Path; + +pub struct MediaInfo; +impl ImportPlugin for MediaInfo { + fn media(&self, ct: &ImportContext, node: NodeID, path: &Path, seg: &Segment) -> Result<()> { + let tracks = seg + .tracks + .as_ref() + .ok_or(anyhow!("no tracks"))? + .entries + .iter() + .map(|track| SourceTrack { + codec: track.codec_id.clone(), + language: track.language.clone(), + name: track.name.clone().unwrap_or_default(), + federated: Vec::new(), + kind: if let Some(video) = &track.video { + SourceTrackKind::Video { + width: video.pixel_width, + height: video.pixel_height, + fps: video.frame_rate, + } + } else if let Some(audio) = &track.audio { + SourceTrackKind::Audio { + channels: audio.channels as usize, + sample_rate: audio.sampling_frequency, + bit_depth: audio.bit_depth.map(|r| r as usize), + } + } else { + SourceTrackKind::Subtitle + }, + source: TrackSource::Local(path.to_owned(), track.track_number), + }) + .collect::<Vec<_>>(); + + let size = path.metadata()?.len(); + + ct.db.update_node_init(node, |node| { + node.storage_size = size; + node.media = Some(jellycommon::MediaInfo { + chapters: seg + .chapters + .clone() + .map(|c| { + let mut chaps = Vec::new(); + if let Some(ee) = c.edition_entries.first() { + for ca in &ee.chapter_atoms { + let mut labels = Vec::new(); + for cd in &ca.displays { + for lang in &cd.languages { + labels.push((lang.to_owned(), cd.string.clone())) + } + } + chaps.push(Chapter { + labels, + time_start: Some(ca.time_start as f64 * 1e-9), + time_end: ca.time_end.map(|ts| ts as f64 * 1e-9), + }) + } + } + chaps + }) + .unwrap_or_default(), + duration: fix_invalid_runtime( + seg.info.duration.unwrap_or_default() * seg.info.timestamp_scale as f64 * 1e-9, + ), + tracks, + }); + Ok(()) + })?; + + Ok(()) + } +} + +fn fix_invalid_runtime(d: f64) -> f64 { + match d { + // Broken durations found experimentally + 359999.999 | 359999.000 | 86399.999 | 86399.99900000001 => 0., + x => x, + } +} diff --git a/import/src/plugins/misc.rs b/import/src/plugins/misc.rs new file mode 100644 index 0000000..4717753 --- /dev/null +++ b/import/src/plugins/misc.rs @@ -0,0 +1,100 @@ +/* + 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 <metamuffin.org> +*/ +use crate::plugins::{ImportContext, ImportPlugin}; +use anyhow::{Result, bail}; +use jellycache::{HashKey, cache_store}; +use jellycommon::{Asset, NodeID, NodeKind, PictureSlot, Visibility}; +use jellyremuxer::matroska::{AttachedFile, Segment}; +use log::info; +use std::{fs::File, io::Read, path::Path}; + +pub struct ImageFiles; +impl ImportPlugin for ImageFiles { + 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 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 import_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(()) + } +} diff --git a/import/src/plugins/mod.rs b/import/src/plugins/mod.rs new file mode 100644 index 0000000..47fcfbf --- /dev/null +++ b/import/src/plugins/mod.rs @@ -0,0 +1,48 @@ +/* + 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 <metamuffin.org> +*/ +pub mod acoustid; +pub mod infojson; +pub mod musicbrainz; +pub mod tags; +pub mod tmdb; +pub mod trakt; +pub mod vgmdb; +pub mod wikidata; +pub mod wikimedia_commons; +pub mod media_info; +pub mod misc; + +use std::path::Path; + +use anyhow::Result; +use jellycommon::NodeID; +use jellydb::Database; +use jellyremuxer::matroska::Segment; +use tokio::runtime::Handle; + +pub struct ImportContext { + pub db: Database, + pub rt: Handle, +} + +pub trait ImportPlugin { + fn file(&self, ct: &ImportContext, parent: NodeID, path: &Path) -> Result<()> { + let _ = (ct, parent, path); + Ok(()) + } + fn media(&self, ct: &ImportContext, node: NodeID, path: &Path, seg: &Segment) -> Result<()> { + let _ = (ct, node, path, seg); + Ok(()) + } + fn import_instruction(&self, ct: &ImportContext, node: NodeID, line: &str) -> Result<()> { + let _ = (ct, node, line); + Ok(()) + } + fn process_node(&self, ct: &ImportContext, node: NodeID) -> Result<()> { + let _ = (ct, node); + Ok(()) + } +} diff --git a/import/src/musicbrainz.rs b/import/src/plugins/musicbrainz.rs index fe86175..44b2a06 100644 --- a/import/src/musicbrainz.rs +++ b/import/src/plugins/musicbrainz.rs @@ -4,20 +4,20 @@ Copyright (C) 2025 metamuffin <metamuffin.org> */ -use crate::USER_AGENT; +use crate::{USER_AGENT, plugins::ImportPlugin}; use anyhow::{Context, Result}; use jellycache::cache_memory; use log::info; use reqwest::{ - header::{HeaderMap, HeaderName, HeaderValue}, Client, ClientBuilder, + header::{HeaderMap, HeaderName, HeaderValue}, }; use serde::{Deserialize, Serialize}; use std::{collections::BTreeMap, sync::Arc, time::Duration}; use tokio::{ runtime::Handle, sync::Semaphore, - time::{sleep_until, Instant}, + time::{Instant, sleep_until}, }; pub mod reltypes { @@ -316,3 +316,5 @@ impl MusicBrainz { .context("musicbrainz artist lookup") } } + +impl ImportPlugin for MusicBrainz {} diff --git a/import/src/plugins/tags.rs b/import/src/plugins/tags.rs new file mode 100644 index 0000000..8452aad --- /dev/null +++ b/import/src/plugins/tags.rs @@ -0,0 +1,60 @@ +/* + 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 <metamuffin.org> +*/ + +use crate::plugins::{ImportContext, ImportPlugin}; +use anyhow::Result; +use jellycommon::{IdentifierType, NodeID, NodeKind}; +use jellyremuxer::matroska::Segment; +use std::{collections::HashMap, path::Path}; + +pub struct Tags; +impl ImportPlugin for Tags { + fn media(&self, ct: &ImportContext, node: NodeID, _path: &Path, seg: &Segment) -> Result<()> { + let tags = seg + .tags + .first() + .map(|tags| { + tags.tags + .iter() + .flat_map(|t| t.simple_tags.clone()) + .map(|st| (st.name, st.string.unwrap_or_default())) + .collect::<HashMap<_, _>>() + }) + .unwrap_or_default(); + + ct.db.update_node_init(node, |node| { + node.title = seg.info.title.clone(); + for (key, value) in tags { + match key.as_str() { + "DESCRIPTION" => node.description = Some(value), + "SYNOPSIS" => node.description = Some(value), + "COMMENT" => node.tagline = Some(value), + "CONTENT_TYPE" => { + node.kind = match value.to_lowercase().trim() { + "movie" | "documentary" | "film" => NodeKind::Movie, + "music" | "recording" => NodeKind::Music, + _ => continue, + } + } + _ => node.identifiers.extend(Some(match key.as_str() { + "MUSICBRAINZ_TRACKID" => (IdentifierType::MusicbrainzRecording, value), + "MUSICBRAINZ_ARTISTID" => (IdentifierType::MusicbrainzArtist, value), + "MUSICBRAINZ_ALBUMID" => (IdentifierType::MusicbrainzRelease, value), + "MUSICBRAINZ_ALBUMARTISTID" => continue, + "MUSICBRAINZ_RELEASEGROUPID" => { + (IdentifierType::MusicbrainzReleaseGroup, value) + } + "ISRC" => (IdentifierType::Isrc, value), + "BARCODE" => (IdentifierType::Barcode, value), + _ => continue, + })), + } + } + Ok(()) + })?; + Ok(()) + } +} diff --git a/import/src/tmdb.rs b/import/src/plugins/tmdb.rs index 3d6e832..3d6e832 100644 --- a/import/src/tmdb.rs +++ b/import/src/plugins/tmdb.rs diff --git a/import/src/trakt.rs b/import/src/plugins/trakt.rs index 270c589..5a1aa8e 100644 --- a/import/src/trakt.rs +++ b/import/src/plugins/trakt.rs @@ -3,14 +3,17 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ -use crate::USER_AGENT; -use anyhow::{Context, Result}; -use jellycache::{cache_memory, HashKey}; -use jellycommon::{Appearance, CreditCategory, NodeID, NodeKind}; +use crate::{ + USER_AGENT, + plugins::{ImportContext, ImportPlugin}, +}; +use anyhow::{Context, Result, bail}; +use jellycache::{HashKey, cache_memory}; +use jellycommon::{Appearance, CreditCategory, IdentifierType, NodeID, NodeKind}; use log::info; use reqwest::{ - header::{HeaderMap, HeaderName, HeaderValue}, Client, ClientBuilder, + header::{HeaderMap, HeaderName, HeaderValue}, }; use serde::{Deserialize, Serialize}; use std::{collections::BTreeMap, fmt::Display, sync::Arc}; @@ -378,3 +381,23 @@ impl Display for TraktKind { }) } } + +impl ImportPlugin for Trakt { + fn import_instruction(&self, ct: &ImportContext, node: NodeID, line: &str) -> Result<()> { + if let Some(value) = line.strip_prefix("trakt-").or(line.strip_prefix("trakt=")) { + let (ty, id) = value.split_once(":").unwrap_or(("movie", value)); + let ty = match ty { + "movie" => IdentifierType::TraktMovie, + "show" => IdentifierType::TraktShow, + "season" => IdentifierType::TraktSeason, + "episode" => IdentifierType::TraktEpisode, + _ => bail!("unknown trakt kind"), + }; + ct.db.update_node_init(node, |node| { + node.identifiers.insert(ty, id.to_owned()); + Ok(()) + })?; + } + Ok(()) + } +} diff --git a/import/src/vgmdb.rs b/import/src/plugins/vgmdb.rs index 402fd90..402fd90 100644 --- a/import/src/vgmdb.rs +++ b/import/src/plugins/vgmdb.rs diff --git a/import/src/wikidata.rs b/import/src/plugins/wikidata.rs index 3a107fe..358996e 100644 --- a/import/src/wikidata.rs +++ b/import/src/plugins/wikidata.rs @@ -5,12 +5,12 @@ */ use crate::USER_AGENT; -use anyhow::{bail, Context, Result}; -use jellycache::{cache_memory, EscapeKey}; +use anyhow::{Context, Result, bail}; +use jellycache::{EscapeKey, cache_memory}; use log::info; use reqwest::{ - header::{HeaderMap, HeaderName, HeaderValue}, Client, ClientBuilder, + header::{HeaderMap, HeaderName, HeaderValue}, }; use serde::{Deserialize, Serialize}; use serde_json::Value; diff --git a/import/src/wikimedia_commons.rs b/import/src/plugins/wikimedia_commons.rs index 86d934c..86d934c 100644 --- a/import/src/wikimedia_commons.rs +++ b/import/src/plugins/wikimedia_commons.rs |