feat: add PublicMetaDB request-plan support alongside mdblist/trakt/simkl

Covers all 58 documented endpoints (resume, watch history, skip
timestamps, ratings, episode ratings, highlights, external ID
mappings, anime seasons, lists, picks, and voting) as stateless
method/url/body plan builders, wired into core_invoke the same way
mdblist_plan is.
This commit is contained in:
KhooLy 2026-07-31 15:34:34 +03:00
parent bc42053405
commit cd9e3cdba0
16 changed files with 987 additions and 2 deletions

View file

@ -16,6 +16,7 @@ mod plan_misc_routes;
mod player_policy_routes;
mod player_scrobble_routes;
mod profile_routes;
mod publicmetadb_routes;
mod resource_plan_routes;
mod search_plan_routes;
mod stream_policy_routes;
@ -43,6 +44,7 @@ use plan_misc_routes::{
use player_policy_routes::route_player_policy;
use player_scrobble_routes::route_player_scrobble;
use profile_routes::{route_profile_contract, route_profile_prefs};
use publicmetadb_routes::route_publicmetadb;
use resource_plan_routes::route_resource_plan;
use search_plan_routes::route_search_plan;
use stream_policy_routes::route_stream_policy;
@ -57,8 +59,8 @@ use crate::{
external_sync, headless_adapter_plan, headless_engine, home_ranking, integration_settings,
intro_segments, library_state, mdblist_plan, nuvio_sync, offline_download, platform_plan,
player_flow, player_policy, player_scrobble, plugins, profile_avatar_pack, profile_contract,
profile_prefs, repository_flow, search_plan, stream_policy, tmdb_plan, trailer_subtitles,
watchlist_plan,
profile_prefs, publicmetadb_plan, repository_flow, search_plan, stream_policy, tmdb_plan,
trailer_subtitles, watchlist_plan,
};
#[derive(Clone, Copy, PartialEq, Eq)]
@ -137,6 +139,7 @@ const ROUTERS: &[fn(&str, &str) -> Outcome] = &[
route_external_sync_simkl,
route_external_sync_anilist,
route_mdblist,
route_publicmetadb,
route_anime_detection,
route_library_state,
route_nuvio_sync,

View file

@ -0,0 +1,191 @@
use super::*;
pub(super) fn route_publicmetadb(method: &str, args_json: &str) -> Outcome {
match method {
"publicmetadbBearer" => Ok(Value::String(publicmetadb_plan::publicmetadb_bearer(
&arg_str(args_json, "apiKey")?,
))),
"publicmetadbResumeUrl" => Ok(Value::String(publicmetadb_plan::publicmetadb_resume_url(
args_json,
))),
"publicmetadbResumeSavePlan" => {
opt_json(publicmetadb_plan::publicmetadb_resume_save_plan(args_json))
}
"publicmetadbResumeBatchPlan" => {
opt_json(publicmetadb_plan::publicmetadb_resume_batch_plan(args_json))
}
"publicmetadbResumeDeletePlan" => opt_json(
publicmetadb_plan::publicmetadb_resume_delete_plan(&arg_str(args_json, "id")?),
),
"publicmetadbWatchedUrl" => Ok(Value::String(publicmetadb_plan::publicmetadb_watched_url(
args_json,
))),
"publicmetadbWatchedMarkPlan" => {
let args = object(args_json)?;
let dedupe = args.get("dedupe").and_then(Value::as_bool).unwrap_or(false);
opt_json(publicmetadb_plan::publicmetadb_watched_mark_plan(
args_json, dedupe,
))
}
"publicmetadbWatchedEditDatePlan" => {
let args = object(args_json)?;
opt_json(publicmetadb_plan::publicmetadb_watched_edit_date_plan(
field_str(&args, "id")?,
args_json,
))
}
"publicmetadbWatchedDeletePlan" => opt_json(
publicmetadb_plan::publicmetadb_watched_delete_plan(&arg_str(args_json, "id")?),
),
"publicmetadbWatchedBulkDeletePlan" => opt_json(
publicmetadb_plan::publicmetadb_watched_bulk_delete_plan(args_json),
),
"publicmetadbSkipsUrl" => opt_str(publicmetadb_plan::publicmetadb_skips_url(args_json)),
"publicmetadbSkipsCreatePlan" => {
opt_json(publicmetadb_plan::publicmetadb_skips_create_plan(args_json))
}
"publicmetadbSkipsDeletePlan" => opt_json(
publicmetadb_plan::publicmetadb_skips_delete_plan(&arg_str(args_json, "id")?),
),
"publicmetadbRatingsUrl" => opt_str(publicmetadb_plan::publicmetadb_ratings_url(args_json)),
"publicmetadbRatingsCreatePlan" => opt_json(
publicmetadb_plan::publicmetadb_ratings_create_plan(args_json),
),
"publicmetadbRatingsDeletePlan" => opt_json(
publicmetadb_plan::publicmetadb_ratings_delete_plan(&arg_str(args_json, "id")?),
),
"publicmetadbEpisodeRatingsUrl" => opt_str(
publicmetadb_plan::publicmetadb_episode_ratings_url(args_json),
),
"publicmetadbEpisodeRatingsCreatePlan" => opt_json(
publicmetadb_plan::publicmetadb_episode_ratings_create_plan(args_json),
),
"publicmetadbEpisodeRatingsDeletePlan" => opt_json(
publicmetadb_plan::publicmetadb_episode_ratings_delete_plan(&arg_str(args_json, "id")?),
),
"publicmetadbEpisodeRatingsBatchUrl" => opt_str(
publicmetadb_plan::publicmetadb_episode_ratings_batch_url(args_json),
),
"publicmetadbEpisodeRatingsBatchCreatePlan" => {
opt_json(publicmetadb_plan::publicmetadb_episode_ratings_batch_create_plan(args_json))
}
"publicmetadbEpisodeRatingsBatchDeletePlan" => {
opt_json(publicmetadb_plan::publicmetadb_episode_ratings_batch_delete_plan(args_json))
}
"publicmetadbHighlightsUrl" => {
opt_str(publicmetadb_plan::publicmetadb_highlights_url(args_json))
}
"publicmetadbHighlightsCreatePlan" => opt_json(
publicmetadb_plan::publicmetadb_highlights_create_plan(args_json),
),
"publicmetadbHighlightsDeletePlan" => opt_json(
publicmetadb_plan::publicmetadb_highlights_delete_plan(&arg_str(args_json, "id")?),
),
"publicmetadbMappingsUrl" => {
opt_str(publicmetadb_plan::publicmetadb_mappings_url(args_json))
}
"publicmetadbMappingsLookupUrl" => opt_str(
publicmetadb_plan::publicmetadb_mappings_lookup_url(args_json),
),
"publicmetadbMappingsCreatePlan" => opt_json(
publicmetadb_plan::publicmetadb_mappings_create_plan(args_json),
),
"publicmetadbMappingsDeletePlan" => opt_json(
publicmetadb_plan::publicmetadb_mappings_delete_plan(&arg_str(args_json, "id")?),
),
"publicmetadbAnimeSeasonsUrl" => {
opt_str(publicmetadb_plan::publicmetadb_anime_seasons_url(args_json))
}
"publicmetadbAnimeSeasonsSubmitPlan" => opt_json(
publicmetadb_plan::publicmetadb_anime_seasons_submit_plan(args_json),
),
"publicmetadbAnimeSeasonsDeleteMappingPlan" => {
opt_json(publicmetadb_plan::publicmetadb_anime_seasons_delete_mapping_plan(args_json))
}
"publicmetadbAnimeSeasonsDeleteChunkPlan" => opt_json(
publicmetadb_plan::publicmetadb_anime_seasons_delete_chunk_plan(&arg_str(
args_json, "id",
)?),
),
"publicmetadbListsUrl" => Ok(Value::String(publicmetadb_plan::publicmetadb_lists_url(
args_json,
))),
"publicmetadbListsCreatePlan" => {
opt_json(publicmetadb_plan::publicmetadb_lists_create_plan(args_json))
}
"publicmetadbListsDeletePlan" => opt_json(
publicmetadb_plan::publicmetadb_lists_delete_plan(&arg_str(args_json, "listId")?),
),
"publicmetadbListItemsUrl" => {
let args = object(args_json)?;
opt_str(publicmetadb_plan::publicmetadb_list_items_url(
field_str(&args, "listId")?,
args_json,
))
}
"publicmetadbListItemsAddPlan" => {
let args = object(args_json)?;
opt_json(publicmetadb_plan::publicmetadb_list_items_add_plan(
field_str(&args, "listId")?,
args_json,
))
}
"publicmetadbListItemsRemovePlan" => {
let args = object(args_json)?;
opt_json(publicmetadb_plan::publicmetadb_list_items_remove_plan(
field_str(&args, "listId")?,
field_str(&args, "itemId")?,
))
}
"publicmetadbCatalogsUrl" => {
Ok(Value::String(publicmetadb_plan::publicmetadb_catalogs_url()))
}
"publicmetadbCatalogItemsUrl" => {
let args = object(args_json)?;
opt_str(publicmetadb_plan::publicmetadb_catalog_items_url(
field_str(&args, "id")?,
args_json,
))
}
"publicmetadbVotesUrl" => {
let args = object(args_json)?;
let all = args.get("all").and_then(Value::as_bool).unwrap_or(false);
opt_str(publicmetadb_plan::publicmetadb_votes_url(
field_str(&args, "resource")?,
field_str(&args, "itemId")?,
all,
))
}
"publicmetadbVotesCreatePlan" => {
let args = object(args_json)?;
opt_json(publicmetadb_plan::publicmetadb_votes_create_plan(
field_str(&args, "resource")?,
field_str(&args, "itemId")?,
field_i64(&args, "vote")?,
))
}
"publicmetadbVotesDeletePlan" => {
let args = object(args_json)?;
opt_json(publicmetadb_plan::publicmetadb_votes_delete_plan(
field_str(&args, "resource")?,
field_str(&args, "itemId")?,
))
}
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}

View file

@ -97,6 +97,8 @@ mod profile_contract;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod profile_prefs;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod publicmetadb_plan;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod repository_flow;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod search_plan;

219
src/publicmetadb_plan.rs Normal file
View file

@ -0,0 +1,219 @@
mod anime_seasons;
mod catalogs;
mod episode_ratings;
mod helpers;
mod highlights;
mod lists;
mod mappings;
mod ratings;
mod resume;
mod skips;
mod votes;
mod watched;
pub(crate) use anime_seasons::{
publicmetadb_anime_seasons_delete_chunk_plan, publicmetadb_anime_seasons_delete_mapping_plan,
publicmetadb_anime_seasons_submit_plan, publicmetadb_anime_seasons_url,
};
pub(crate) use catalogs::{publicmetadb_catalog_items_url, publicmetadb_catalogs_url};
pub(crate) use episode_ratings::{
publicmetadb_episode_ratings_batch_create_plan, publicmetadb_episode_ratings_batch_delete_plan,
publicmetadb_episode_ratings_batch_url, publicmetadb_episode_ratings_create_plan,
publicmetadb_episode_ratings_delete_plan, publicmetadb_episode_ratings_url,
};
pub(crate) use helpers::publicmetadb_bearer;
pub(crate) use highlights::{
publicmetadb_highlights_create_plan, publicmetadb_highlights_delete_plan,
publicmetadb_highlights_url,
};
pub(crate) use lists::{
publicmetadb_list_items_add_plan, publicmetadb_list_items_remove_plan,
publicmetadb_list_items_url, publicmetadb_lists_create_plan, publicmetadb_lists_delete_plan,
publicmetadb_lists_url,
};
pub(crate) use mappings::{
publicmetadb_mappings_create_plan, publicmetadb_mappings_delete_plan,
publicmetadb_mappings_lookup_url, publicmetadb_mappings_url,
};
pub(crate) use ratings::{
publicmetadb_ratings_create_plan, publicmetadb_ratings_delete_plan, publicmetadb_ratings_url,
};
pub(crate) use resume::{
publicmetadb_resume_batch_plan, publicmetadb_resume_delete_plan, publicmetadb_resume_save_plan,
publicmetadb_resume_url,
};
pub(crate) use skips::{
publicmetadb_skips_create_plan, publicmetadb_skips_delete_plan, publicmetadb_skips_url,
};
pub(crate) use votes::{
publicmetadb_votes_create_plan, publicmetadb_votes_delete_plan, publicmetadb_votes_url,
};
pub(crate) use watched::{
publicmetadb_watched_bulk_delete_plan, publicmetadb_watched_delete_plan,
publicmetadb_watched_edit_date_plan, publicmetadb_watched_mark_plan, publicmetadb_watched_url,
};
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, json};
#[test]
fn builds_bearer_header() {
assert_eq!(publicmetadb_bearer("pm-abc"), "Bearer pm-abc");
}
#[test]
fn builds_resume_requests() {
assert_eq!(
publicmetadb_resume_url(r#"{"tmdb_id":1399,"media_type":"tv"}"#),
"https://publicmetadb.com/api/external/resume?tmdb_id=1399&media_type=tv"
);
let plan: Value = serde_json::from_str(
&publicmetadb_resume_save_plan(
r#"{"media_type":"movie","position_ms":1000,"runtime_ms":6000,"tmdb_id":42}"#,
)
.unwrap(),
)
.unwrap();
assert_eq!(plan["method"], "POST");
assert_eq!(plan["url"], "https://publicmetadb.com/api/external/resume");
assert_eq!(plan["body"]["tmdb_id"], 42);
assert!(publicmetadb_resume_save_plan(r#"{"media_type":"movie"}"#).is_none());
let items = json!({ "items": [{ "tmdb_id": 1, "media_type": "movie", "position_ms": 1, "runtime_ms": 2 }] })
.to_string();
assert!(publicmetadb_resume_batch_plan(&items).is_some());
assert!(publicmetadb_resume_batch_plan(r#"{"items":[]}"#).is_none());
assert_eq!(
publicmetadb_resume_delete_plan("abc123")
.and_then(|p| serde_json::from_str::<Value>(&p).ok())
.map(|p| p["url"].clone()),
Some(json!("https://publicmetadb.com/api/external/resume/abc123"))
);
}
#[test]
fn builds_watched_requests() {
let mark: Value = serde_json::from_str(
&publicmetadb_watched_mark_plan(
r#"{"tmdb_id":1,"media_type":"movie","watched_at":null}"#,
false,
)
.unwrap(),
)
.unwrap();
assert_eq!(mark["body"]["watched_at"], Value::Null);
let deduped: Value = serde_json::from_str(
&publicmetadb_watched_mark_plan(r#"{"tmdb_id":1,"media_type":"movie"}"#, true).unwrap(),
)
.unwrap();
assert!(deduped["url"].as_str().unwrap().contains("dedupe=true"));
assert!(
publicmetadb_watched_bulk_delete_plan(r#"{"tmdb_id":1,"media_type":"movie"}"#)
.is_some()
);
assert!(publicmetadb_watched_bulk_delete_plan(r#"{"tmdb_id":1}"#).is_none());
}
#[test]
fn builds_skips_requests() {
assert_eq!(
publicmetadb_skips_url(r#"{"tmdb_id":1399,"media_type":"tv","season":1,"episode":1}"#),
Some(
"https://publicmetadb.com/api/external/skips?tmdb_id=1399&media_type=tv&season=1&episode=1"
.to_string()
)
);
assert!(publicmetadb_skips_url(r#"{"tmdb_id":1399}"#).is_none());
let create: Value = serde_json::from_str(
&publicmetadb_skips_create_plan(
r#"{"tmdb_id":1,"media_type":"tv","season":1,"episode":1,"intro_end_ms":60000}"#,
)
.unwrap(),
)
.unwrap();
assert_eq!(create["body"]["intro_end_ms"], 60000);
}
#[test]
fn builds_episode_ratings_batch_requests() {
let ratings = json!({
"tmdb_id": 1,
"media_type": "tv",
"season": 1,
"ratings": [{ "episode": 1, "score": 90 }]
})
.to_string();
assert!(publicmetadb_episode_ratings_batch_create_plan(&ratings).is_some());
let too_many = json!({
"tmdb_id": 1,
"media_type": "tv",
"season": 1,
"ratings": (0..51).map(|i| json!({ "episode": i, "score": 50 })).collect::<Vec<_>>()
})
.to_string();
assert!(publicmetadb_episode_ratings_batch_create_plan(&too_many).is_none());
assert!(publicmetadb_episode_ratings_batch_delete_plan(r#"{"ids":["a","b"]}"#).is_some());
assert!(publicmetadb_episode_ratings_batch_delete_plan(r#"{"ids":[]}"#).is_none());
}
#[test]
fn builds_anime_seasons_requests() {
let submit = json!({
"tmdb_id": 1,
"season_number": 1,
"chunks": [{ "tmdb_season": 1 }]
})
.to_string();
assert!(publicmetadb_anime_seasons_submit_plan(&submit).is_some());
assert!(
publicmetadb_anime_seasons_submit_plan(
&json!({ "tmdb_id": 1, "season_number": 1, "chunks": [] }).to_string()
)
.is_none()
);
assert!(
publicmetadb_anime_seasons_delete_mapping_plan(r#"{"tmdb_id":1,"season_number":1}"#)
.is_some()
);
}
#[test]
fn builds_votes_requests() {
assert_eq!(
publicmetadb_votes_url("skips", "skip123", true),
Some("https://publicmetadb.com/api/external/skips/skip123/votes?all=true".to_string())
);
assert!(publicmetadb_votes_url("bogus", "id", false).is_none());
assert!(publicmetadb_votes_create_plan("ratings", "r1", 1).is_some());
assert!(publicmetadb_votes_create_plan("ratings", "r1", 2).is_none());
}
#[test]
fn builds_lists_and_catalogs_requests() {
assert_eq!(
publicmetadb_catalogs_url(),
"https://publicmetadb.com/api/external/catalogs"
);
assert_eq!(
publicmetadb_catalog_items_url("pick1", r#"{"page":2}"#),
Some("https://publicmetadb.com/api/external/catalogs/pick1/items?page=2".to_string())
);
assert!(
publicmetadb_list_items_add_plan("", r#"{"tmdb_id":1,"media_type":"movie"}"#).is_none()
);
let add: Value = serde_json::from_str(
&publicmetadb_list_items_add_plan("list1", r#"{"tmdb_id":1,"media_type":"movie"}"#)
.unwrap(),
)
.unwrap();
assert_eq!(
add["url"],
"https://publicmetadb.com/api/external/lists/list1/items"
);
}
}

View file

@ -0,0 +1,52 @@
use super::helpers::{body_from_keys, build_url, extract_query, parse_args, plan};
use serde_json::Value;
pub(crate) fn publicmetadb_anime_seasons_url(query_json: &str) -> Option<String> {
let args = parse_args(query_json);
let params = extract_query(&args, &["tmdb_id"]);
if params.is_empty() {
return None;
}
Some(build_url("/anime-seasons", &params))
}
fn chunks_are_valid(chunks: &[Value]) -> bool {
!chunks.is_empty()
&& chunks
.iter()
.all(|chunk| chunk.get("tmdb_season").is_some())
}
pub(crate) fn publicmetadb_anime_seasons_submit_plan(args_json: &str) -> Option<String> {
let args: Value = serde_json::from_str(args_json).ok()?;
let chunks = args.get("chunks")?.as_array()?;
if !chunks_are_valid(chunks) {
return None;
}
let body = body_from_keys(
&args,
&["tmdb_id", "season_number", "chunks"],
&["season_name"],
)?;
plan("POST", build_url("/anime-seasons", &[]), Some(body))
}
pub(crate) fn publicmetadb_anime_seasons_delete_mapping_plan(query_json: &str) -> Option<String> {
let args = parse_args(query_json);
let params = extract_query(&args, &["tmdb_id", "season_number"]);
if params.len() != 2 {
return None;
}
plan("DELETE", build_url("/anime-seasons", &params), None)
}
pub(crate) fn publicmetadb_anime_seasons_delete_chunk_plan(id: &str) -> Option<String> {
if id.is_empty() {
return None;
}
plan(
"DELETE",
build_url(&format!("/anime-seasons/{id}"), &[]),
None,
)
}

View file

@ -0,0 +1,13 @@
use super::helpers::{build_url, extract_query, parse_args};
pub(crate) fn publicmetadb_catalogs_url() -> String {
build_url("/catalogs", &[])
}
pub(crate) fn publicmetadb_catalog_items_url(id: &str, query_json: &str) -> Option<String> {
if id.is_empty() {
return None;
}
let params = extract_query(&parse_args(query_json), &["page"]);
Some(build_url(&format!("/catalogs/{id}/items"), &params))
}

View file

@ -0,0 +1,84 @@
use super::helpers::{body_from_keys, build_url, extract_query, parse_args, plan};
use serde_json::Value;
pub(crate) fn publicmetadb_episode_ratings_url(query_json: &str) -> Option<String> {
let args = parse_args(query_json);
let params = extract_query(
&args,
&[
"tmdb_id",
"media_type",
"season",
"episode",
"label",
"perPage",
],
);
if !params.iter().any(|(key, _)| key == "tmdb_id")
|| !params.iter().any(|(key, _)| key == "media_type")
{
return None;
}
Some(build_url("/episode-ratings", &params))
}
pub(crate) fn publicmetadb_episode_ratings_create_plan(args_json: &str) -> Option<String> {
let args: Value = serde_json::from_str(args_json).ok()?;
let body = body_from_keys(
&args,
&["tmdb_id", "media_type", "season", "episode", "score"],
&["label"],
)?;
plan("POST", build_url("/episode-ratings", &[]), Some(body))
}
pub(crate) fn publicmetadb_episode_ratings_delete_plan(id: &str) -> Option<String> {
if id.is_empty() {
return None;
}
plan(
"DELETE",
build_url(&format!("/episode-ratings/{id}"), &[]),
None,
)
}
pub(crate) fn publicmetadb_episode_ratings_batch_url(query_json: &str) -> Option<String> {
let args = parse_args(query_json);
let params = extract_query(&args, &["tmdb_id", "media_type", "season", "label"]);
if !params.iter().any(|(key, _)| key == "tmdb_id")
|| !params.iter().any(|(key, _)| key == "media_type")
|| !params.iter().any(|(key, _)| key == "season")
{
return None;
}
Some(build_url("/episode-ratings/batch", &params))
}
pub(crate) fn publicmetadb_episode_ratings_batch_create_plan(args_json: &str) -> Option<String> {
let args: Value = serde_json::from_str(args_json).ok()?;
let ratings = args.get("ratings")?.as_array()?;
if ratings.is_empty() || ratings.len() > 50 {
return None;
}
let body = body_from_keys(
&args,
&["tmdb_id", "media_type", "season", "ratings"],
&["label"],
)?;
plan("POST", build_url("/episode-ratings/batch", &[]), Some(body))
}
pub(crate) fn publicmetadb_episode_ratings_batch_delete_plan(args_json: &str) -> Option<String> {
let args: Value = serde_json::from_str(args_json).ok()?;
let ids = args.get("ids")?.as_array()?;
if ids.is_empty() || ids.len() > 50 {
return None;
}
let body = body_from_keys(&args, &["ids"], &[])?;
plan(
"DELETE",
build_url("/episode-ratings/batch", &[]),
Some(body),
)
}

View file

@ -0,0 +1,79 @@
use serde_json::{Map, Value, json};
const PUBLICMETADB_API_BASE_URL: &str = "https://publicmetadb.com/api/external";
pub(crate) fn publicmetadb_bearer(api_key: &str) -> String {
format!("Bearer {api_key}")
}
pub(crate) fn encode_query(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
pub(crate) fn build_url(path: &str, params: &[(String, String)]) -> String {
let mut url = format!("{PUBLICMETADB_API_BASE_URL}{path}");
let mut first = true;
for (key, value) in params {
if value.is_empty() {
continue;
}
url.push(if first { '?' } else { '&' });
first = false;
url.push_str(key);
url.push('=');
url.push_str(&encode_query(value));
}
url
}
pub(crate) fn plan(method: &str, url: String, body: Option<Value>) -> Option<String> {
serde_json::to_string(&json!({ "method": method, "url": url, "body": body })).ok()
}
pub(crate) fn value_to_query_string(value: &Value) -> Option<String> {
match value {
Value::String(s) if !s.is_empty() => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
pub(crate) fn extract_query(args: &Value, keys: &[&str]) -> Vec<(String, String)> {
keys.iter()
.filter_map(|&key| {
let raw = args.get(key)?;
Some((key.to_string(), value_to_query_string(raw)?))
})
.collect()
}
pub(crate) fn parse_args(args_json: &str) -> Value {
serde_json::from_str(args_json).unwrap_or(json!({}))
}
// Copies `required` keys verbatim (failing the whole body if any is absent)
// then any present `optional` keys — including explicit JSON `null`, which
// several endpoints here use on purpose (e.g. watched_at: null for "unknown
// date"), so this can't be a plain `Value::is_null` filter.
pub(crate) fn body_from_keys(args: &Value, required: &[&str], optional: &[&str]) -> Option<Value> {
let mut body = Map::new();
for &key in required {
body.insert(key.to_string(), args.get(key)?.clone());
}
for &key in optional {
if let Some(value) = args.get(key) {
body.insert(key.to_string(), value.clone());
}
}
Some(Value::Object(body))
}

View file

@ -0,0 +1,35 @@
use super::helpers::{body_from_keys, build_url, extract_query, parse_args, plan};
use serde_json::Value;
pub(crate) fn publicmetadb_highlights_url(query_json: &str) -> Option<String> {
let args = parse_args(query_json);
let params = extract_query(&args, &["tmdb_id", "media_type", "season", "episode"]);
if !params.iter().any(|(key, _)| key == "tmdb_id")
|| !params.iter().any(|(key, _)| key == "media_type")
{
return None;
}
Some(build_url("/highlights", &params))
}
pub(crate) fn publicmetadb_highlights_create_plan(args_json: &str) -> Option<String> {
let args: Value = serde_json::from_str(args_json).ok()?;
let body = body_from_keys(
&args,
&[
"tmdb_id",
"media_type",
"highlight_start_ms",
"highlight_end_ms",
],
&["season", "episode", "description"],
)?;
plan("POST", build_url("/highlights", &[]), Some(body))
}
pub(crate) fn publicmetadb_highlights_delete_plan(id: &str) -> Option<String> {
if id.is_empty() {
return None;
}
plan("DELETE", build_url(&format!("/highlights/{id}"), &[]), None)
}

View file

@ -0,0 +1,54 @@
use super::helpers::{body_from_keys, build_url, extract_query, parse_args, plan};
use serde_json::Value;
pub(crate) fn publicmetadb_lists_url(query_json: &str) -> String {
build_url(
"/lists",
&extract_query(&parse_args(query_json), &["page", "perPage"]),
)
}
pub(crate) fn publicmetadb_lists_create_plan(args_json: &str) -> Option<String> {
let args: Value = serde_json::from_str(args_json).ok()?;
let body = body_from_keys(&args, &["name"], &["description", "is_public", "type"])?;
plan("POST", build_url("/lists", &[]), Some(body))
}
pub(crate) fn publicmetadb_lists_delete_plan(list_id: &str) -> Option<String> {
if list_id.is_empty() {
return None;
}
plan("DELETE", build_url(&format!("/lists/{list_id}"), &[]), None)
}
pub(crate) fn publicmetadb_list_items_url(list_id: &str, query_json: &str) -> Option<String> {
if list_id.is_empty() {
return None;
}
let params = extract_query(&parse_args(query_json), &["page", "perPage"]);
Some(build_url(&format!("/lists/{list_id}/items"), &params))
}
pub(crate) fn publicmetadb_list_items_add_plan(list_id: &str, args_json: &str) -> Option<String> {
if list_id.is_empty() {
return None;
}
let args: Value = serde_json::from_str(args_json).ok()?;
let body = body_from_keys(&args, &["tmdb_id", "media_type"], &[])?;
plan(
"POST",
build_url(&format!("/lists/{list_id}/items"), &[]),
Some(body),
)
}
pub(crate) fn publicmetadb_list_items_remove_plan(list_id: &str, item_id: &str) -> Option<String> {
if list_id.is_empty() || item_id.is_empty() {
return None;
}
plan(
"DELETE",
build_url(&format!("/lists/{list_id}/items/{item_id}"), &[]),
None,
)
}

View file

@ -0,0 +1,41 @@
use super::helpers::{body_from_keys, build_url, extract_query, parse_args, plan};
use serde_json::Value;
pub(crate) fn publicmetadb_mappings_url(query_json: &str) -> Option<String> {
let args = parse_args(query_json);
let params = extract_query(&args, &["tmdb_id", "media_type", "id_type"]);
if !params.iter().any(|(key, _)| key == "tmdb_id")
|| !params.iter().any(|(key, _)| key == "media_type")
{
return None;
}
Some(build_url("/mappings", &params))
}
pub(crate) fn publicmetadb_mappings_lookup_url(query_json: &str) -> Option<String> {
let args = parse_args(query_json);
let params = extract_query(&args, &["id_type", "id_value", "media_type"]);
if !params.iter().any(|(key, _)| key == "id_type")
|| !params.iter().any(|(key, _)| key == "id_value")
{
return None;
}
Some(build_url("/mappings/lookup", &params))
}
pub(crate) fn publicmetadb_mappings_create_plan(args_json: &str) -> Option<String> {
let args: Value = serde_json::from_str(args_json).ok()?;
let body = body_from_keys(
&args,
&["tmdb_id", "media_type", "id_type", "id_value"],
&[],
)?;
plan("POST", build_url("/mappings", &[]), Some(body))
}
pub(crate) fn publicmetadb_mappings_delete_plan(id: &str) -> Option<String> {
if id.is_empty() {
return None;
}
plan("DELETE", build_url(&format!("/mappings/{id}"), &[]), None)
}

View file

@ -0,0 +1,26 @@
use super::helpers::{body_from_keys, build_url, extract_query, parse_args, plan};
use serde_json::Value;
pub(crate) fn publicmetadb_ratings_url(query_json: &str) -> Option<String> {
let args = parse_args(query_json);
let params = extract_query(&args, &["tmdb_id", "media_type", "label"]);
if !params.iter().any(|(key, _)| key == "tmdb_id")
|| !params.iter().any(|(key, _)| key == "media_type")
{
return None;
}
Some(build_url("/ratings", &params))
}
pub(crate) fn publicmetadb_ratings_create_plan(args_json: &str) -> Option<String> {
let args: Value = serde_json::from_str(args_json).ok()?;
let body = body_from_keys(&args, &["tmdb_id", "media_type", "score"], &["label"])?;
plan("POST", build_url("/ratings", &[]), Some(body))
}
pub(crate) fn publicmetadb_ratings_delete_plan(id: &str) -> Option<String> {
if id.is_empty() {
return None;
}
plan("DELETE", build_url(&format!("/ratings/{id}"), &[]), None)
}

View file

@ -0,0 +1,47 @@
use super::helpers::{body_from_keys, build_url, extract_query, parse_args, plan};
use serde_json::Value;
const RESUME_QUERY_KEYS: &[&str] = &[
"tmdb_id",
"media_type",
"season",
"episode",
"id_type",
"id_value",
"page",
"perPage",
];
pub(crate) fn publicmetadb_resume_url(query_json: &str) -> String {
build_url(
"/resume",
&extract_query(&parse_args(query_json), RESUME_QUERY_KEYS),
)
}
pub(crate) fn publicmetadb_resume_save_plan(args_json: &str) -> Option<String> {
let args: Value = serde_json::from_str(args_json).ok()?;
let body = body_from_keys(
&args,
&["media_type", "position_ms", "runtime_ms"],
&["tmdb_id", "season", "episode", "id_type", "id_value"],
)?;
plan("POST", build_url("/resume", &[]), Some(body))
}
pub(crate) fn publicmetadb_resume_batch_plan(args_json: &str) -> Option<String> {
let args: Value = serde_json::from_str(args_json).ok()?;
let items = args.get("items")?.as_array()?;
if items.is_empty() || items.len() > 50 {
return None;
}
let body = body_from_keys(&args, &["items"], &[])?;
plan("POST", build_url("/resume/batch", &[]), Some(body))
}
pub(crate) fn publicmetadb_resume_delete_plan(id: &str) -> Option<String> {
if id.is_empty() {
return None;
}
plan("DELETE", build_url(&format!("/resume/{id}"), &[]), None)
}

View file

@ -0,0 +1,39 @@
use super::helpers::{body_from_keys, build_url, extract_query, parse_args, plan};
use serde_json::Value;
pub(crate) fn publicmetadb_skips_url(query_json: &str) -> Option<String> {
let args = parse_args(query_json);
let params = extract_query(
&args,
&["tmdb_id", "media_type", "season", "episode", "source"],
);
if !params.iter().any(|(key, _)| key == "tmdb_id")
|| !params.iter().any(|(key, _)| key == "media_type")
{
return None;
}
Some(build_url("/skips", &params))
}
pub(crate) fn publicmetadb_skips_create_plan(args_json: &str) -> Option<String> {
let args: Value = serde_json::from_str(args_json).ok()?;
let body = body_from_keys(
&args,
&["tmdb_id", "media_type", "season", "episode"],
&[
"source",
"intro_start_ms",
"intro_end_ms",
"credits_start_ms",
"credits_end_ms",
],
)?;
plan("POST", build_url("/skips", &[]), Some(body))
}
pub(crate) fn publicmetadb_skips_delete_plan(id: &str) -> Option<String> {
if id.is_empty() {
return None;
}
plan("DELETE", build_url(&format!("/skips/{id}"), &[]), None)
}

View file

@ -0,0 +1,45 @@
use super::helpers::{build_url, plan};
use serde_json::json;
const VOTE_RESOURCES: &[&str] = &[
"skips",
"ratings",
"highlights",
"mappings",
"anime-seasons",
"episode-ratings",
];
fn votes_path(resource: &str, item_id: &str) -> Option<String> {
if item_id.is_empty() || !VOTE_RESOURCES.contains(&resource) {
return None;
}
Some(format!("/{resource}/{item_id}/votes"))
}
pub(crate) fn publicmetadb_votes_url(resource: &str, item_id: &str, all: bool) -> Option<String> {
let path = votes_path(resource, item_id)?;
let params = if all {
vec![("all".to_string(), "true".to_string())]
} else {
vec![]
};
Some(build_url(&path, &params))
}
pub(crate) fn publicmetadb_votes_create_plan(
resource: &str,
item_id: &str,
vote: i64,
) -> Option<String> {
let path = votes_path(resource, item_id)?;
if !(-1..=1).contains(&vote) {
return None;
}
plan("POST", build_url(&path, &[]), Some(json!({ "vote": vote })))
}
pub(crate) fn publicmetadb_votes_delete_plan(resource: &str, item_id: &str) -> Option<String> {
let path = votes_path(resource, item_id)?;
plan("DELETE", build_url(&path, &[]), None)
}

View file

@ -0,0 +1,55 @@
use super::helpers::{body_from_keys, build_url, extract_query, parse_args, plan};
use serde_json::Value;
pub(crate) fn publicmetadb_watched_url(query_json: &str) -> String {
build_url(
"/watched",
&extract_query(&parse_args(query_json), &["page", "perPage"]),
)
}
pub(crate) fn publicmetadb_watched_mark_plan(args_json: &str, dedupe: bool) -> Option<String> {
let args: Value = serde_json::from_str(args_json).ok()?;
let body = body_from_keys(
&args,
&["tmdb_id", "media_type"],
&["season", "episode", "watched_at"],
)?;
let params = if dedupe {
vec![("dedupe".to_string(), "true".to_string())]
} else {
vec![]
};
plan("POST", build_url("/watched", &params), Some(body))
}
pub(crate) fn publicmetadb_watched_edit_date_plan(id: &str, args_json: &str) -> Option<String> {
if id.is_empty() {
return None;
}
let args: Value = serde_json::from_str(args_json).ok()?;
let body = body_from_keys(&args, &["watched_at"], &[])?;
plan(
"PATCH",
build_url(&format!("/watched/{id}"), &[]),
Some(body),
)
}
pub(crate) fn publicmetadb_watched_delete_plan(id: &str) -> Option<String> {
if id.is_empty() {
return None;
}
plan("DELETE", build_url(&format!("/watched/{id}"), &[]), None)
}
pub(crate) fn publicmetadb_watched_bulk_delete_plan(query_json: &str) -> Option<String> {
let args: Value = serde_json::from_str(query_json).ok()?;
let params = extract_query(&args, &["tmdb_id", "media_type", "season", "episode"]);
if !params.iter().any(|(key, _)| key == "tmdb_id")
|| !params.iter().any(|(key, _)| key == "media_type")
{
return None;
}
plan("DELETE", build_url("/watched", &params), None)
}