/* 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 */ #![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; use jellydb::Database; pub use jellyimport_asset_token as asset_token; use jellyimport_asset_token::AssetInner; use acoustid::{acoustid_fingerprint, AcoustID}; use anyhow::{anyhow, bail, Context, Result}; use infojson::YVideo; use jellycache::cache_file; use jellycommon::{ Appearance, Chapter, LocalTrack, MediaInfo, Node, NodeID, NodeKind, ObjectIds, PeopleGroup, Person, Rating, SourceTrack, SourceTrackKind, TmdbKind, TrackSource, TraktKind, Visibility, }; use jellyimport_fallback_generator::generate_fallback; use jellyremuxer::magic::detect_container_format; use log::info; use musicbrainz::MusicBrainz; use rayon::iter::{ParallelBridge, ParallelIterator}; use regex::Regex; use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, HashMap}, fs::{read_to_string, File}, io::BufReader, path::{Path, PathBuf}, sync::{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)] pub struct Config { media_path: PathBuf, api: ApiSecrets, } #[derive(Serialize, Deserialize, Debug, Default)] pub struct ApiSecrets { pub acoustid: Option, pub tmdb: Option, pub tvdb: Option, pub imdb: Option, pub omdb: Option, pub fanart_tv: Option, pub trakt: Option, } pub static CONF_PRELOAD: Mutex> = Mutex::new(None); static CONF: LazyLock = LazyLock::new(|| { CONF_PRELOAD .lock() .unwrap() .take() .expect("import config not preloaded. logic error") }); pub const USER_AGENT: &'static str = concat!( "jellything/", env!("CARGO_PKG_VERSION"), " +https://codeberg.org/metamuffin/jellything" ); static IMPORT_SEM: LazyLock = LazyLock::new(|| Semaphore::new(1)); pub static IMPORT_ERRORS: RwLock> = RwLock::const_new(Vec::new()); static RE_EPISODE_FILENAME: LazyLock = LazyLock::new(|| Regex::new(r#"([sS](?\d+))?([eE](?\d+))( (.+))?"#).unwrap()); struct Apis { trakt: Option, tmdb: Option, acoustid: Option, musicbrainz: MusicBrainz, wikidata: Wikidata, wikimedia_commons: WikimediaCommons, vgmdb: Vgmdb, } pub fn is_importing() -> bool { IMPORT_SEM.available_permits() == 0 } pub fn get_trakt() -> Result { Ok(Trakt::new( CONF.api .trakt .as_ref() .ok_or(anyhow!("no trakt api key configured"))?, )) } pub async fn import_wrap(db: Database, incremental: bool) -> Result<()> { let _sem = IMPORT_SEM.try_acquire()?; let jh = spawn_blocking(move || { *IMPORT_ERRORS.blocking_write() = Vec::new(); if let Err(e) = import(&db, incremental) { IMPORT_ERRORS.blocking_write().push(format!("{e:#}")); } }); let _ = jh.await; Ok(()) } fn import(db: &Database, incremental: bool) -> Result<()> { let apis = Apis { trakt: CONF.api.trakt.as_ref().map(|key| Trakt::new(key)), tmdb: CONF.api.tmdb.as_ref().map(|key| Tmdb::new(key)), acoustid: CONF.api.acoustid.as_ref().map(|key| AcoustID::new(key)), musicbrainz: MusicBrainz::new(), wikidata: Wikidata::new(), wikimedia_commons: WikimediaCommons::new(), vgmdb: Vgmdb::new(), }; let rthandle = Handle::current(); import_traverse( &CONF.media_path, db, &apis, &rthandle, incremental, NodeID::MIN, "", InheritedFlags { visibility: Visibility::Visible, use_acoustid: false, }, )?; Ok(()) } #[derive(Debug, Clone, Copy)] struct InheritedFlags { visibility: Visibility, use_acoustid: bool, } fn import_traverse( path: &Path, db: &Database, apis: &Apis, rthandle: &Handle, incremental: bool, parent: NodeID, parent_slug_fragment: &str, mut iflags: InheritedFlags, ) -> Result<()> { if path.is_dir() { let slug_fragment = if path == CONF.media_path { "library".to_string() } else { path.file_name().unwrap().to_string_lossy().to_string() }; let slug = if parent_slug_fragment.is_empty() { slug_fragment.clone() } else { format!("{parent_slug_fragment}-{slug_fragment}") }; let id = NodeID::from_slug(&slug); // Some flags need to applied immediatly because they are inherited if let Ok(content) = read_to_string(path.join("flags")) { for flag in content.lines() { match flag.trim() { "hidden" => iflags.visibility = iflags.visibility.min(Visibility::Hidden), "reduced" => iflags.visibility = iflags.visibility.min(Visibility::Reduced), "use_acoustid" => iflags.use_acoustid = true, _ => (), } } } db.update_node_init(id, |n| { if parent != NodeID::MIN { n.parents.insert(parent); } n.slug = slug; n.visibility = iflags.visibility; Ok(()) })?; path.read_dir()?.par_bridge().try_for_each(|e| { let path = e?.path(); if let Err(e) = import_traverse( &path, db, apis, rthandle, incremental, id, &slug_fragment, iflags, ) { 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(); if incremental { if let Some(last_mtime) = db.get_import_file_mtime(path)? { if last_mtime >= mtime { return Ok(()); } } } import_file(db, apis, rthandle, path, parent, iflags)?; db.set_import_file_mtime(path, mtime)?; } Ok(()) } fn import_file( db: &Database, apis: &Apis, rthandle: &Handle, path: &Path, parent: NodeID, iflags: InheritedFlags, ) -> 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:?}"); db.update_node_init(parent, |node| { node.poster = Some(AssetInner::Media(path.to_owned()).ser()); Ok(()) })?; } "backdrop.jpeg" | "backdrop.webp" | "backdrop.png" => { info!("import backdrop at {path:?}"); db.update_node_init(parent, |node| { node.backdrop = Some(AssetInner::Media(path.to_owned()).ser()); Ok(()) })?; } "node.yaml" => { info!("import node info at {path:?}"); let data = serde_yaml::from_str::(&read_to_string(path)?)?; db.update_node_init(parent, |node| { fn merge_option(a: &mut Option, b: Option) { if b.is_some() { *a = b; } } if data.kind != NodeKind::Unknown { node.kind = data.kind; } merge_option(&mut node.title, data.title); merge_option(&mut node.tagline, data.tagline); merge_option(&mut node.description, data.description); merge_option(&mut node.index, data.index); merge_option(&mut node.release_date, data.release_date); node.external_ids.extend(data.external_ids); Ok(()) })?; } "flags" => { let content = read_to_string(path)?; for flag in content.lines() { apply_node_flag(db, rthandle, apis, parent, flag.trim())?; } } "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; } db.update_node_init(NodeID::from_slug(line), |n| { n.slug = line.to_owned(); n.parents.insert(parent); Ok(()) })?; } } "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.external_ids.insert("youtube.channel".to_string(), cid); } if let Some(uid) = data.uploader_id { node.external_ids .insert("youtube.channelname".to_string(), uid); } if let Some(desc) = data.description { node.description = Some(desc); } if let Some(followers) = data.channel_follower_count { node.ratings .insert(Rating::YoutubeFollowers, followers as f64); } Ok(()) })?; } _ => import_media_file(db, apis, rthandle, path, parent, iflags).context("media file")?, } Ok(()) } fn import_media_file( db: &Database, apis: &Apis, rthandle: &Handle, path: &Path, parent: NodeID, iflags: InheritedFlags, ) -> Result<()> { info!("media file {path:?}"); let mut file = File::open(path)?; let Some(container) = detect_container_format(&mut file)? else { return Ok(()); }; eprintln!("{container:?}"); // let infojson = m // .infojson // .map(|d| serde_json::from_slice::(&d)) // .transpose() // .context("infojson parsing")?; // let info = m.info.ok_or(anyhow!("no info"))?; // let tracks = m.tracks.ok_or(anyhow!("no tracks"))?; // let mut tags = m // .tags // .map(|tags| { // tags.tags // .into_iter() // .flat_map(|t| t.simple_tags) // .map(|st| (st.name, st.string.unwrap_or_default())) // .collect::>() // }) // .unwrap_or_default(); // let filename = path.file_name().unwrap().to_string_lossy().to_string(); // let mut episode_index = None; // 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")?; // episode_index = Some((season, episode)) // } // } // 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 node = NodeID::from_slug(&slug); // let meta = path.metadata()?; // let mut eids = BTreeMap::new(); // for (key, value) in &tags { // match key.as_str() { // "MUSICBRAINZ_TRACKID" => { // eids.insert("musicbrainz.recording".to_string(), value.to_owned()) // } // "MUSICBRAINZ_ARTISTID" => { // eids.insert("musicbrainz.artist".to_string(), value.to_owned()) // } // "MUSICBRAINZ_ALBUMID" => { // eids.insert("musicbrainz.release".to_string(), value.to_owned()) // } // "MUSICBRAINZ_ALBUMARTISTID" => { // eids.insert("musicbrainz.albumartist".to_string(), value.to_owned()) // } // "MUSICBRAINZ_RELEASEGROUPID" => { // eids.insert("musicbrainz.releasegroup".to_string(), value.to_owned()) // } // "ISRC" => eids.insert("isrc".to_string(), value.to_owned()), // "BARCODE" => eids.insert("barcode".to_string(), value.to_owned()), // _ => None, // }; // } // if iflags.use_acoustid { // let fp = rthandle.block_on(acoustid_fingerprint(path))?; // if let Some((atid, mbid)) = rthandle.block_on( // apis.acoustid // .as_ref() // .ok_or(anyhow!("need acoustid"))? // .get_atid_mbid(&fp), // )? { // eids.insert("acoustid.track".to_string(), atid); // eids.insert("musicbrainz.recording".to_string(), mbid); // }; // } // let mbrec = eids.get("musicbrainz.recording").cloned(); // db.update_node_init(node, |node| { // node.slug = slug; // node.title = info.title.or(node.title.clone()); // node.visibility = iflags.visibility; // node.poster = m // .cover // .map(|a| AssetInner::Cache(a).ser()) // .or(node.poster.clone()); // 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.external_ids.extend(eids); // 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 = tracks // .entries // .into_iter() // .map(|track| SourceTrack { // codec: track.codec_id, // language: track.language, // name: track.name.unwrap_or_default(), // default_duration: track.default_duration, // federated: Vec::new(), // codec_delay: track.codec_delay, // seek_pre_roll: track.seek_pre_roll, // flag_lacing: track.flag_lacing, // kind: if let Some(video) = track.video { // SourceTrackKind::Video { // width: video.pixel_width, // height: video.pixel_height, // display_width: video.display_width, // display_height: video.display_height, // display_unit: video.display_unit, // 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( // AssetInner::LocalTrack(LocalTrack { // path: path.to_owned(), // track: track.track_number as usize, // }) // .ser(), // ), // }) // .collect::>(); // 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.external_ids // .insert("youtube.video".to_string(), infojson.id); // node.ratings.insert( // Rating::YoutubeViews, // infojson.view_count.unwrap_or_default() as f64, // ); // if let Some(lc) = infojson.like_count { // node.ratings.insert(Rating::YoutubeLikes, lc as f64); // } // } // "Bandcamp" => drop( // node.external_ids // .insert("bandcamp".to_string(), 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( // info.duration.unwrap_or_default() * info.timestamp_scale as f64 * 1e-9, // ), // tracks, // }); // Ok(()) // })?; // if let Some((season, episode)) = episode_index { // let mut trakt_id = None; // let flagspath = path.parent().unwrap().join("flags"); // if flagspath.exists() { // for flag in read_to_string(flagspath)?.lines() { // if let Some(value) = flag.strip_prefix("trakt-").or(flag.strip_prefix("trakt=")) { // let (kind, id) = value.split_once(":").unwrap_or(("", value)); // if kind == "show" { // trakt_id = Some(id.parse::()?); // } // } // } // } // if let Some(trakt_id) = trakt_id { // let trakt = apis.trakt.as_ref().ok_or(anyhow!("trakt required"))?; // let seasons = rthandle.block_on(trakt.show_seasons(trakt_id))?; // if seasons.iter().any(|x| x.number == season) { // let episodes = rthandle.block_on(trakt.show_season_episodes(trakt_id, season))?; // let mut poster = None; // if let Some(tmdb) = &apis.tmdb { // let trakt_details = // rthandle.block_on(trakt.lookup(TraktKind::Show, trakt_id))?; // if let Some(tmdb_id) = trakt_details.ids.tmdb { // let tmdb_details = // rthandle.block_on(tmdb.episode_details(tmdb_id, season, episode))?; // if let Some(still) = &tmdb_details.still_path { // poster = // Some(AssetInner::Cache(rthandle.block_on(tmdb.image(still))?).ser()) // } // } // } // if let Some(episode) = episodes.get(episode.saturating_sub(1)) { // db.update_node_init(node, |node| { // node.kind = NodeKind::Episode; // node.index = Some(episode.number); // node.title = Some(episode.title.clone()); // node.poster = poster.or(node.poster.clone()); // node.description = episode.overview.clone().or(node.description.clone()); // node.ratings.insert(Rating::Trakt, episode.rating); // Ok(()) // })? // } // } // } // } // 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(()) } fn apply_node_flag( db: &Database, rthandle: &Handle, apis: &Apis, 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())? } Ok(()) } fn apply_musicbrainz_recording( db: &Database, rthandle: &Handle, apis: &Apis, node: NodeID, mbid: String, ) -> Result<()> { let rec = rthandle.block_on(apis.musicbrainz.lookup_recording(mbid))?; db.update_node_init(node, |node| { node.title = Some(rec.title.clone()); node.external_ids .insert("musicbrainz.recording".to_string(), rec.id.to_string()); if let Some(a) = rec.artist_credit.get(0) { node.subtitle = Some(a.artist.name.clone()); node.external_ids .insert("musicbrainz.artist".to_string(), a.artist.id.to_string()); } // // TODO proper dedup // node.people.clear(); for rel in &rec.relations { use musicbrainz::reltypes::*; let a = match rel.type_id.as_str() { INSTRUMENT => Some(("", PeopleGroup::Instrument)), VOCAL => Some(("", PeopleGroup::Vocal)), PRODUCER => Some(("", PeopleGroup::Producer)), MIX => Some(("mix ", PeopleGroup::Engineer)), PHONOGRAPHIC_COPYRIGHT => Some(("phonographic copyright ", PeopleGroup::Engineer)), PROGRAMMING => Some(("programming ", PeopleGroup::Engineer)), _ => None, }; if let Some((note, group)) = a { let artist = rel.artist.as_ref().unwrap(); let artist = rthandle.block_on(apis.musicbrainz.lookup_artist(artist.id.clone()))?; let mut image_1 = None; let mut image_2 = None; for rel in &artist.relations { match rel.type_id.as_str() { WIKIDATA => { let url = rel.url.as_ref().unwrap().resource.clone(); if let Some(id) = url.strip_prefix("https://www.wikidata.org/wiki/") { if let Some(filename) = rthandle .block_on(apis.wikidata.query_image_path(id.to_owned()))? { let path = rthandle.block_on( apis.wikimedia_commons.image_by_filename(filename), )?; image_1 = Some(AssetInner::Cache(path).ser()); } } } VGMDB => { let url = rel.url.as_ref().unwrap().resource.clone(); if let Some(id) = url.strip_prefix("https://vgmdb.net/artist/") { let id = id.parse::().context("parse vgmdb id")?; if let Some(path) = rthandle.block_on(apis.vgmdb.get_artist_image(id))? { image_2 = Some(AssetInner::Cache(path).ser()); } } } _ => (), } } let mut jobs = vec![]; if !note.is_empty() { jobs.push(note.to_string()); } jobs.extend(rel.attributes.clone()); let headshot = match image_1.or(image_2) { Some(x) => x, None => AssetInner::Cache(cache_file( "person-headshot-fallback", &artist.sort_name, |mut file| { generate_fallback(&artist.sort_name, &mut file)?; Ok(()) }, )?) .ser(), }; node.people.entry(group).or_default().push(Appearance { jobs, characters: vec![], person: Person { name: if rel.target_credit.is_empty() { artist.name.clone() } else { rel.target_credit.clone() }, headshot: Some(headshot), ids: ObjectIds::default(), }, }); } } for isrc in &rec.isrcs { node.external_ids .insert("isrc".to_string(), isrc.to_string()); } Ok(()) })?; Ok(()) } fn apply_trakt_tmdb( db: &Database, rthandle: &Handle, apis: &Apis, node: NodeID, trakt_kind: TraktKind, trakt_id: &str, ) -> Result<()> { let trakt_id: u64 = trakt_id.parse().context("parse trakt id")?; if let (Some(trakt), Some(tmdb)) = (&apis.trakt, &apis.tmdb) { let data = rthandle .block_on(trakt.lookup(trakt_kind, trakt_id)) .context("trakt lookup")?; let people = rthandle .block_on(trakt.people(trakt_kind, trakt_id)) .context("trakt people lookup")?; let mut people_map = BTreeMap::>::new(); for p in people.cast.iter() { people_map.entry(PeopleGroup::Cast).or_default().push(p.a()) } for (group, people) in people.crew.iter() { for p in people { people_map.entry(group.a()).or_default().push(p.a()) } } let mut tmdb_data = None; let mut backdrop = None; let mut poster = None; if let Some(tmdb_id) = data.ids.tmdb { let data = rthandle .block_on(tmdb.details( match trakt_kind { TraktKind::Movie => TmdbKind::Movie, TraktKind::Show => TmdbKind::Tv, _ => TmdbKind::Movie, }, tmdb_id, )) .context("tmdb details")?; tmdb_data = Some(data.clone()); if let Some(path) = &data.backdrop_path { let im = rthandle .block_on(tmdb.image(path)) .context("tmdb backdrop image")?; backdrop = Some(AssetInner::Cache(im).ser()); } if let Some(path) = &data.poster_path { let im = rthandle .block_on(tmdb.image(path)) .context("tmdb poster image")?; poster = Some(AssetInner::Cache(im).ser()); } for p in people_map.values_mut().flatten() { if let Some(id) = p.person.ids.tmdb { let k = rthandle.block_on(tmdb.person_image(id))?; if let Some(prof) = k.profiles.first() { let im = rthandle.block_on(tmdb.image(&prof.file_path))?; p.person.headshot = Some(AssetInner::Cache(im).ser()); } } } } db.update_node_init(node, |node| { node.title = Some(data.title.clone()); node.people.extend(people_map); node.kind = match trakt_kind { TraktKind::Movie => NodeKind::Movie, TraktKind::Show => NodeKind::Show, TraktKind::Season => NodeKind::Season, TraktKind::Episode => NodeKind::Episode, TraktKind::Person => NodeKind::Channel, TraktKind::User => NodeKind::Channel, }; if let Some(overview) = &data.overview { node.description = Some(overview.clone()) } if let Some(tagline) = &data.tagline { node.tagline = Some(tagline.clone()) } if let Some(rating) = &data.rating { node.ratings.insert(Rating::Trakt, *rating); } if let Some(poster) = poster { node.poster = Some(poster); } if let Some(backdrop) = backdrop { node.backdrop = Some(backdrop); } if let Some(data) = tmdb_data { node.title = data.title.clone().or(node.title.clone()); node.tagline = data.tagline.clone().or(node.tagline.clone()); node.description = Some(data.overview.clone()); node.ratings.insert(Rating::Tmdb, data.vote_average); if let Some(date) = data.release_date.clone() { if let Ok(date) = tmdb::parse_release_date(&date) { node.release_date = date; } } } Ok(()) })?; } Ok(()) } fn make_kebab(i: &str) -> String { let mut o = String::with_capacity(i.len()); for c in i.chars() { o.extend(match c { 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' => Some(c), ' ' => Some('-'), _ => None, }); } 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, } }