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
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
//! The current song title and artist
//!
//! Also provides buttons for play/pause, previous and next.
//!
//! Supports all music players that implement the [MediaPlayer2 Interface]. This includes:
//!
//! - Spotify
//! - VLC
//! - mpd (via [mpDris2](https://github.com/eonpatapon/mpDris2))
//!
//! and many others.
//!
//! By default the block tracks all players available on the MPRIS bus. Right clicking on the block
//! will cycle it to the next player. You can pin the widget to a given player via the "player"
//! setting.
//!
//! # Configuration
//!
//! Key | Values | Default
//! ----|--------|--------
//! `format` | A string to customise the output of this block. See below for available placeholders. | <code>\" $icon {$combo.str(max_w:25,rot_interval:0.5) $play \|}\"</code>
//! `format_alt` | If set, block will switch between `format` and `format_alt` on every click | `None`
//! `player` | Name(s) of the music player(s) MPRIS interface. This can be either a music player name or an array of music player names. Run <code>busctl \--user list \| grep \"org.mpris.MediaPlayer2.\" \| cut -d\' \' -f1</code> and the name is the part after "org.mpris.MediaPlayer2.". | `None`
//! `interface_name_exclude` | A list of regex patterns for player MPRIS interface names to ignore. | `["playerctld"]`
//! `separator` | String to insert between artist and title. | `" - "`
//! `seek_step_secs` | Positive number of seconds to seek forward/backward when scrolling on the bar. Does not need to be an integer. | `1`
//! `seek_forward_step_secs` | Positive number of seconds to seek forward when scrolling on the bar. Does not need to be an integer. | `seek_step_secs`
//! `seek_backward_step_secs` | Positive number of seconds to seek backward when scrolling on the bar. Does not need to be an integer. | `seek_step_secs`
//! `volume_step` | The percent volume level is increased/decreased for the selected audio device when scrolling. Capped automatically at 50. | `5`
//!
//! Note: All placeholders except `icon` can be absent. See the examples below to learn how to handle this.
//!
//! Placeholder   | Value          | Type
//! --------------|----------------|------
//! `icon`        | A static icon  | Icon
//! `artist`      | Current artist | Text
//! `title`       | Current title  | Text
//! `url`         | Current song url | Text
//! `combo`       | Resolves to "`$artist[sep]$title"`, `"$artist"`, `"$title"`, or `"$url"` depending on what information is available. `[sep]` is set by `separator` option. | Text
//! `player`      | Name of the current player (taken from the last part of its MPRIS bus name) | Text
//! `avail`       | Total number of players available to switch between | Number
//! `cur`         | The current player index of the available players | Number
//! `play`        | Play/Pause button | Clickable icon
//! `next`        | Next button | Clickable icon
//! `prev`        | Previous button | Clickable icon
//! `volume_icon` | Icon based on volume. Missing if unsupported.    | Icon
//! `volume`      | Current volume. Missing if muted or unsupported. | Number
//!
//! Action          | Default button
//! ----------------|------------------
//! `play_pause`    | Left on `$play`
//! `next`          | Left on `$next`
//! `prev`          | Left on `$prev`
//! `next_player`   | Right
//! `seek_forward`  | Wheel Up
//! `seek_backward` | Wheel Down
//! `volume_up`     | -
//! `volume_down`   | -
//! `toggle_format` | Left
//!
//! # Examples
//!
//! Show the currently playing song on Spotify only, with play & next buttons and limit the width
//! to 20 characters:
//!
//! ```toml
//! [[block]]
//! block = "music"
//! format = " $icon {$combo.str(max_w:20) $play $next |}"
//! player = "spotify"
//! ```
//!
//! Same thing for any compatible player, takes the first active on the bus, but ignores "mpd" or anything with "kdeconnect" in the name:
//!
//! ```toml
//! [[block]]
//! block = "music"
//! format = " $icon {$combo.str(max_w:20) $play $next |}"
//! interface_name_exclude = [".*kdeconnect.*", "mpd"]
//! ```
//!
//! Same as above, but displays with rotating text
//!
//! ```toml
//! [[block]]
//! block = "music"
//! format = " $icon {$combo.str(max_w:20,rot_interval:0.5) $play $next |}"
//! interface_name_exclude = [".*kdeconnect.*", "mpd"]
//! ```
//!
//! Click anywhere to play/pause, middle click to toggle format:
//!
//! ```toml
//! [[block]]
//! block = "music"
//! format = " format 1 "
//! format_alt = " format 2 "
//! [[block.click]]
//! button = "left"
//! action = "play_pause"
//! [[block.click]]
//! button = "middle"
//! widget = "."
//! action = "toggle_format"
//! ```
//!
//! Scroll to change the player volume, use the forward and back buttons to seek:
//!
//! ```toml
//! [[block]]
//! block = "music"
//! format = " $icon $volume_icon $combo $play $next| "
//! seek_step_secs = 10
//! [[block.click]]
//! button = "up"
//! action = "volume_up"
//! [[block.click]]
//! button = "down"
//! action = "volume_down"
//! [[block.click]]
//! button = "forward"
//! action = "seek_forward"
//! [[block.click]]
//! button = "back"
//! action = "seek_backward"
//! ```
//!
//! # Icons Used
//! - `music`
//! - `music_next`
//! - `music_play`
//! - `music_prev`
//! - `volume_muted`
//! - `volume` (as a progression)
//!
//! [MediaPlayer2 Interface]: https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html

