/* 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) 2026 metamuffin */ use crate::plugins::{ImportPlugin, PluginContext, PluginInfo}; use anyhow::{Context, Result, anyhow}; use chrono::{Utc, format::Parsed}; use jellycommon::*; use jellydb::table::RowNum; use jellyremuxer::matroska::{AttachedFile, Segment}; use log::info; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, fs::File, io::BufReader, path::Path}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct YVideo { pub album: Option, pub age_limit: Option, pub alt_title: Option, pub aspect_ratio: Option, pub automatic_captions: Option>>, pub availability: Option, // "public" | "private" | "unlisted", pub average_rating: Option, pub categories: Option>, pub channel_follower_count: Option, pub channel_id: Option, pub channel_is_verified: Option, pub channel: Option, pub chapters: Option>, pub comment_count: Option, pub description: Option, pub display_id: Option, pub duration_string: Option, pub duration: Option, pub epoch: usize, pub extractor_key: String, pub extractor: String, pub formats: Option>, pub fulltitle: Option, pub heatmap: Option>, pub height: Option, pub id: String, pub is_live: Option, pub like_count: Option, pub media_type: Option, pub n_entries: Option, pub original_url: Option, pub playable_in_embed: Option, pub playlist_count: Option, pub playlist_id: Option, pub playlist_index: Option, pub playlist_title: Option, pub playlist_uploader_id: Option, pub playlist_uploader: Option, pub playlist: Option, pub tags: Option>, pub thumbnail: Option, pub thumbnails: Option>, pub title: String, pub upload_date: Option, pub uploader_id: Option, pub uploader_url: Option, pub uploader: Option, pub view_count: Option, pub was_live: Option, pub webpage_url_basename: String, pub webpage_url_domain: String, pub webpage_url: String, pub width: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct YCaption { pub url: Option, pub ext: String, //"vtt" | "json3" | "srv1" | "srv2" | "srv3" | "ttml", pub protocol: Option, pub name: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct YFormat { pub format_id: String, pub format_note: Option, pub ext: String, pub protocol: String, pub acodec: Option, pub vcodec: Option, pub url: Option, pub width: Option, pub height: Option, pub fps: Option, pub columns: Option, pub fragments: Option>, pub resolution: Option, pub dynamic_range: Option, pub aspect_ratio: Option, pub http_headers: HashMap, pub audio_ext: String, pub video_ext: String, pub vbr: Option, pub abr: Option, pub format: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct YFragment { pub url: Option, pub duration: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct YThumbnail { pub url: String, pub preference: Option, pub id: String, pub height: Option, pub width: Option, pub resolution: Option, } #[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 { 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 info(&self) -> PluginInfo { PluginInfo { name: "infojson", handle_file: true, handle_media: true, ..Default::default() } } fn file(&self, ct: &PluginContext, parent: RowNum, 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)?))?; let title = clean_uploader_name(&data.title); ct.dba.db.write_transaction(&mut |txn| { let mut node = ct.dba.nodes.get(txn, parent)?.unwrap(); node = node.as_object().insert(NO_KIND, KIND_CHANNEL); node = node.as_object().insert(NO_TITLE, title); if let Some(cid) = &data.channel_id { node = node.as_object().update(NO_IDENTIFIERS, |ids| { ids.insert(IDENT_YOUTUBE_CHANNEL, &cid) }); } if let Some(uid) = &data.uploader_id { node = node.as_object().update(NO_IDENTIFIERS, |ids| { ids.insert(IDENT_YOUTUBE_CHANNEL_HANDLE, &uid) }) } if let Some(desc) = &data.description { node = node.as_object().insert(NO_DESCRIPTION, &desc); } if let Some(followers) = data.channel_follower_count { node = node.as_object().update(NO_RATINGS, |rat| { rat.insert(RTYP_YOUTUBE_FOLLOWERS, followers as f64) }); } ct.dba.nodes.update(txn, parent, node) }) } fn media(&self, ct: &PluginContext, row: RowNum, _path: &Path, seg: &Segment) -> Result<()> { let infojson = seg .attachments .iter() .flat_map(|a| &a.files) .find(is_info_json) .map(|att| { let data = ct .dba .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")?; if let Some(infojson) = infojson { let release_date = infojson .upload_date .as_ref() .map(|date| parse_upload_date(date).context("parsing upload date")) .transpose()?; let kind = if let Some(ty) = &infojson.media_type && ty == "short" { KIND_SHORTFORMVIDEO } else if infojson.album.is_some() { KIND_MUSIC } else { KIND_VIDEO }; ct.dba.db.write_transaction(&mut |txn| { let mut node = ct.dba.nodes.get(txn, row)?.unwrap(); node = node.as_object().insert(NO_KIND, kind); node = node.as_object().insert(NO_TITLE, &infojson.title); if let Some(title) = &infojson.alt_title && title != &infojson.title && !node.as_object().has(NO_SUBTITLE.0) { node = node.as_object().insert(NO_SUBTITLE, &title); } if let Some(up) = &infojson.uploader && !node.as_object().has(NO_SUBTITLE.0) { node = node .as_object() .insert(NO_SUBTITLE, &clean_uploader_name(&up)); } if let Some(desc) = &infojson.description { node = node.as_object().insert(NO_DESCRIPTION, &desc); } if let Some(tag) = infojson.tags.clone() { node = node .as_object() .extend(NO_TAG, tag.iter().map(String::as_str)); } if let Some(rd) = release_date { node = node.as_object().insert(NO_RELEASEDATE, rd); } match infojson.extractor.as_str() { "youtube" => { node = node.as_object().update(NO_IDENTIFIERS, |rat| { rat.insert(IDENT_YOUTUBE_VIDEO, &infojson.id) }); node = node.as_object().update(NO_RATINGS, |rat| { rat.insert( RTYP_YOUTUBE_VIEWS, infojson.view_count.unwrap_or_default() as f64, ) }); node = node.as_object().update(NO_RATINGS, |rat| { rat.insert( RTYP_YOUTUBE_LIKES, infojson.like_count.unwrap_or_default() as f64, ) }); } "Bandcamp" => { node = node.as_object().update(NO_IDENTIFIERS, |rat| { rat.insert(IDENT_BANDCAMP, &infojson.id) }); } _ => (), }; ct.dba.nodes.update(txn, row, node) })?; } Ok(()) } } fn clean_uploader_name(mut s: &str) -> &str { s = s.strip_suffix(" - Videos").unwrap_or(s); // youtube s = s.strip_suffix(" - Topic").unwrap_or(s); // youtube s = s.strip_prefix("Uploads from ").unwrap_or(s); // youtube s = s.strip_prefix("Discography of ").unwrap_or(s); // bandcamp s }