v4v 0.5.22

Value-for-value helper utilities for Podcasting 2.0
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
use super::payments::Action;
use chrono::Duration;
use serde_json::Value;
use url::Url;
use uuid::Uuid;

/// bLIP-10 TLV record coming from an untrusted source.
///
/// Apps may not conform to
/// <https://github.com/Podcastindex-org/podcast-namespace/blob/main/value/blip-0010.md#fields>
/// standard so will make it as generic as possible.
#[derive(Debug, serde::Deserialize)]
struct UntrustedRecord {
    /// ACTION
    /// "boost", "stream" or "auto"
    action: Value,

    /// FEED IDENTIFIER
    ///
    ///  The `<podcast:guid>` tag.
    #[serde(rename = "guid", default)]
    feed_guid: Value,
    /// title of the feed
    #[serde(rename = "podcast", default)]
    feed_name: Value,
    /// ID of podcast in PodcastIndex.org directory
    #[serde(rename = "feedID", default)]
    feed_pi_id: Value,
    /// RSS feed URL of podcast
    #[serde(rename = "url", default)]
    feed_url: Value,

    /// ITEM IDENTIFIER
    ///
    /// The `<item:guid>` tag.
    #[serde(rename = "episode_guid", default)]
    item_guid: Value,
    /// title of the item
    #[serde(rename = "episode", default)]
    item_name: Value,
    /// ID of the item in PodcastIndex.org directory
    #[serde(rename = "itemID", default)]
    item_pi_id: Value,

    /// PLAYBACK INFO
    ///
    ///  Timestamp of when the payment was sent, in seconds, as an offset from zero (i.e. - playback position).
    #[serde(rename = "ts", default)]
    timestamp_seconds: Value,
    /// Timestamp of when the payment was sent, in HH:MM:SS notation, as an offset from 00:00:00 (i.e. - playback position).
    #[serde(rename = "time", default)]
    timestamp_hhmmss: Value,
    /// Speed in which the podcast was playing, in decimal notation at the time the payment was sent. So 0.5 is half speed and 2 is double speed.
    #[serde(default)]
    speed: Value,

    /// APP INFO
    ///
    /// Name of sending app
    #[serde(default)]
    app_name: Value,
    /// Version of sending app
    #[serde(default)]
    app_version: Value,

    /// SENDER INFO
    ///
    /// Name of the sender (free text, not validated in any way)
    #[serde(default)]
    sender_name: Value,
    /// Static random identifier for users, not displayed by apps to prevent abuse. Apps can set this per-feed or app-wide. A GUID-like random identifier or a hash works well. Max 32 bytes (64 ascii characters). This can be a Nostr hex encoded pubkey (not NIP-19) for purposes of sender attribution.
    #[serde(default)]
    sender_id: Value,

    /// RECEIVER INFO
    ///
    /// Name for this split in value tag
    #[serde(rename = "name", default)]
    receiver_name: Value,

    /// PAYMENT INFO
    ///
    /// Total number of millisats for the payment before any fees are subtracted. This should be the number the listener entered into the app. Preserving this value is important for numerology reasons. Certain numeric values can have significance to the sender and/or receiver, so giving a way to show this is critical.
    #[serde(default, rename = "value_msat_total")]
    total_num_millisats: Value,
    /// Text message to add to the payment. When this field is present, the payment is known as a "boostagram".
    #[serde(default)]
    message: Value,
    /// App-specific URL containing route to podcast, episode, and/or timestamp at time of the action. The use case for this is sending a link along with the payment that will take the recipient to the exact playback position within the episode where the payment was sent.
    #[serde(default)]
    boost_link: Value,
    /// Optionally, this field can contain a signature for the payment, to be able to verify that the user who sent it is actually who they claim in the sender_id field. If the sender_id contains a Nostr public key, this field should contain a Nostr sig value as a 64-byte encoded hex string. For the purpose of generating the Nostr signature, the following data should be serialized: [0,sender_id,ts,1,[],message] to conform to the NIP-01 specification. The resulting serialized string should be hashed with sha256 to obtain the value.
    #[serde(rename = "signature", default)]
    payment_signature: Value,
    /// UUID of a payment sent out to a single recipient.
    #[serde(rename = "uuid", default)]
    payment_id: Value,
    /// UUID for the boost/stream/auto payment. If there are several recipients, the same identifier should be sent to all of them.
    #[serde(rename = "boost_uuid", default)]
    boost_id: Value,