use super::prelude::*;
use crate::wrappers::DisplaySlice;

use regex::Regex;
use std::fmt;
use zbus::fdo::{DBusProxy, NameOwnerChanged, PropertiesChanged};
use zbus::names::{OwnedBusName, OwnedUniqueName};
use zbus::{MatchRule, MessageStream};

mod zbus_mpris;
mod zbus_playerctld;

make_log_macro!(debug, "music");

const PLAY_PAUSE_BTN: &str = "play_pause_btn";
const NEXT_BTN: &str = "next_btn";
const PREV_BTN: &str = "prev_btn";

#[derive(Deserialize, Debug, SmartDefault)]
#[serde(deny_unknown_fields, default)]
pub struct Config {
    pub format: FormatConfig,
    pub format_alt: Option<FormatConfig>,
    pub player: PlayerName,
    #[default(vec!["playerctld".into()])]
    pub interface_name_exclude: Vec<String>,
    #[default(" - ".into())]
    pub separator: String,
    #[default(1.into())]
    pub seek_step_secs: Seconds<false>,
    pub seek_forward_step_secs: Option<Seconds<false>>,
    pub seek_backward_step_secs: Option<Seconds<false>>,
    #[default(5.0)]
    pub volume_step: f64,
}

#[derive(Deserialize, Debug, Clone, SmartDefault)]
#[serde(untagged)]
pub enum PlayerName {
    Single(String),
    #[default]
    Multiple(Vec<String>),
}

pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
    let mut actions = api.get_actions()?;
    api.set_default_actions(&[
        (MouseButton::Left, Some(PLAY_PAUSE_BTN), "play_pause"),
        (MouseButton::Left, Some(NEXT_BTN), "next"),
        (MouseButton::Left, Some(PREV_BTN), "prev"),
        (MouseButton::Right, None, "next_player"),
        (MouseButton::WheelUp, None, "seek_forward"),
        (MouseButton::WheelDown, None, "seek_backward"),
        (MouseButton::Left, None, "toggle_format"),
    ])?;

    let dbus_conn = new_dbus_connection().await?;

    let mut format = config
        .format
        .with_default(" $icon {$combo.str(max_w:25,rot_interval:0.5) $play |}")?;
    let mut format_alt = match &config.format_alt {
        Some(f) => Some(f.with_default("")?),
        None => None,
    };

    let volume_step = config.volume_step.clamp(0.0, 50.0) / 100.0;

    let seek_forward_step = config
        .seek_forward_step_secs
        .unwrap_or(config.seek_step_secs)
        .0
        .as_micros() as i64;
    let seek_backward_step = -(config
        .seek_backward_step_secs
        .unwrap_or(config.seek_step_secs)
        .0
        .as_micros() as i64);

    let new_btn = |icon: &str, instance: &'static str| -> Result<Value> {
        Ok(Value::icon(icon.to_string()).with_instance(instance))
    };

    let values = map! {
        "icon" => Value::icon("music"),
        "next" => new_btn("music_next", NEXT_BTN)?,
        "prev" => new_btn("music_prev", PREV_BTN)?,
    };

    let preferred_players = match config.player.clone() {
        PlayerName::Single(name) => vec![name],
        PlayerName::Multiple(names) => names,
    };
    let exclude_regex = config
        .interface_name_exclude
        .iter()
        .map(|r| Regex::new(r))
        .collect::<Result<Vec<_>, _>>()
        .error("Invalid regex")?;

    let playerctld_proxy = zbus_playerctld::PlayerctldProxy::new(&dbus_conn)
        .await
        .error("Failed to create PlayerctldProxy")?;

    let mut players = get_players(&dbus_conn, &preferred_players, &exclude_regex).await?;
    let mut cur_player = None;
    if let Ok(playerctld_players) = playerctld_proxy.player_names().await {
        // If we can get the list of players from playerctld then we should
        // take the first matching player (this is the most recently active player)
        for playerctld_player in playerctld_players {
            if let Some(pos) = players
                .iter()
                .position(|p| p.bus_name.as_str() == playerctld_player)
            {
                cur_player = Some(pos);
                break;
            }
        }
    } else {
        // If we couldn't get the players from playerctld then fall back to walking over
        // the players and select the first one found playing something, or the last one
        // in the list (the most recently opened)
        for (i, player) in players.iter().enumerate() {
            cur_player = Some(i);
            if player.status == Some(PlaybackStatus::Playing) {
                break;
            }
        }
    }

    let mut properties_stream = MessageStream::for_match_rule(
        MatchRule::builder()
            .msg_type(zbus::message::Type::Signal)
            .interface("org.freedesktop.DBus.Properties")
            .and_then(|x| x.member("PropertiesChanged"))
            .and_then(|x| x.path("/org/mpris/MediaPlayer2"))
            .unwrap()
            .build(),
        &dbus_conn,
        None,
    )
    .await
    .error("Failed to add match rule")?;

    let mut name_owner_changed_stream = MessageStream::for_match_rule(
        MatchRule::builder()
            .msg_type(zbus::message::Type::Signal)
            .interface("org.freedesktop.DBus")
            .and_then(|x| x.member("NameOwnerChanged"))
            .and_then(|x| x.arg0ns("org.mpris.MediaPlayer2"))
            .unwrap()
            .build(),
        &dbus_conn,
        None,
    )
    .await
    .error("Failed to add match rule")?;

    let mut active_player_change_end_stream = playerctld_proxy
        .receive_active_player_change_end()
        .await
        .error("Failed to create ActivePlayerChangeEndStream")?;

    loop {
        debug!("available players: {}", DisplaySlice(&players));

        let avail = players.len();
        let player = cur_player.map(|c| players.get_mut(c).unwrap());
        match player {
            Some(player) => {
                let mut values = values.clone();
                values.insert("avail".into(), Value::number(avail));
                values.insert("cur".into(), Value::number(cur_player.unwrap() + 1));
                values.insert(
                    "player".into(),
                    Value::text(
                        extract_player_name(player.bus_name.as_str())
                            .unwrap()
                            .into(),
                    ),
                );
                let (state, play_icon) = match player.status {
                    Some(PlaybackStatus::Playing) => (State::Info, "music_pause"),
                    _ => (State::Idle, "music_play"),
                };
                values.insert("play".into(), new_btn(play_icon, PLAY_PAUSE_BTN)?);
                if let Some(url) = &player.metadata.url {
                    values.insert("url".into(), Value::text(url.clone()));
                }
                match (
                    &player.metadata.title,
                    &player.metadata.artist,
                    &player.metadata.url,
                ) {
                    (Some(t), None, _) => {
                        values.insert("combo".into(), Value::text(t.clone()));
                        values.insert("title".into(), Value::text(t.clone()));
                    }
                    (None, Some(a), _) => {
                        values.insert("combo".into(), Value::text(a.clone()));
                        values.insert("artist".into(), Value::text(a.clone()));
                    }
                    (Some(t), Some(a), _) => {
                        values.insert(
                            "combo".into(),
                            Value::text(format!("{t}{}{a}", config.separator)),
                        );
                        values.insert("title".into(), Value::text(t.clone()));
                        values.insert("artist".into(), Value::text(a.clone()));
                    }
                    (None, None, Some(url)) => {
                        values.insert("combo".into(), Value::text(url.clone()));
                    }
                    _ => (),
                }
                if let Some(volume) = player.volume {
                    values.insert(
                        "volume_icon".into(),
                        Value::icon_progression("volume", volume),
                    );
                    values.insert("volume".into(), Value::percents(volume * 100.0));
                }
                let mut widget = Widget::new().with_format(format.clone());
                widget.set_values(values);
                widget.state = state;
                api.set_widget(widget)?;
            }
            None => {
                let mut widget = Widget::new().with_format(format.clone());
                widget.set_values(map!("icon" => Value::icon("music")));
                api.set_widget(widget)?;
            }
        }

        loop {
            select! {
                Some(msg) = properties_stream.next() => {
                    let msg = msg.unwrap();
                    let msg = PropertiesChanged::from_message(msg).unwrap();
                    let args = msg.args().unwrap();
                    let header = msg.message().header();
                    let sender = header.sender().unwrap();
                    if let Some((pos, player)) = players.iter_mut().enumerate().find(|p| &*p.1.owner == sender) {
                        let props = args.changed_properties;
                        if let Some(status) = props.get("PlaybackStatus") {
                            let status: &str = status.downcast_ref().unwrap();
                            player.status = PlaybackStatus::from_str(status);
                        }
                        if let Some(metadata) = props.get("Metadata") {
                            player.metadata =
                                zbus_mpris::PlayerMetadata::try_from(metadata.try_to_owned().unwrap()).unwrap();
                        }
                        if let Some(volume) = props.get("Volume") {
                            player.volume = Some(*volume.downcast_ref::<&f64>().unwrap());
                        }
                        if player.status == Some(PlaybackStatus::Playing)
                        && (
                            player.metadata.title.is_some()
                            || player.metadata.artist.is_some()
                            || player.metadata.url.is_some()
                        ) {
                            cur_player = Some(pos);
                        }
                        break;
                    }
                }
                Some(msg) = name_owner_changed_stream.next() => {
                    let msg = msg.unwrap();
                    let msg = NameOwnerChanged::from_message(msg).unwrap();
                    let args = msg.args().unwrap();
                    match (args.old_owner.as_ref(), args.new_owner.as_ref()) {
                        (None, Some(new)) => {
                            debug!("new player {} owned by {new}", args.name);
                            if player_matches(args.name.as_str(), &preferred_players, &exclude_regex) {
                                match Player::new(&dbus_conn, args.name.to_owned().into(), new.to_owned().into()).await {
                                    Ok(player) => players.push(player),
                                    Err(e) => {
                                        debug!("{e}");
                                    },
                                }
                            }
                        }
                        (Some(old), None) => {
                            if let Some(pos) = players.iter().position(|p| &*p.owner == old) {
                                debug!("removed player {} owned by {old}", args.name);
                                players.remove(pos);
                                if let Some(cur) = cur_player {
                                    if players.is_empty() {
                                        cur_player = None;
                                    } else if pos == cur {
                                        cur_player = Some(0);
                                    } else if pos < cur {
                                        cur_player = Some(cur - 1);
                                    }
                                }
                            }
                        }
                        _ => (),
                    }
                    break;
                }
                Some(msg) = active_player_change_end_stream.next() => {
                    let args = msg.args().unwrap();
                    if let Some(pos) = players.iter().position(|p| p.bus_name == args.name){
                        cur_player = Some(pos);
                    }
                    else{
                        // We must have shifted to a player we wanted to skip (on the interface_name_exclude list).
                        // Let's shift again
                        if let Err(e) = playerctld_proxy.shift().await{
                            debug!("{e}");
                        }
                    }
                    break;
                }
                Some(action) = actions.recv() => {
                    if let Some(i) = cur_player {
                        let player = &players[i];
                        match action.as_ref() {
                            "play_pause" => {
                                player.play_pause().await?;
                            }
                            "next" => {
                                player.next().await?;
                            }
                            "prev" => {
                                player.prev().await?;
                            }
                            "next_player" => {
                                cur_player = Some((i + 1) % players.len());
                                if let Err(e) = playerctld_proxy.shift().await{
                                    debug!("{e}");
                                }
                                break;
                            }
                            "seek_forward" => {
                                player.seek(seek_forward_step).await?;
                            }
                            "seek_backward" => {
                                player.seek(seek_backward_step).await?;
                            }
                            "volume_up" => {
                                player.set_volume(volume_step).await?;
                            }
                            "volume_down" => {
                                player.set_volume(-volume_step).await?;
                            }
                            "toggle_format" => {
                                if let Some(format_alt) = &mut format_alt {
                                    std::mem::swap(format_alt, &mut format);
                                    break;
                                }
                            }
                            _ => (),
                        }
                    }
                }
            }
        }
    }
}

