Skip to content

Commit 9f13041

Browse files
committed
Fix parsing errors for some non-scrobble Plex webhooks
1 parent 7181d9a commit 9f13041

File tree

2 files changed

+183
-29
lines changed

2 files changed

+183
-29
lines changed

src/main.rs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,22 +82,28 @@ async fn scrobble(
8282
// Check that the webhook is something anifunnel can handle.
8383
match webhook.is_actionable(state.multi_season) {
8484
plex::WebhookState::Actionable => log::debug!("Webhook is actionable"),
85+
plex::WebhookState::NoMetadata => {
86+
error!("Webhook was a scrobble event but has no metadata. This should not happen.");
87+
return "ERROR";
88+
}
8589
plex::WebhookState::NonScrobbleEvent => {
8690
info!("Webhook is not a scrobble event");
8791
return "NO OP";
8892
}
8993
plex::WebhookState::IncorrectType => {
94+
let metadata = webhook.metadata.unwrap();
9095
info!(
9196
"Scrobble event for {} is for a non-episode media ({})",
92-
&webhook.metadata.title, &webhook.metadata.media_type
97+
&metadata.title, &metadata.media_type
9398
);
9499
return "NO OP";
95100
}
96101
plex::WebhookState::IncorrectSeason => {
102+
let metadata = webhook.metadata.unwrap();
97103
info!(
98104
"Scrobble event for {} is for a non-first season ({}). \
99105
Enable multi-season matching if this is unexpected.",
100-
&webhook.metadata.title, &webhook.metadata.season_number
106+
&metadata.title, &metadata.season_number
101107
);
102108
return "NO OP";
103109
}
@@ -121,15 +127,17 @@ async fn scrobble(
121127
};
122128

123129
if let Ok(media_list_entries) = anilist_client.get_watching_list().await {
124-
let mut anime_override = db::get_override_by_title(&mut db, &webhook.metadata.title).await;
130+
let metadata = webhook.metadata.unwrap();
131+
132+
let mut anime_override = db::get_override_by_title(&mut db, &metadata.title).await;
125133
let matched_media_list = match &anime_override {
126134
Some(o) => media_list_entries.find_id(&o.id),
127-
None => media_list_entries.find_match(&webhook.metadata.title),
135+
None => media_list_entries.find_match(&metadata.title),
128136
};
129137
let matched_media_list = match matched_media_list {
130138
Some(media_list) => media_list,
131139
None => {
132-
debug!("Could not find a match for '{}'", &webhook.metadata.title);
140+
debug!("Could not find a match for '{}'", &metadata.title);
133141
return "NO OP";
134142
}
135143
};
@@ -142,7 +150,8 @@ async fn scrobble(
142150
Some(o) => o.get_episode_offset(),
143151
None => 0,
144152
};
145-
if webhook.metadata.episode_number + episode_offset == matched_media_list.progress + 1 {
153+
154+
if metadata.episode_number + episode_offset == matched_media_list.progress + 1 {
146155
match anilist_client.update_progress(matched_media_list).await {
147156
Ok(true) => info!("Updated '{}' progress", matched_media_list.media.title),
148157
Ok(false) => error!(

src/plex.rs

Lines changed: 168 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
use serde::Deserialize;
1+
use serde::{Deserialize, Deserializer};
2+
use serde_json::Value;
23

34
#[derive(Debug, Deserialize)]
45
pub struct Webhook {
@@ -7,13 +8,27 @@ pub struct Webhook {
78
#[serde(rename = "Account")]
89
pub account: WebhookAccount,
910

10-
#[serde(rename = "Metadata")]
11-
pub metadata: WebhookMetadata,
11+
#[serde(
12+
default,
13+
rename = "Metadata",
14+
deserialize_with = "webhook_metadata_wrapper"
15+
)]
16+
pub metadata: Option<WebhookMetadata>,
17+
}
18+
19+
/// Convert incomplete metadata into None for non-scrobble events.
20+
fn webhook_metadata_wrapper<'de, D>(deserializer: D) -> Result<Option<WebhookMetadata>, D::Error>
21+
where
22+
D: Deserializer<'de>,
23+
{
24+
let v: Value = Deserialize::deserialize(deserializer)?;
25+
Ok(Option::deserialize(v).unwrap_or_default())
1226
}
1327

1428
#[derive(Debug, PartialEq)]
1529
pub enum WebhookState {
1630
Actionable,
31+
NoMetadata,
1732
NonScrobbleEvent,
1833
IncorrectSeason,
1934
IncorrectType,
@@ -24,12 +39,19 @@ impl Webhook {
2439
if self.event != "media.scrobble" {
2540
return WebhookState::NonScrobbleEvent;
2641
}
27-
if self.metadata.media_type != "episode" {
42+
let Some(metadata) = &self.metadata else {
43+
// Metadata may not be present (or complete, which results in Webhook.metadata
44+
// being None) in non-scrobble webhooks, but should always be present in
45+
// scrobble events, meaning that this should never happen after checking the
46+
// event, but we have it just in case.
47+
return WebhookState::NoMetadata;
48+
};
49+
if metadata.media_type != "episode" {
2850
return WebhookState::IncorrectType;
2951
}
3052
let allowed_season = match multi_season {
31-
true => self.metadata.season_number >= 1,
32-
false => self.metadata.season_number == 1,
53+
true => metadata.season_number >= 1,
54+
false => metadata.season_number == 1,
3355
};
3456
if !allowed_season {
3557
return WebhookState::IncorrectSeason;
@@ -44,7 +66,7 @@ pub struct WebhookAccount {
4466
pub name: String,
4567
}
4668

47-
#[derive(Debug, Deserialize)]
69+
#[derive(Debug, Deserialize, PartialEq)]
4870
pub struct WebhookMetadata {
4971
#[serde(rename = "type")]
5072
pub media_type: String,
@@ -63,19 +85,129 @@ pub struct WebhookMetadata {
6385
mod tests {
6486
use super::*;
6587

88+
#[test]
89+
// admin.database.backup events have no Metadata object.
90+
fn deserialize_admin_database_backup() {
91+
let json = r#"
92+
{
93+
"event": "admin.database.backup",
94+
"user": true,
95+
"owner": true,
96+
"Account": {
97+
"title": "Username"
98+
},
99+
"Server": {
100+
"title": "Example"
101+
}
102+
}
103+
"#;
104+
let webhook = serde_json::from_str::<Webhook>(json.into()).unwrap();
105+
assert_eq!(webhook.event, "admin.database.backup");
106+
assert_eq!(webhook.account.name, "Username");
107+
assert_eq!(webhook.metadata, None);
108+
}
109+
110+
#[test]
111+
// library.new events have missing fields in the Metadata object.
112+
fn deserialize_library_new() {
113+
let json = r#"
114+
{
115+
"event": "library.new",
116+
"user": true,
117+
"owner": true,
118+
"Account": {
119+
"title": "Username"
120+
},
121+
"Server": {
122+
"title": "Example"
123+
},
124+
"Metadata": {
125+
"index": 1,
126+
"title": "Chanto Suenai Kyuuketsuki-chan",
127+
"type": "show"
128+
}
129+
}
130+
"#;
131+
let webhook = serde_json::from_str::<Webhook>(json).unwrap();
132+
assert_eq!(webhook.event, "library.new");
133+
assert_eq!(webhook.account.name, "Username");
134+
assert_eq!(webhook.metadata, None);
135+
}
136+
137+
#[test]
138+
fn deserialize_scrobble() {
139+
let json = r#"
140+
{
141+
"event": "media.scrobble",
142+
"user": true,
143+
"owner": true,
144+
"Account": {
145+
"title": "Username"
146+
},
147+
"Server": {
148+
"title": "Example"
149+
},
150+
"Metadata": {
151+
"grandparentTitle": "Chanto Suenai Kyuuketsuki-chan",
152+
"index": 2,
153+
"parentIndex": 1,
154+
"type": "episode"
155+
}
156+
}
157+
"#;
158+
let webhook = serde_json::from_str::<Webhook>(json).unwrap();
159+
assert_eq!(webhook.event, "media.scrobble");
160+
assert_eq!(webhook.account.name, "Username");
161+
assert_eq!(
162+
webhook.metadata,
163+
Some(WebhookMetadata {
164+
media_type: "episode".into(),
165+
title: "Chanto Suenai Kyuuketsuki-chan".into(),
166+
season_number: 1,
167+
episode_number: 2,
168+
})
169+
);
170+
}
171+
172+
#[test]
173+
// Hypothetical media.scrobble event with missing fields in the Metadata object.
174+
fn deserialize_scrobble_corrupted() {
175+
let json = r#"
176+
{
177+
"event": "media.scrobble",
178+
"user": true,
179+
"owner": true,
180+
"Account": {
181+
"title": "Username"
182+
},
183+
"Server": {
184+
"title": "Example"
185+
},
186+
"Metadata": {
187+
"grandparentTitle": "Chanto Suenai Kyuuketsuki-chan",
188+
"type": "episode"
189+
}
190+
}
191+
"#;
192+
let webhook = serde_json::from_str::<Webhook>(json).unwrap();
193+
assert_eq!(webhook.event, "media.scrobble");
194+
assert_eq!(webhook.account.name, "Username");
195+
assert_eq!(webhook.metadata, None);
196+
}
197+
66198
#[test]
67199
fn webhook_actionable() {
68200
let webhook = Webhook {
69201
event: String::from("media.scrobble"),
70202
account: WebhookAccount {
71203
name: String::from("yukikaze"),
72204
},
73-
metadata: WebhookMetadata {
205+
metadata: Some(WebhookMetadata {
74206
media_type: String::from("episode"),
75207
title: String::from("Onii-chan wa Oshimai!"),
76208
season_number: 1,
77209
episode_number: 4,
78-
},
210+
}),
79211
};
80212
assert_eq!(webhook.is_actionable(false), WebhookState::Actionable);
81213
}
@@ -88,12 +220,12 @@ mod tests {
88220
account: WebhookAccount {
89221
name: String::from("yukikaze"),
90222
},
91-
metadata: WebhookMetadata {
223+
metadata: Some(WebhookMetadata {
92224
media_type: String::from("episode"),
93225
title: String::from("Onii-chan wa Oshimai!"),
94226
season_number: 1,
95227
episode_number: 1,
96-
},
228+
}),
97229
};
98230
assert_eq!(webhook.is_actionable(false), WebhookState::Actionable);
99231
}
@@ -106,16 +238,29 @@ mod tests {
106238
account: WebhookAccount {
107239
name: String::from("yukikaze"),
108240
},
109-
metadata: WebhookMetadata {
241+
metadata: Some(WebhookMetadata {
110242
media_type: String::from("track"),
111243
title: String::from("Onii-chan wa Oshimai!"),
112244
season_number: 1,
113245
episode_number: 4,
114-
},
246+
}),
115247
};
116248
assert_eq!(webhook.is_actionable(false), WebhookState::IncorrectType);
117249
}
118250

251+
#[test]
252+
// Scrobbles with unreadable Metadata objects are not actionable. Mostly hypothetical.
253+
fn webhook_actionable_missing_metadata() {
254+
let webhook = Webhook {
255+
event: String::from("media.scrobble"),
256+
account: WebhookAccount {
257+
name: String::from("yukikaze"),
258+
},
259+
metadata: None,
260+
};
261+
assert_eq!(webhook.is_actionable(false), WebhookState::NoMetadata);
262+
}
263+
119264
#[test]
120265
// Only scrobble events trigger anifunnel.
121266
fn webhook_actionable_playback() {
@@ -124,12 +269,12 @@ mod tests {
124269
account: WebhookAccount {
125270
name: String::from("yukikaze"),
126271
},
127-
metadata: WebhookMetadata {
272+
metadata: Some(WebhookMetadata {
128273
media_type: String::from("episode"),
129274
title: String::from("Onii-chan wa Oshimai!"),
130275
season_number: 1,
131276
episode_number: 4,
132-
},
277+
}),
133278
};
134279
assert_eq!(webhook.is_actionable(false), WebhookState::NonScrobbleEvent);
135280
}
@@ -142,12 +287,12 @@ mod tests {
142287
account: WebhookAccount {
143288
name: String::from("yukikaze"),
144289
},
145-
metadata: WebhookMetadata {
290+
metadata: Some(WebhookMetadata {
146291
media_type: String::from("episode"),
147292
title: String::from("Kidou Senshi Gundam: Suisei no Majo"),
148293
season_number: 2,
149294
episode_number: 4,
150-
},
295+
}),
151296
};
152297
assert_eq!(webhook.is_actionable(false), WebhookState::IncorrectSeason);
153298
}
@@ -160,12 +305,12 @@ mod tests {
160305
account: WebhookAccount {
161306
name: String::from("yukikaze"),
162307
},
163-
metadata: WebhookMetadata {
308+
metadata: Some(WebhookMetadata {
164309
media_type: String::from("episode"),
165310
title: String::from("Kidou Senshi Gundam: Suisei no Majo"),
166311
season_number: 2,
167312
episode_number: 4,
168-
},
313+
}),
169314
};
170315
assert_eq!(webhook.is_actionable(true), WebhookState::Actionable);
171316
}
@@ -178,12 +323,12 @@ mod tests {
178323
account: WebhookAccount {
179324
name: String::from("yukikaze"),
180325
},
181-
metadata: WebhookMetadata {
326+
metadata: Some(WebhookMetadata {
182327
media_type: String::from("episode"),
183328
title: String::from("Bakemonogatari"),
184329
season_number: 0,
185330
episode_number: 3,
186-
},
331+
}),
187332
};
188333
assert_eq!(webhook.is_actionable(false), WebhookState::IncorrectSeason);
189334
}
@@ -196,12 +341,12 @@ mod tests {
196341
account: WebhookAccount {
197342
name: String::from("yukikaze"),
198343
},
199-
metadata: WebhookMetadata {
344+
metadata: Some(WebhookMetadata {
200345
media_type: String::from("episode"),
201346
title: String::from("Bakemonogatari"),
202347
season_number: 0,
203348
episode_number: 3,
204-
},
349+
}),
205350
};
206351
assert_eq!(webhook.is_actionable(true), WebhookState::IncorrectSeason);
207352
}

0 commit comments

Comments
 (0)