    /// REMOTE INFO
    ///
    /// Sometimes a payment will be sent to a feed's value block because a different feed referenced it in a <podcast:valueTimeSplit> tag. When that happens, this field will contain the guid of the referencing feed.
    #[serde(default)]
    remote_feed_guid: Value,
    /// The Split Kit does weird stuff.
    #[serde(default, rename = "remoteFeedGuid")]
    remote_feed_guid_camelcase: Value,
    /// Sometimes a payment will be sent to an episode's value block because a different feed referenced it in a <podcast:valueTimeSplit> tag. When that happens, this field will contain the guid of the referencing feed's `<item>`.
    #[serde(default)]
    remote_item_guid: Value,
    /// The Split Kit does weird stuff.
    #[serde(default, rename = "remoteItemGuid")]
    remote_item_guid_camelcase: Value,

    /// REPLY INFO
    ///
    /// The pubkey of the lightning node that can receive payments for the sender. The node given must be capable of receiving keysend payments. If this field contains an "@" symbol, it should be interpreted as a "lightning address".
    #[serde(default)]
    reply_address: Value,
    /// The custom key for routing a reply payment to the sender. This field should not be present if it is not required for payment routing.
    #[serde(default)]
    reply_custom_key: Value,
    /// The custom value for routing a reply payment to the sender. This field should not be present if it is not required for payment routing.
    #[serde(default)]
    reply_custom_value: Value,
}

/// Well-formed bLIP-10 TLV record.
#[derive(Debug, serde::Serialize, Clone)]
pub struct Record {
    /// ACTION
    pub action: Action,

    /// FEED IDENTIFIER
    ///
    ///  The `<podcast:guid>` tag.
    #[serde(rename = "guid", skip_serializing_if = "Option::is_none")]
    pub feed_guid: Option<Uuid>,
    /// title of the feed
    #[serde(rename = "podcast", skip_serializing_if = "Option::is_none")]
    pub feed_name: Option<String>,
    /// ID of podcast in PodcastIndex.org directory
    #[serde(rename = "feedID", skip_serializing_if = "Option::is_none")]
    pub feed_pi_id: Option<u64>,
    /// RSS feed URL of podcast
    #[serde(rename = "url", skip_serializing_if = "Option::is_none")]
    pub feed_url: Option<Url>,

    /// ITEM IDENTIFIER
    ///
    /// The `<item:guid>` tag.
    #[serde(rename = "episode_guid", skip_serializing_if = "Option::is_none")]
    pub item_guid: Option<String>,
    /// title of the item
    #[serde(rename = "episode", skip_serializing_if = "Option::is_none")]
    pub item_name: Option<String>,
    /// ID of the item in PodcastIndex.org directory
    #[serde(rename = "itemID", skip_serializing_if = "Option::is_none")]
    pub item_pi_id: Option<u64>,