async fn get_players(
    dbus_conn: &zbus::Connection,
    preferred_players: &[String],
    exclude_regex: &[Regex],
) -> Result<Vec<Player>> {
    let proxy = DBusProxy::new(dbus_conn)
        .await
        .error("failed to create DBusProxy")?;
    let names = proxy
        .list_names()
        .await
        .error("failed to list dbus names")?;
    let mut players = Vec::new();
    for name in names {
        if player_matches(name.as_str(), preferred_players, exclude_regex) {
            let owner = proxy.get_name_owner(name.as_ref()).await.unwrap();
            match Player::new(dbus_conn, name, owner).await {
                Ok(player) => players.push(player),
                Err(e) => {
                    debug!("{e}");
                }
            }
        }
    }
    Ok(players)
}

#[derive(Debug)]
struct Player {
    status: Option<PlaybackStatus>,
    owner: OwnedUniqueName,
    bus_name: OwnedBusName,
    player_proxy: zbus_mpris::PlayerProxy<'static>,
    metadata: zbus_mpris::PlayerMetadata,
    volume: Option<f64>,
}

impl Player {
    async fn new(
        dbus_conn: &zbus::Connection,
        bus_name: OwnedBusName,
        owner: OwnedUniqueName,
    ) -> Result<Player> {
        debug!("creating Player for {bus_name}");

        let proxy = zbus_mpris::PlayerProxy::builder(dbus_conn)
            .destination(bus_name.clone())
            .error("failed to set proxy destination")?
            .build()
            .await
            .error("failed to open player proxy")?;

        // debug!("querying player info");
        // let (metadata, status, volume) =
        //     tokio::join!(proxy.metadata(), proxy.playback_status(), proxy.volume());
        debug!("querying player metadata");
        let metadata = proxy.metadata().await;
        debug!("querying player status");
        let status = proxy.playback_status().await;
        debug!("querying player volume");
        let volume = proxy.volume().await;

        let metadata = metadata.error("failed to obtain player metadata")?;
        let status = status.error("failed to obtain player status")?;

        debug!("Player created");

        Ok(Self {
            status: PlaybackStatus::from_str(&status),
            owner,
            bus_name,
            player_proxy: proxy,
            metadata,
            volume: volume.ok(),
        })
    }

