aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2025-02-02 16:02:42 +0100
committermetamuffin <metamuffin@disroot.org>2025-02-02 16:02:42 +0100
commit4d3ec68b9cbac493ee76981527cb0e780fac9432 (patch)
tree3f0df02f4c1a119e70663e0e3b4a485d81bc92b3
parent64c962b50d4fbd4605087fc97eac1a032bb826ce (diff)
downloadjellything-4d3ec68b9cbac493ee76981527cb0e780fac9432.tar
jellything-4d3ec68b9cbac493ee76981527cb0e780fac9432.tar.bz2
jellything-4d3ec68b9cbac493ee76981527cb0e780fac9432.tar.zst
trakt metadata source
-rw-r--r--Cargo.lock30
-rw-r--r--common/src/lib.rs2
-rw-r--r--import/src/lib.rs174
-rw-r--r--tool/src/add.rs224
-rw-r--r--tool/src/bin/generate_completions.rs5
-rw-r--r--tool/src/cli.rs16
6 files changed, 222 insertions, 229 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 863b2fe..bc90711 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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 {