diff options
author | metamuffin <metamuffin@disroot.org> | 2025-02-02 16:02:42 +0100 |
---|---|---|
committer | metamuffin <metamuffin@disroot.org> | 2025-02-02 16:02:42 +0100 |
commit | 4d3ec68b9cbac493ee76981527cb0e780fac9432 (patch) | |
tree | 3f0df02f4c1a119e70663e0e3b4a485d81bc92b3 | |
parent | 64c962b50d4fbd4605087fc97eac1a032bb826ce (diff) | |
download | jellything-4d3ec68b9cbac493ee76981527cb0e780fac9432.tar jellything-4d3ec68b9cbac493ee76981527cb0e780fac9432.tar.bz2 jellything-4d3ec68b9cbac493ee76981527cb0e780fac9432.tar.zst |
trakt metadata source
-rw-r--r-- | Cargo.lock | 30 | ||||
-rw-r--r-- | common/src/lib.rs | 2 | ||||
-rw-r--r-- | import/src/lib.rs | 174 | ||||
-rw-r--r-- | tool/src/add.rs | 224 | ||||
-rw-r--r-- | tool/src/bin/generate_completions.rs | 5 | ||||
-rw-r--r-- | tool/src/cli.rs | 16 |
6 files changed, 222 insertions, 229 deletions
@@ -241,9 +241,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.85" +version = "0.1.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f934833b4b7233644e5848f235df3f57ed8c80f1528a26c3dfa13d2147fa056" +checksum = "644dd749086bf3771a2fbc5f256fdb982d53f011c7d5d560304eafeecebce79d" dependencies = [ "proc-macro2", "quote", @@ -448,9 +448,9 @@ checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "cc" -version = "1.2.10" +version = "1.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13208fcbb66eaeffe09b99fffbe1af420f00a7b35aa99ad683dfc1aa76145229" +checksum = "e4730490333d58093109dc02c23174c3f4d490998c3fed3cc8e82d57afedb9cf" dependencies = [ "jobserver", "libc", @@ -804,7 +804,7 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] name = "ebml-struct" version = "0.1.0" -source = "git+https://codeberg.org/metamuffin/ebml-struct#baa1f77aea4accf7a6046bf6b60275e5d942d816" +source = "git+https://codeberg.org/metamuffin/ebml-struct#fcefaa67b85b96b17cec2d1c7f7c53998520559b" dependencies = [ "bincode", ] @@ -3081,9 +3081,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.21" +version = "0.23.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f287924602bf649d949c63dc8ac8b235fa5387d394020705b80c4eb597ce5b8" +checksum = "9fb9263ab4eb695e42321db096e3b8fbd715a59b154d5c88d82db2175b681ba7" dependencies = [ "once_cell", "ring", @@ -3366,9 +3366,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.96" +version = "2.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80" +checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" dependencies = [ "proc-macro2", "quote", @@ -3784,9 +3784,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.22" +version = "0.22.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +checksum = "02a8b472d1a3d7c18e2d61a489aee3453fd9031c33e4f55bd533f4a7adca1bee" dependencies = [ "indexmap", "serde", @@ -4192,9 +4192,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.7" +version = "0.26.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d642ff16b7e79272ae451b7322067cdc17cadf68c23264be9d94a32319efe7e" +checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9" dependencies = [ "rustls-pki-types", ] @@ -4416,9 +4416,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.6.25" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad699df48212c6cc6eb4435f35500ac6fd3b9913324f938aea302022ce19d310" +checksum = "7e49d2d35d3fad69b39b94139037ecfb4f359f08958b9c11e7315ce770462419" dependencies = [ "memchr", ] diff --git a/common/src/lib.rs b/common/src/lib.rs index 46d543d..4b67054 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -217,7 +217,7 @@ pub enum SourceTrackKind { Subtitles, } -#[derive(Debug, Serialize, Deserialize, Clone, Copy, Encode, Decode)] +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Encode, Decode)] #[serde(rename_all = "snake_case")] pub enum TraktKind { Movie, diff --git a/import/src/lib.rs b/import/src/lib.rs index c70d357..4d39565 100644 --- a/import/src/lib.rs +++ b/import/src/lib.rs @@ -14,12 +14,12 @@ use jellybase::{ database::Database, CONF, SECRETS, }; -use jellyclient::Visibility; +use jellyclient::{Appearance, PeopleGroup, TmdbKind, TraktKind, Visibility}; use log::warn; use matroska::matroska_metadata; use rayon::iter::{ParallelBridge, ParallelIterator}; use std::{ - collections::HashMap, + collections::{BTreeMap, HashMap}, fs::{read_to_string, File}, io::BufReader, path::Path, @@ -28,6 +28,7 @@ use std::{ }; use tmdb::Tmdb; use tokio::{ + runtime::Handle, sync::{RwLock, Semaphore}, task::spawn_blocking, }; @@ -73,11 +74,14 @@ fn import(db: &Database, incremental: bool) -> Result<()> { trakt: SECRETS.api.trakt.as_ref().map(|key| Trakt::new(key)), tmdb: SECRETS.api.tmdb.as_ref().map(|key| Tmdb::new(key)), }; - drop((apis.tmdb, apis.trakt)); + + let rthandle = Handle::current(); import_traverse( &CONF.media_path, db, + &apis, + &rthandle, incremental, NodeID::MIN, "", @@ -90,6 +94,8 @@ fn import(db: &Database, incremental: bool) -> Result<()> { fn import_traverse( path: &Path, db: &Database, + apis: &Apis, + rthandle: &Handle, incremental: bool, parent: NodeID, parent_slug_fragment: &str, @@ -127,8 +133,16 @@ fn import_traverse( path.read_dir()?.par_bridge().try_for_each(|e| { let path = e?.path(); - if let Err(e) = import_traverse(&path, db, incremental, id, &slug_fragment, visibility) - { + if let Err(e) = import_traverse( + &path, + db, + apis, + rthandle, + incremental, + id, + &slug_fragment, + visibility, + ) { IMPORT_ERRORS.blocking_write().push(format!("{e:#}")); } Ok::<_, anyhow::Error>(()) @@ -147,13 +161,20 @@ fn import_traverse( } } - import_file(&db, &path, parent, visibility).context(anyhow!("{path:?}"))?; + import_file(&db, apis, rthandle, &path, parent, visibility).context(anyhow!("{path:?}"))?; db.set_import_file_mtime(&path, mtime)?; } return Ok(()); } -fn import_file(db: &Database, path: &Path, parent: NodeID, visibility: Visibility) -> Result<()> { +fn import_file( + db: &Database, + apis: &Apis, + rthandle: &Handle, + path: &Path, + parent: NodeID, + visibility: Visibility, +) -> Result<()> { let filename = path.file_name().unwrap().to_string_lossy(); match filename.as_ref() { "poster.jpeg" | "poster.webp" | "poster.png" => { @@ -215,7 +236,9 @@ fn import_file(db: &Database, path: &Path, parent: NodeID, visibility: Visibilit Ok(()) })?; } - _ => import_media_file(db, path, parent, visibility).context("media file")?, + _ => { + import_media_file(db, apis, rthandle, path, parent, visibility).context("media file")? + } } Ok(()) @@ -223,6 +246,8 @@ fn import_file(db: &Database, path: &Path, parent: NodeID, visibility: Visibilit fn import_media_file( db: &Database, + apis: &Apis, + rthandle: &Handle, path: &Path, parent: NodeID, visibility: Visibility, @@ -245,12 +270,74 @@ fn import_media_file( }) .unwrap_or_default(); - let filepath_stem = path - .file_stem() + let filename = path + .file_name() .ok_or(anyhow!("no file stem"))? .to_string_lossy() .to_string(); + let mut backdrop = None; + let mut poster = None; + let mut trakt_data = None; + let mut tmdb_data = None; + + let mut filename_toks = filename.split("."); + let filepath_stem = filename_toks.next().unwrap(); + for tok in filename_toks { + if let Some(trakt_id) = tok.strip_prefix("trakt-") { + 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(TraktKind::Movie, trakt_id, true)) + .context("trakt lookup")?; + let people = rthandle + .block_on(trakt.people(TraktKind::Movie, trakt_id, true)) + .context("trakt people lookup")?; + + let mut people_map = BTreeMap::<PeopleGroup, Vec<Appearance>>::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()) + } + } + + if let Some(tmdb_id) = data.ids.tmdb { + let data = rthandle + .block_on(tmdb.details(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()); + } + } + } + } + trakt_data = Some((data.clone(), people_map)); + } + } + } + let slug = m .infojson .as_ref() @@ -261,11 +348,35 @@ fn import_media_file( node.slug = slug; node.title = info.title; node.visibility = visibility; - node.poster = m.cover.clone(); + node.poster = m.cover.clone().or(poster); + node.backdrop = backdrop; node.description = tags.remove("DESCRIPTION"); node.tagline = tags.remove("COMMENT"); node.parents.insert(parent); + if let Some(data) = tmdb_data { + node.title = data.title.clone(); + node.tagline = data.tagline.clone(); + node.description = Some(data.overview.clone()); + node.ratings.insert(Rating::Tmdb, data.vote_average); + if let Some(date) = data.release_date.clone() { + node.release_date = tmdb::parse_release_date(&date)?; + } + } + if let Some((data, people)) = trakt_data { + node.title = Some(data.title.clone()); + 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); + } + node.people.extend(people); + } + let tracks = tracks .entries .into_iter() @@ -303,47 +414,6 @@ fn import_media_file( }) .collect::<Vec<_>>(); - if let Some(infojson) = m.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); - 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), - ), - _ => (), - } - } node.media = Some(MediaInfo { chapters: m .chapters diff --git a/tool/src/add.rs b/tool/src/add.rs index fdaa14e..6e79381 100644 --- a/tool/src/add.rs +++ b/tool/src/add.rs @@ -1,7 +1,12 @@ +/* + 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::cli::Action; -use anyhow::{anyhow, bail, Context}; -use dialoguer::{theme::ColorfulTheme, FuzzySelect, Input, MultiSelect}; -use jellybase::{CONF, SECRETS}; +use anyhow::anyhow; +use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, Input}; +use jellybase::SECRETS; use jellycommon::TraktKind; use jellyimport::trakt::Trakt; use log::warn; @@ -9,76 +14,35 @@ use std::{ fmt::Display, path::{Path, PathBuf}, }; +use tokio::fs::rename; pub async fn add(action: Action) -> anyhow::Result<()> { match action { - Action::Add { - id, - media, - library_path, - } => { + Action::Add { media } => { let theme = ColorfulTheme::default(); - let possible_kinds = [ - TraktKind::Movie, - TraktKind::Season, - TraktKind::Show, - TraktKind::Episode, - ]; - let trakt_kind: Vec<usize> = MultiSelect::with_theme(&theme) - .with_prompt("Media Kind") - .items(&possible_kinds) - .defaults(&[true, false, false, false]) - .interact() - .unwrap(); - let search_kinds = trakt_kind - .iter() - .map(|&i| possible_kinds[i]) - .collect::<Vec<_>>(); - - let library_path = if let Some(library_path) = library_path { - library_path - } else { - let mut directories = Vec::new(); - find_folders(&CONF.media_path, &PathBuf::new(), &mut directories) - .context("listing library directories")?; - - let mut default = 0; - for k in possible_kinds { - match k { - TraktKind::Movie => { - if let Some(i) = directories - .iter() - .position(|d| d.0.to_str().unwrap().contains("movies")) - { - default = i - }; - } - TraktKind::Show => { - if let Some(i) = directories - .iter() - .position(|d| d.0.to_str().unwrap().contains("shows")) - { - default = i - }; - } - _ => (), - } - } - - let target_dir_index = FuzzySelect::with_theme(&theme) - .items(&directories) - .default(default) - .with_prompt("Library Path") - .interact() - .unwrap(); - directories[target_dir_index].0.clone() - }; + // let possible_kinds = [ + // TraktKind::Movie, + // TraktKind::Season, + // TraktKind::Show, + // TraktKind::Episode, + // ]; + // let trakt_kind: Vec<usize> = MultiSelect::with_theme(&theme) + // .with_prompt("Media Kind") + // .items(&possible_kinds) + // .defaults(&[true, false, false, false]) + // .interact() + // .unwrap(); + // let search_kinds = trakt_kind + // .iter() + // .map(|&i| possible_kinds[i]) + // .collect::<Vec<_>>(); + let search_kinds = [TraktKind::Show, TraktKind::Season, TraktKind::Movie]; - let (last_search, trakt_object, trakt_kind) = loop { + let (trakt_object, trakt_kind) = loop { let name: String = Input::with_theme(&theme) .with_prompt("Search by title") - .default(media.as_ref().map(|p| path_to_query(p)).unwrap_or_default()) + .default(path_to_query(&media)) .interact_text() .unwrap(); @@ -105,60 +69,32 @@ pub async fn add(action: Action) -> anyhow::Result<()> { .unwrap(); if let Some(o) = correct { - break (name, results[o].inner.inner().to_owned(), results[o].r#type); + break (results[o].inner.inner().to_owned(), results[o].r#type); } }; - let id = id.unwrap_or_else(|| { - trakt_object.ids.slug.unwrap_or_else(|| { - let o: String = Input::with_theme(&theme) - .with_prompt("Node ID") - .validate_with(validate_id) - .default(make_id(&last_search)) - .interact_text() - .unwrap(); - o - }) - }); - - // TODO - let _ = id; - let _ = library_path; - let _ = trakt_kind; - - // let mut sources = Vec::new(); - // sources.push(ImportSource::Trakt { - // id: trakt_object.ids.trakt.unwrap(), - // kind: trakt_kind, - // }); - // if let Some(media) = media { - // sources.push(ImportSource::Media { - // path: media, - // ignore_metadata: true, - // ignore_attachments: false, - // ignore_chapters: false, - // }) - // } - - // let impo = ImportOptions { id, sources }; + assert_eq!(trakt_kind, TraktKind::Movie); - // let ypath = CONF - // .library_path - // .join(library_path) - // .join(&impo.id) - // .with_extension("yaml"); + let stem = media.file_name().unwrap().to_string_lossy().to_string(); + let stem = stem.split_once(".").unwrap_or((stem.as_str(), "")).0; + let mut newpath = media.parent().unwrap().join(format!( + "{stem}.trakt-{}.mkv", + trakt_object.ids.trakt.unwrap() + )); + let mut n = 1; + while newpath.exists() { + newpath = media.parent().unwrap().join(format!("{stem}.alt-{n}.mkv",)); + n += 1; + } - // if Confirm::with_theme(&theme) - // .with_prompt(format!("Write {:?}?", ypath)) - // .default(true) - // .interact() - // .unwrap() - // { - // File::create(ypath) - // .await? - // .write_all(serde_yaml::to_string(&impo)?.as_bytes()) - // .await?; - // } + if Confirm::with_theme(&theme) + .with_prompt(format!("Rename {media:?} -> {newpath:?}?")) + .default(true) + .interact() + .unwrap() + { + rename(media, newpath).await?; + } Ok(()) } @@ -166,46 +102,30 @@ pub async fn add(action: Action) -> anyhow::Result<()> { } } -fn validate_id(s: &String) -> anyhow::Result<()> { - if &make_id(s) == s { - Ok(()) - } else { - bail!("invalid id") - } -} -fn make_id(s: &str) -> String { - let mut out = String::new(); - for s in s.chars() { - match s { - 'a'..='z' | '0'..='9' => out.push(s), - 'A'..='Z' => out.push(s.to_ascii_lowercase()), - '-' | ' ' | '_' | ':' => out.push('-'), - _ => (), - } - } - out -} +// fn validate_id(s: &String) -> anyhow::Result<()> { +// if &make_id(s) == s { +// Ok(()) +// } else { +// bail!("invalid id") +// } +// } +// fn make_id(s: &str) -> String { +// let mut out = String::new(); +// for s in s.chars() { +// match s { +// 'a'..='z' | '0'..='9' => out.push(s), +// 'A'..='Z' => out.push(s.to_ascii_lowercase()), +// '-' | ' ' | '_' | ':' => out.push('-'), +// _ => (), +// } +// } +// out +// } fn path_to_query(path: &Path) -> String { - path.file_stem() - .unwrap() - .to_str() - .unwrap() - .to_string() - .replace("-", " ") - .replace(".", " ") -} - -fn find_folders(base: &Path, path: &Path, out: &mut Vec<PathDisplay>) -> anyhow::Result<()> { - out.push(PathDisplay(path.to_owned())); - for entry in base.join(path).read_dir()? { - let entry = entry?; - let child_path = path.join(entry.file_name()); - if entry.path().is_dir() { - find_folders(base, &child_path, out)?; - } - } - Ok(()) + let stem = path.file_name().unwrap().to_string_lossy().to_string(); + let stem = stem.split_once(".").unwrap_or((stem.as_str(), "")).0; + stem.replace("-", " ") } pub struct PathDisplay(PathBuf); diff --git a/tool/src/bin/generate_completions.rs b/tool/src/bin/generate_completions.rs index 9f0917f..1afce29 100644 --- a/tool/src/bin/generate_completions.rs +++ b/tool/src/bin/generate_completions.rs @@ -1,3 +1,8 @@ +/* + 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 clap::{CommandFactory, Parser, ValueEnum}; use clap_complete::{generate_to, Shell}; use jellytool::cli; diff --git a/tool/src/cli.rs b/tool/src/cli.rs index 2e12c81..b51b135 100644 --- a/tool/src/cli.rs +++ b/tool/src/cli.rs @@ -1,3 +1,8 @@ +/* + 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 clap::{arg, Parser, ValueEnum}; use std::path::PathBuf; @@ -7,17 +12,10 @@ pub type Args = Action; #[clap(version, about)] /// Tool for administering a Jellything instance pub enum Action { - /// Interactive wizard for adding new nodes + /// Interactive wizard for renaming files Add { - /// ID of the new node; inferred if not specified - #[arg(short, long)] - id: Option<String>, /// Path to the media of this node. - #[arg(short, long)] - media: Option<PathBuf>, - /// Path of the new node within the library - #[arg(short, long)] - library_path: Option<PathBuf>, + media: PathBuf, }, /// Migrate the database by export or import to JSON Migrate { |