aboutsummaryrefslogtreecommitdiff
path: root/import
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2025-12-10 16:21:38 +0100
committermetamuffin <metamuffin@disroot.org>2025-12-10 16:21:38 +0100
commita0cfd77b4d19c43a28c4d82072e6ff136e336af3 (patch)
tree05df9f5faa54cef0ae4136fffddea57fbbafee6b /import
parent242d5763d451eed2402be7afde50cd9fa0d6bc79 (diff)
downloadjellything-a0cfd77b4d19c43a28c4d82072e6ff136e336af3.tar
jellything-a0cfd77b4d19c43a28c4d82072e6ff136e336af3.tar.bz2
jellything-a0cfd77b4d19c43a28c4d82072e6ff136e336af3.tar.zst
refactor import plugins part 1
Diffstat (limited to 'import')
-rw-r--r--import/Cargo.toml2
-rw-r--r--import/src/infojson.rs146
-rw-r--r--import/src/lib.rs473
-rw-r--r--import/src/plugins/acoustid.rs (renamed from import/src/acoustid.rs)28
-rw-r--r--import/src/plugins/infojson.rs272
-rw-r--r--import/src/plugins/media_info.rs92
-rw-r--r--import/src/plugins/misc.rs100
-rw-r--r--import/src/plugins/mod.rs48
-rw-r--r--import/src/plugins/musicbrainz.rs (renamed from import/src/musicbrainz.rs)8
-rw-r--r--import/src/plugins/tags.rs60
-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