    /// PLAYBACK INFO
    ///
    ///  Timestamp of when the payment was sent, in seconds, as an offset from zero (i.e. - playback position).
    #[serde(
        rename = "ts",
        skip_serializing_if = "Option::is_none",
        serialize_with = "serialize_duration_to_seconds"
    )]
    pub timestamp_seconds: Option<Duration>,
    /// Timestamp of when the payment was sent, in HH:MM:SS notation, as an offset from 00:00:00 (i.e. - playback position).
    #[serde(
        rename = "time",
        skip_serializing_if = "Option::is_none",
        serialize_with = "serialize_duration_to_timestamp"
    )]
    pub timestamp_hhmmss: Option<Duration>,
    /// Speed in which the podcast was playing, in decimal notation at the time the payment was sent. So 0.5 is half speed and 2 is double speed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub speed: Option<f64>,

    /// APP INFO
    ///
    /// Name of sending app
    #[serde(skip_serializing_if = "Option::is_none")]
    pub app_name: Option<String>,
    /// Version of sending app
    #[serde(skip_serializing_if = "Option::is_none")]
    pub app_version: Option<String>,

    /// SENDER INFO
    ///
    /// Name of the sender (free text, not validated in any way)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sender_name: Option<String>,
    /// Static random identifier for users, not displayed by apps to prevent abuse. Apps can set this per-feed or app-wide. A GUID-like random identifier or a hash works well. Max 32 bytes (64 ascii characters). This can be a Nostr hex encoded pubkey (not NIP-19) for purposes of sender attribution.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sender_id: Option<String>,

    /// RECEIVER INFO
    ///
    /// Name for this split in value tag
    #[serde(rename = "name", skip_serializing_if = "Option::is_none")]
    pub receiver_name: Option<String>,

    /// PAYMENT INFO
    ///
    /// Total number of millisats for the payment before any fees are subtracted. This should be the number the listener entered into the app. Preserving this value is important for numerology reasons. Certain numeric values can have significance to the sender and/or receiver, so giving a way to show this is critical.
    #[serde(rename = "value_msat_total", skip_serializing_if = "Option::is_none")]
    pub total_num_millisats: Option<u64>,
    /// Text message to add to the payment. When this field is present, the payment is known as a "boostagram".
    #[serde(skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
    /// App-specific URL containing route to podcast, episode, and/or timestamp at time of the action. The use case for this is sending a link along with the payment that will take the recipient to the exact playback position within the episode where the payment was sent.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub boost_link: Option<Url>,
    /// Optionally, this field can contain a signature for the payment, to be able to verify that the user who sent it is actually who they claim in the sender_id field. If the sender_id contains a Nostr public key, this field should contain a Nostr sig value as a 64-byte encoded hex string. For the purpose of generating the Nostr signature, the following data should be serialized: [0,sender_id,ts,1,[],message] to conform to the NIP-01 specification. The resulting serialized string should be hashed with sha256 to obtain the value.
    #[serde(rename = "signature", skip_serializing_if = "Option::is_none")]
    pub payment_signature: Option<String>,
    /// UUID of a payment sent out to a single recipient.
    #[serde(rename = "uuid", skip_serializing_if = "Option::is_none")]
    pub payment_id: Option<Uuid>,
    /// UUID for the boost/stream/auto payment. If there are several recipients, the same identifier should be sent to all of them.
    #[serde(rename = "boost_uuid", skip_serializing_if = "Option::is_none")]
    pub boost_id: Option<Uuid>,

    /// REMOTE INFO
    ///
    /// Sometimes a payment will be sent to a feed's value block because a different feed referenced it in a <podcast:valueTimeSplit> tag. When that happens, this field will contain the guid of the referencing feed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub remote_feed_guid: Option<Uuid>,
    /// Sometimes a payment will be sent to an episode's value block because a different feed referenced it in a <podcast:valueTimeSplit> tag. When that happens, this field will contain the guid of the referencing feed's `<item>`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub remote_item_guid: Option<String>,

    /// REPLY INFO
    ///
    /// The pubkey of the lightning node that can receive payments for the sender. The node given must be capable of receiving keysend payments. If this field contains an "@" symbol, it should be interpreted as a "lightning address".
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reply_address: Option<String>,
    /// The custom key for routing a reply payment to the sender. This field should not be present if it is not required for payment routing.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reply_custom_key: Option<String>,
    /// The custom value for routing a reply payment to the sender. This field should not be present if it is not required for payment routing.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reply_custom_value: Option<String>,
}

fn json_value_to_string(value: Value) -> Option<String> {
    match value {
        Value::String(string) => {
            let string = string.trim();
            if string.is_empty() {
                None
            } else {
                Some(string.to_string())
            }
        }
        Value::Number(number) => Some(number.to_string()),
        _ => None,
    }
}

fn json_value_to_u64(value: Value) -> Option<u64> {
    match value {
        Value::String(string) => string.parse().ok(),
        Value::Number(number) => number.as_u64(),
        _ => None,
    }
}

fn json_value_to_url(value: Value) -> Option<Url> {
    match value {
        Value::String(string) => Url::parse(&string).ok(),
        _ => None,
    }
}

fn json_value_to_uuid(value: Value) -> Option<Uuid> {
    match value {
        Value::String(string) => Uuid::parse_str(&string).ok(),
        _ => None,
    }
}

fn json_value_to_f64(value: Value) -> Option<f64> {
    match value {
        Value::Number(number) => number.as_f64(),
        _ => None,
    }
}

/// Parse "HH:MM:SS" into [chrono::Duration].
fn parse_timestamp_seconds(value: Value) -> Option<Duration> {
    let hhmmss = match value {
        Value::String(string) => string,
        _ => return None,
    };

    let split: Vec<&str> = hhmmss.split(':').collect();

    if split.len() != 3 {
        return None;
    }

    let hours: i64 = split[0].parse().ok()?;
    let minutes: i64 = split[1].parse().ok()?;
    let seconds: i64 = split[2].parse().ok()?;

    Some(Duration::hours(hours) + Duration::minutes(minutes) + Duration::seconds(seconds))
}

/// Parse seconds into [chrono::Duration].
fn parse_seconds(value: Value) -> Option<Duration> {
    match value {
        Value::Number(number) => {
            let seconds = number.as_f64();
            if let Some(seconds) = seconds {
                let milliseconds = seconds * 1000.0;
                Some(Duration::milliseconds(milliseconds as i64))
            } else {
                None
            }
        }
        _ => None,
    }
}

