/* 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; 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 jellycommon::{ Appearance, Asset, Chapter, CreditCategory, IdentifierType, MediaInfo, Node, NodeID, NodeKind, PictureSlot, RatingType, SourceTrack, SourceTrackKind, TrackSource, Visibility, }; use jellyimport_fallback_generator::generate_fallback; use jellyremuxer::{ demuxers::create_demuxer_autodetect, matroska::{self, Segment}, }; 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, Read}, 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)] 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: &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:?}"); 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::(&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); 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.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")?, } Ok(()) } pub fn read_media_metadata(path: &Path) -> Result> { cache_memory( &format!("media/metadata/{}.json", HashKey(path)), move || { let media = File::open(path)?; let mut media = create_demuxer_autodetect(Box::new(media))? .ok_or(anyhow!("media format unknown"))?; let info = media.info()?; let tracks = media.tracks()?; let tags = media.tags()?; let mut attachments = media.attachments()?; let chapters = media.chapters()?; // 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) { let key = cache_store( format!("media/attachment/{}-{fname}", HashKey(path)), || Ok(att.data.clone()), )?; att.data = key.as_bytes().to_vec(); } else { att.data.clear(); } } } Ok(Segment { info, tracks, tags: tags.into_iter().collect(), attachments, chapters, ..Default::default() }) }, ) } 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/") } } fn import_media_file( db: &Database, apis: &Apis, rthandle: &Handle, path: &Path, parent: NodeID, iflags: InheritedFlags, ) -> Result<()> { 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::(&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::>() }) .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(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::>(); 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(()) })?; 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 = trakt.show_seasons(trakt_id, rthandle)?; if seasons.iter().any(|x| x.number == season) { let episodes = trakt.show_season_episodes(trakt_id, season, rthandle)?; let mut poster = None; if let Some(tmdb) = &apis.tmdb { let trakt_details = trakt.lookup(TraktKind::Show, trakt_id, rthandle)?; if let Some(tmdb_id) = trakt_details.ids.tmdb { let tmdb_details = tmdb.episode_details(tmdb_id, season, episode, rthandle)?; if let Some(still) = &tmdb_details.still_path { poster = Some(tmdb.image(still, rthandle)?) } } } 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()); if let Some(poster) = poster { node.pictures.insert(PictureSlot::Cover, poster); } node.description = episode.overview.clone().or(node.description.clone()); node.ratings.insert(RatingType::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 = apis.musicbrainz.lookup_recording(mbid, rthandle)?; db.update_node_init(node, |node| { node.title = Some(rec.title.clone()); node.identifiers .insert(IdentifierType::MusicbrainzRecording, rec.id.to_string()); if let Some(a) = rec.artist_credit.first() { node.subtitle = Some(a.artist.name.clone()); node.identifiers .insert(IdentifierType::MusicbrainzArtist, 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(("", CreditCategory::Instrument)), VOCAL => Some(("", CreditCategory::Vocal)), PRODUCER => Some(("", CreditCategory::Producer)), MIX => Some(("mix ", CreditCategory::Engineer)), PHONOGRAPHIC_COPYRIGHT => { Some(("phonographic copyright ", CreditCategory::Engineer)) } PROGRAMMING => Some(("programming ", CreditCategory::Engineer)), _ => None, }; if let Some((note, group)) = a { let artist = rel.artist.as_ref().unwrap(); let artist = apis .musicbrainz .lookup_artist(artist.id.clone(), rthandle)?; 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) = apis.wikidata.query_image_path(id.to_owned(), rthandle)? { image_1 = Some( apis.wikimedia_commons .image_by_filename(filename, rthandle)?, ); } } } 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) = apis.vgmdb.get_artist_image(id, rthandle)? { image_2 = Some(path); } } } _ => (), } } 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 => Asset(cache_store( format!("fallback/{}.image", HashKey(&artist.sort_name)), || generate_fallback(&artist.sort_name), )?), }; node.credits.entry(group).or_default().push(Appearance { jobs, characters: vec![], node: NodeID([0; 32]), // TODO }); } } for isrc in &rec.isrcs { node.identifiers .insert(IdentifierType::Isrc, 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 = trakt.lookup(trakt_kind, trakt_id, rthandle)?; let people = trakt.people(trakt_kind, trakt_id, rthandle)?; let mut people_map = BTreeMap::>::new(); for p in people.cast.iter() { people_map .entry(CreditCategory::Cast) .or_default() .push(p.a()) } for (group, people) in people.crew.iter() { for p in people { people_map .entry(group.as_credit_category()) .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 = tmdb.details( match trakt_kind { TraktKind::Movie => TmdbKind::Movie, TraktKind::Show => TmdbKind::Tv, _ => TmdbKind::Movie, }, tmdb_id, rthandle, )?; tmdb_data = Some(data.clone()); if let Some(path) = &data.backdrop_path { backdrop = Some(tmdb.image(path, rthandle).context("tmdb backdrop image")?); } if let Some(path) = &data.poster_path { poster = Some(tmdb.image(path, rthandle).context("tmdb poster image")?); } // 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.credits.extend(people_map); node.kind = trakt_kind.as_node_kind(); 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(RatingType::Trakt, *rating); } if let Some(poster) = poster { node.pictures.insert(PictureSlot::Cover, poster); } if let Some(backdrop) = backdrop { node.pictures.insert(PictureSlot::Backdrop, 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(RatingType::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, } }