1- use serde:: Deserialize ;
1+ use serde:: { Deserialize , Deserializer } ;
2+ use serde_json:: Value ;
23
34#[ derive( Debug , Deserialize ) ]
45pub 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 ) ]
1529pub 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 ) ]
4870pub struct WebhookMetadata {
4971 #[ serde( rename = "type" ) ]
5072 pub media_type : String ,
@@ -63,19 +85,129 @@ pub struct WebhookMetadata {
6385mod 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