/// Serialize [chrono::Duration] into "HH:MM:SS".
fn serialize_duration_to_timestamp<S>(
    duration: &Option<Duration>,
    serializer: S,
) -> Result<S::Ok, S::Error>
where
    S: serde::Serializer,
{
    let duration = match duration {
        Some(duration) => duration,
        None => return serializer.serialize_none(),
    };

    let hours = duration.num_hours();
    let minutes = duration.num_minutes() - hours * 60;
    let seconds = duration.num_seconds() - hours * 3600 - minutes * 60;

    let formatted = format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
    serializer.serialize_str(&formatted)
}

/// Serialize [chrono::Duration] into seconds.
pub fn serialize_duration_to_seconds<S>(
    duration: &Option<Duration>,
    serializer: S,
) -> Result<S::Ok, S::Error>
where
    S: serde::Serializer,
{
    let duration = match duration {
        Some(duration) => duration,
        None => return serializer.serialize_none(),
    };

    // If integer, serialize as integer.
    if duration.num_milliseconds() % 1000 == 0 {
        serializer.serialize_u64(duration.num_seconds() as u64)
    } else {
        serializer.serialize_f64(duration.num_seconds() as f64)
    }
}

/// Deserialize seconds into [chrono::Duration].
pub fn deserialize_seconds<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let value = match serde::Deserialize::deserialize(deserializer) {
        Ok(value) => value,
        Err(_) => return Ok(None),
    };

    match value {
        Value::Number(number) => {
            let seconds = number.as_f64();
            if let Some(seconds) = seconds {
                let milliseconds = seconds * 1000.0;
                Ok(Some(Duration::milliseconds(milliseconds as i64)))
            } else {
                Ok(None)
            }
        }
        _ => Ok(None),
    }
}

impl From<UntrustedRecord> for Record {
    fn from(record: UntrustedRecord) -> Self {
        Self {
            action: match record.action {
                Value::String(string) => match string.as_str() {
                    "boost" => Action::Boost,
                    "stream" => Action::Stream,
                    "auto" => Action::Auto,
                    _ => Action::Boost,
                },
                _ => Action::Boost,
            },
            feed_guid: json_value_to_uuid(record.feed_guid),
            feed_name: json_value_to_string(record.feed_name),
            feed_pi_id: json_value_to_u64(record.feed_pi_id),
            feed_url: json_value_to_url(record.feed_url),
            item_guid: json_value_to_string(record.item_guid),
            item_name: json_value_to_string(record.item_name),
            item_pi_id: json_value_to_u64(record.item_pi_id),
            timestamp_seconds: parse_seconds(record.timestamp_seconds),
            timestamp_hhmmss: parse_timestamp_seconds(record.timestamp_hhmmss),
            speed: json_value_to_f64(record.speed),
            app_name: json_value_to_string(record.app_name),
            app_version: json_value_to_string(record.app_version),
            sender_name: json_value_to_string(record.sender_name),
            sender_id: json_value_to_string(record.sender_id),
            receiver_name: json_value_to_string(record.receiver_name),
            total_num_millisats: json_value_to_u64(record.total_num_millisats),
            message: json_value_to_string(record.message),
            boost_link: json_value_to_url(record.boost_link),
            payment_signature: json_value_to_string(record.payment_signature),
            payment_id: json_value_to_uuid(record.payment_id),
            boost_id: json_value_to_uuid(record.boost_id),
            remote_feed_guid: match (
                json_value_to_uuid(record.remote_feed_guid),
                json_value_to_uuid(record.remote_feed_guid_camelcase),
            ) {
                (Some(guid), _) => Some(guid),
                (_, Some(guid)) => Some(guid),
                _ => None,
            },
            remote_item_guid: match (
                json_value_to_string(record.remote_item_guid),
                json_value_to_string(record.remote_item_guid_camelcase),
            ) {
                (Some(guid), _) => Some(guid),
                (_, Some(guid)) => Some(guid),
                _ => None,
            },
            reply_address: json_value_to_string(record.reply_address),
            reply_custom_key: json_value_to_string(record.reply_custom_key),
            reply_custom_value: json_value_to_string(record.reply_custom_value),
        }
    }
}

/// Deserialize a bLIP-10 TLV record from an untrusted source.
pub fn deserialize_untrusted_tlv_record<'de, D>(deserializer: D) -> Result<Option<Record>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let value = match serde::Deserialize::deserialize(deserializer) {
        Ok(value) => value,
        Err(_) => return Ok(None),
    };

    let untrusted_record: UntrustedRecord = match serde_json::from_value(value) {
        Ok(untrusted_record) => untrusted_record,
        Err(_) => return Ok(None),
    };

    Ok(Some(untrusted_record.into()))
}