    async fn play_pause(&self) -> Result<()> {
        self.player_proxy
            .play_pause()
            .await
            .error("play_pause() failed")
    }

    async fn prev(&self) -> Result<()> {
        self.player_proxy.previous().await.error("prev() failed")
    }

    async fn next(&self) -> Result<()> {
        self.player_proxy.next().await.error("next() failed")
    }

    async fn seek(&self, offset: i64) -> Result<()> {
        match self.player_proxy.seek(offset).await {
            Err(zbus::Error::MethodError(e, _, _))
                if e == "org.freedesktop.DBus.Error.NotSupported" =>
            {
                // TODO show this error somehow
                Ok(())
            }
            other => dbg!(other).error("seek() failed"),
        }
    }

    async fn set_volume(&self, step_size: f64) -> Result<()> {
        if let Some(volume) = self.volume {
            self.player_proxy
                .set_volume(volume + step_size)
                .await
                .error("set_volume() failed")?;
        }
        Ok(())
    }
}

impl fmt::Display for Player {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        extract_player_name(&self.bus_name).unwrap().fmt(f)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PlaybackStatus {
    Playing,
    Paused,
    Stopped,
}

impl PlaybackStatus {
    fn from_str(s: &str) -> Option<Self> {
        match s {
            "Paused" => Some(Self::Paused),
            "Playing" => Some(Self::Playing),
            "Stopped" => Some(Self::Stopped),
            _ => None,
        }
    }
}

fn extract_player_name(full_name: &str) -> Option<&str> {
    const NAME_PREFIX: &str = "org.mpris.MediaPlayer2.";
    full_name
        .starts_with(NAME_PREFIX)
        .then(|| &full_name[NAME_PREFIX.len()..])
}

fn player_matches(full_name: &str, preferred_players: &[String], exclude_regex: &[Regex]) -> bool {
    let name = match extract_player_name(full_name) {
        Some(name) => name,
        None => return false,
    };

    exclude_regex.iter().all(|r| !r.is_match(name))
        && (preferred_players.is_empty()
            || preferred_players.iter().any(|p| name.starts_with(&**p)))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn extract_player_name_test() {
        assert_eq!(
            extract_player_name("org.mpris.MediaPlayer2.firefox.instance852"),
            Some("firefox.instance852")
        );
        assert_eq!(
            extract_player_name("not.org.mpris.MediaPlayer2.firefox.instance852"),
            None,
        );
        assert_eq!(
            extract_player_name("org.mpris.MediaPlayer3.firefox.instance852"),
            None,
        );
    }

    #[test]
    fn player_matches_test() {
        let exclude = vec![Regex::new("mpd").unwrap(), Regex::new("firefox.*").unwrap()];
        assert!(player_matches(
            "org.mpris.MediaPlayer2.playerctld",
            &[],
            &exclude
        ));
        assert!(!player_matches(
            "org.mpris.MediaPlayer2.playerctld",
            &["spotify".into()],
            &exclude
        ));
        assert!(!player_matches(
            "org.mpris.MediaPlayer2.firefox.instance852",
            &[],
            &exclude
        ));
    }
}