pleezer 0.5.0

Headless Deezer Connect player
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
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
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
//! Track management and playback preparation.
//!
//! This module handles Deezer track operations including:
//! * Track metadata management
//! * Media source retrieval
//! * Download management
//! * Format handling
//! * Encryption detection
//!
//! # Track Lifecycle
//!
//! 1. Creation
//!    * From gateway API response
//!    * Contains metadata and tokens
//!
//! 2. Media Source Resolution
//!    * Retrieves download URLs
//!    * Negotiates quality/format
//!    * Validates availability
//!
//! 3. Download Management
//!    * Background downloading
//!    * Progress tracking
//!    * Buffer management
//!
//! # Quality Fallback
//!
//! When requested quality isn't available, the system attempts fallback in order:
//! * FLAC → MP3 320 → MP3 128 → MP3 64
//! * MP3 320 → MP3 128 → MP3 64
//! * MP3 128 → MP3 64
//!
//! # Integration
//!
//! Works with:
//! * [`player`](crate::player) - For playback management
//! * [`gateway`](crate::gateway) - For track metadata
//! * [`decrypt`](crate::decrypt) - For encrypted content
//!
//! # Example
//!
//! ```rust
//! use pleezer::track::Track;
//!
//! // Create track from gateway data
//! let mut track = Track::from(track_data);
//!
//! // Get media source
//! let medium = track.get_medium(&client, &media_url, quality, license_token).await?;
//!
//! // Start download
//! track.start_download(&client, &medium).await?;
//!
//! // Monitor progress
//! println!("Downloaded: {:?} of {:?}", track.buffered(), track.duration());
//! ```

use std::{
    fmt,
    num::NonZeroI64,
    sync::{Arc, Mutex, PoisonError},
    time::{Duration, SystemTime},
};

use stream_download::{
    self, http::HttpStream, source::SourceStream, storage::temp::TempStorageProvider,
    StreamDownload, StreamHandle, StreamPhase, StreamState,
};
use time::OffsetDateTime;
use url::Url;

use crate::{
    error::{Error, Result},
    http,
    protocol::{
        connect::AudioQuality,
        gateway,
        media::{self, Cipher, CipherFormat, Data, Format, Medium},
    },
    util::ToF32,
};

/// A unique identifier for a track.
///
/// * Positive IDs: Regular Deezer tracks
/// * Negative IDs: User-uploaded tracks
#[expect(clippy::module_name_repetitions)]
pub type TrackId = NonZeroI64;

/// Represents a Deezer track with metadata and download state.
///
/// Combines track metadata (title, artist, etc) with download management
/// functionality including quality settings, buffering state, and
/// encryption information.
///
/// # Example
///
/// ```rust
/// use pleezer::track::Track;
///
/// let track = Track::from(track_data);
/// println!("Track: {} by {}", track.title(), track.artist());
/// println!("Duration: {:?}", track.duration());
/// ```
#[derive(Debug)]
pub struct Track {
    /// Unique identifier for the track.
    /// Negative values indicate user-uploaded content.
    id: TrackId,

    /// Authentication token specific to this track.
    /// Required for media access requests.
    track_token: String,

    /// Title of the track.
    title: String,

    /// Main artist name.
    artist: String,

    /// Title of the album containing this track.
    album_title: String,

    /// Identifier for the album's cover artwork.
    /// Used to construct cover image URLs.
    album_cover: String,

    /// Replay gain value in decibels.
    /// Used for volume normalization if available.
    gain: Option<f32>,

    /// When this track's access token expires.
    /// After this time, new tokens must be requested.
    expiry: SystemTime,

    /// Current audio quality setting.
    /// May be lower than requested if higher quality unavailable.
    quality: AudioQuality,

    /// Total duration of the track.
    duration: Duration,

    /// Amount of audio data downloaded and available for playback.
    /// Protected by mutex for concurrent access from download task.
    buffered: Arc<Mutex<Duration>>,

    /// Total size of the audio file in bytes.
    /// Available only after download begins.
    file_size: Option<u64>,

    /// Encryption cipher used for this track.
    /// `Cipher::NONE` represents unencrypted content.
    cipher: Cipher,

    /// Handle to active download if any.
    /// None if download hasn't started or was reset.
    handle: Option<StreamHandle>,
}

impl Track {
    /// Amount of audio to buffer before playback can start.
    ///
    /// This helps prevent playback interruptions by ensuring
    /// enough audio data is available.
    const PREFETCH_LENGTH: Duration = Duration::from_secs(3);

    /// Default prefetch size in bytes when Content-Length is unknown.
    ///
    /// Used when server doesn't provide file size. Value matches
    /// official Deezer client behavior.
    const PREFETCH_DEFAULT: usize = 60 * 1024;

    /// Returns the track's unique identifier.
    #[must_use]
    pub fn id(&self) -> TrackId {
        self.id
    }

    /// Returns the track duration.
    ///
    /// The duration represents the total playback time of the track.
    #[must_use]
    pub fn duration(&self) -> Duration {
        self.duration
    }

    /// Returns the track's replay gain value if available.
    ///
    /// Replay gain is used for volume normalization:
    /// * Positive values indicate track is quieter than reference
    /// * Negative values indicate track is louder than reference
    /// * None indicates no gain information available
    #[must_use]
    pub fn gain(&self) -> Option<f32> {
        self.gain
    }

    /// Returns the track title.
    #[must_use]
    pub fn title(&self) -> &str {
        &self.title
    }

    /// Returns the track artist name.
    #[must_use]
    pub fn artist(&self) -> &str {
        &self.artist
    }

    /// Returns the album title for this track.
    #[must_use]
    pub fn album_title(&self) -> &str {
        &self.album_title
    }

    /// The ID of the album cover image.
    ///
    /// This ID can be used to construct a URL for retrieving the album cover image.
    /// Album covers are always square and available in various resolutions up to 1920x1920.
    ///
    /// # URL Format
    /// ```text
    /// https://e-cdns-images.dzcdn.net/images/cover/{album_cover}/{resolution}x{resolution}.{format}
    /// ```
    /// where:
    /// - `{album_cover}` is the ID returned by this method
    /// - `{resolution}` is the desired resolution in pixels (e.g., 500)
    /// - `{format}` is either `jpg` or `png`
    ///
    /// # Recommended Usage
    /// - Default resolution: 500x500
    /// - Default format: `jpg` (smaller file size)
    /// - Alternative: `png` (higher quality but larger file size)
    ///
    /// # Example
    /// ```text
    /// https://e-cdns-images.dzcdn.net/images/cover/f286f9e7dc818e181c37b944e2461101/500x500.jpg
    /// ```
    #[must_use]
    pub fn album_cover(&self) -> &str {
        &self.album_cover
    }

    /// Returns the track's expiration time.
    ///
    /// After this time, the track becomes unavailable for download
    /// and may need token refresh.
    #[must_use]
    pub fn expiry(&self) -> SystemTime {
        self.expiry
    }

    /// Returns the duration of audio data currently buffered.
    ///
    /// This represents how much of the track has been downloaded and
    /// is available for playback.
    ///
    /// # Panics
    ///
    /// Returns last known value if lock is poisoned due to download task panic.
    #[must_use]
    pub fn buffered(&self) -> Duration {
        // Return the buffered duration, or when the lock is poisoned because
        // the download task panicked, return the last value before the panic.
        // Practically, this should mean that this track will never be fully
        // buffered.
        *self.buffered.lock().unwrap_or_else(PoisonError::into_inner)
    }

    /// Returns the track's audio quality.
    #[must_use]
    pub fn quality(&self) -> AudioQuality {
        self.quality
    }

    /// Returns the encryption cipher used for this track.
    #[must_use]
    pub fn cipher(&self) -> Cipher {
        self.cipher
    }

    /// Returns whether the track is encrypted.
    ///
    /// True if the track uses any cipher other than NONE.
    #[must_use]
    pub fn is_encrypted(&self) -> bool {
        self.cipher != Cipher::NONE
    }

    /// Returns whether this track uses lossless audio encoding.
    ///
    /// True only for FLAC encoded tracks.
    #[must_use]
    pub fn is_lossless(&self) -> bool {
        self.quality == AudioQuality::Lossless
    }

    /// Cipher format for 64kbps MP3 files using Blowfish CBC stripe encryption.
    const BF_CBC_STRIPE_MP3_64: CipherFormat = CipherFormat {
        cipher: Cipher::BF_CBC_STRIPE,
        format: Format::MP3_64,
    };

    /// Cipher format for 128kbps MP3 files using Blowfish CBC stripe encryption.
    const BF_CBC_STRIPE_MP3_128: CipherFormat = CipherFormat {
        cipher: Cipher::BF_CBC_STRIPE,
        format: Format::MP3_128,
    };

    /// Cipher format for 320kbps MP3 files using Blowfish CBC stripe encryption.
    const BF_CBC_STRIPE_MP3_320: CipherFormat = CipherFormat {
        cipher: Cipher::BF_CBC_STRIPE,
        format: Format::MP3_320,
    };

    /// Cipher format for MP3 files with unknown bitrate using Blowfish CBC stripe encryption.
    const BF_CBC_STRIPE_MP3_MISC: CipherFormat = CipherFormat {
        cipher: Cipher::BF_CBC_STRIPE,
        format: Format::MP3_MISC,
    };

    /// Cipher format for FLAC files using Blowfish CBC stripe encryption.
    const BF_CBC_STRIPE_FLAC: CipherFormat = CipherFormat {
        cipher: Cipher::BF_CBC_STRIPE,
        format: Format::FLAC,
    };

    /// Available cipher formats for basic quality.
    const CIPHER_FORMATS_MP3_64: [CipherFormat; 2] =
        [Self::BF_CBC_STRIPE_MP3_64, Self::BF_CBC_STRIPE_MP3_MISC];

    /// Available cipher formats for standard quality.
    const CIPHER_FORMATS_MP3_128: [CipherFormat; 3] = [
        Self::BF_CBC_STRIPE_MP3_128,
        Self::BF_CBC_STRIPE_MP3_64,
        Self::BF_CBC_STRIPE_MP3_MISC,
    ];

    /// Available cipher formats for high quality.
    const CIPHER_FORMATS_MP3_320: [CipherFormat; 4] = [
        Self::BF_CBC_STRIPE_MP3_320,
        Self::BF_CBC_STRIPE_MP3_128,
        Self::BF_CBC_STRIPE_MP3_64,
        Self::BF_CBC_STRIPE_MP3_MISC,
    ];

    /// Available cipher formats for lossless quality.
    const CIPHER_FORMATS_FLAC: [CipherFormat; 5] = [
        Self::BF_CBC_STRIPE_FLAC,
        Self::BF_CBC_STRIPE_MP3_320,
        Self::BF_CBC_STRIPE_MP3_128,
        Self::BF_CBC_STRIPE_MP3_64,
        Self::BF_CBC_STRIPE_MP3_MISC,
    ];

    /// API endpoint for retrieving media sources.
    const MEDIA_ENDPOINT: &'static str = "v1/get_url";

    /// Retrieves a media source for the track.
    ///
    /// Attempts to get download URLs for the requested quality level,
    /// falling back to lower qualities if necessary.
    ///
    /// # Arguments
    ///
    /// * `client` - HTTP client for API requests
    /// * `media_url` - Base URL for media content
    /// * `quality` - Preferred audio quality
    /// * `license_token` - Token authorizing media access
    ///
    /// # Errors
    ///
    /// Returns error if:
    /// * Track has expired
    /// * Quality level is unknown
    /// * Media source unavailable
    /// * Network request fails
    ///
    /// # Quality Fallback
    ///
    /// If requested quality unavailable, attempts lower qualities in order:
    /// * FLAC → MP3 320 → MP3 128 → MP3 64
    /// * MP3 320 → MP3 128 → MP3 64
    /// * MP3 128 → MP3 64
    pub async fn get_medium(
        &self,
        client: &http::Client,
        media_url: &Url,
        quality: AudioQuality,
        license_token: impl Into<String>,
    ) -> Result<Medium> {
        if self.expiry <= SystemTime::now() {
            return Err(Error::unavailable(format!(
                "track {self} no longer available since {}",
                OffsetDateTime::from(self.expiry)
            )));
        }

        let cipher_formats = match quality {
            AudioQuality::Basic => Self::CIPHER_FORMATS_MP3_64.to_vec(),
            AudioQuality::Standard => Self::CIPHER_FORMATS_MP3_128.to_vec(),
            AudioQuality::High => Self::CIPHER_FORMATS_MP3_320.to_vec(),
            AudioQuality::Lossless => Self::CIPHER_FORMATS_FLAC.to_vec(),
            AudioQuality::Unknown => {
                return Err(Error::unknown("unknown audio quality for track {self}"));
            }
        };

        let request = media::Request {
            license_token: license_token.into(),
            track_tokens: vec![self.track_token.clone()],
            media: vec![media::Media {
                typ: media::Type::FULL,
                cipher_formats,
            }],
        };

        // Do not use `client.unlimited` but instead apply rate limiting.
        // This is to prevent hammering the Deezer API in case of deserialize errors.
        let get_url = media_url.join(Self::MEDIA_ENDPOINT)?;
        let body = serde_json::to_string(&request)?;
        let request = client.post(get_url, body);
        let response = client.execute(request).await?;
        let result = response.json::<media::Response>().await?;

        // Deezer only sends a single media object.
        let result = match result.data.first() {
            Some(data) => match data {
                Data::Media { media } => media.first().cloned().ok_or(Error::not_found(
                    format!("empty media data for track {self}"),
                ))?,
                Data::Errors { errors } => {
                    return Err(Error::unavailable(errors.first().map_or_else(
                        || format!("unknown error getting media for track {self}"),
                        ToString::to_string,
                    )));
                }
            },
            None => return Err(Error::not_found(format!("no media data for track {self}"))),
        };

        trace!("get_url: {result:#?}");

        let available_quality = AudioQuality::from(result.format);

        // User-uploaded tracks are not reported with any quality. We could estimate the quality
        // based on the bitrate, but the official client does not do this either.
        if !self.is_user_uploaded() && quality != available_quality {
            warn!(
                "requested track {self} in {}, but got {}",
                quality, available_quality
            );
        }

        Ok(result)
    }

    /// Returns whether this is a user-uploaded track.
    ///
    /// User-uploaded tracks are identified by negative IDs and may
    /// have different availability and quality characteristics.
    #[must_use]
    pub fn is_user_uploaded(&self) -> bool {
        self.id.is_negative()
    }

    /// Opens a stream for downloading the track content.
    ///
    /// Attempts to open the first available source URL, falling back
    /// to alternatives if needed.
    ///
    /// # Arguments
    ///
    /// * `client` - HTTP client for making requests
    /// * `medium` - Media source information
    ///
    /// # Errors
    ///
    /// Returns error if:
    /// * No valid sources available
    /// * Track expired or not yet available
    /// * Network error occurs
    async fn open_stream(
        &self,
        client: &http::Client,
        medium: &Medium,
    ) -> Result<HttpStream<reqwest::Client>> {
        let mut result = Err(Error::unavailable(format!(
            "no valid sources found for track {self}"
        )));

        let now = SystemTime::now();

        // Deezer usually returns multiple sources for a track. The official
        // client seems to always use the first one. We start with the first
        // and continue with the next one if the first one fails to start.
        #[expect(clippy::iter_next_slice)]
        while let Some(source) = medium.sources.iter().next() {
            // URLs can theoretically be non-HTTP, and we only support HTTP(S) URLs.
            let Some(host_str) = source.url.host_str() else {
                warn!("skipping source with invalid host for track {self}");
                continue;
            };

            // Check if the track is in a timeframe where it can be downloaded.
            // If not, it can be that the download link expired and needs to be
            // refreshed, that the track is not available yet, or that the track is
            // no longer available.
            if medium.not_before > now {
                warn!(
                    "track {self} is not available for download until {} from {host_str}",
                    OffsetDateTime::from(medium.not_before)
                );
                continue;
            }
            if medium.expiry <= now {
                warn!(
                    "track {self} is no longer available for download since {} from {host_str}",
                    OffsetDateTime::from(medium.expiry)
                );
                continue;
            }

            // Perform the request and stream the response.
            match HttpStream::new(client.unlimited.clone(), source.url.clone()).await {
                Ok(http_stream) => {
                    debug!("starting download of track {self} from {host_str}");
                    result = Ok(http_stream);
                    break;
                }
                Err(err) => {
                    warn!("failed to start download of track {self} from {host_str}: {err}",);
                    continue;
                }
            };
        }

        result
    }

    /// Starts downloading the track.
    ///
    /// Initiates a background download task that:
    /// * Streams content from source
    /// * Tracks download progress
    /// * Updates buffer state
    /// * Enables playback before completion
    ///
    /// # Arguments
    ///
    /// * `client` - HTTP client for download
    /// * `medium` - Media source information
    ///
    /// # Errors
    ///
    /// Returns error if:
    /// * No valid source found
    /// * Track unavailable
    /// * Network error occurs
    /// * Download cannot start
    ///
    /// # Progress Tracking
    ///
    /// Download progress is tracked via:
    /// * `buffered()` - Amount downloaded
    /// * `is_complete()` - Download status
    /// * `file_size()` - Total size if known
    ///
    /// # Panics
    ///
    /// * When the buffered duration mutex is poisoned in the progress callback
    /// * When duration calculation overflows during progress calculation
    pub async fn start_download(
        &mut self,
        client: &http::Client,
        medium: &Medium,
    ) -> Result<StreamDownload<TempStorageProvider>> {
        let stream = self.open_stream(client, medium).await?;

        // Set actual audio quality and cipher type.
        self.quality = medium.format.into();
        self.cipher = medium.cipher.typ;

        // Calculate the prefetch size based on the audio quality. This assumes
        // that the track is encoded with a constant bitrate, which is not
        // necessarily true. However, it is a good approximation.
        let mut prefetch_size = None;
        if let Some(file_size) = stream.content_length() {
            info!("downloading {file_size} bytes for track {self}");
            self.file_size = Some(file_size);

            if !self.duration.is_zero() {
                let size = Self::PREFETCH_LENGTH.as_secs()
                    * file_size.saturating_div(self.duration.as_secs());
                trace!("prefetch size for track {self}: {size} bytes");
                prefetch_size = Some(size);
            }
        } else {
            info!("downloading track {self} with unknown file size");
        };
        let prefetch_size = prefetch_size.unwrap_or(Self::PREFETCH_DEFAULT as u64);

        // A progress callback that logs the download progress.
        let track_str = self.to_string();
        let duration = self.duration;
        let buffered = Arc::clone(&self.buffered);
        let callback = move |stream: &HttpStream<_>,
                             stream_state: StreamState,
                             _: &tokio_util::sync::CancellationToken| {
            if stream_state.phase == StreamPhase::Complete {
                info!("completed download of track {track_str}");

                // Prevent rounding errors and set the buffered duration
                // equal to the total duration. It's OK to unwrap here: if
                // the mutex is poisoned, then the main thread panicked and
                // we should propagate the error.
                *buffered.lock().unwrap() = duration;
            } else if let Some(file_size) = stream.content_length() {
                if file_size > 0 {
                    // `f64` not for precision, but to be able to fit
                    // as big as possible file sizes.
                    // TODO : use `Percentage` type
                    #[expect(clippy::cast_precision_loss)]
                    let progress = stream_state.current_position as f64 / file_size as f64;

                    // OK to unwrap: see rationale above.
                    *buffered.lock().unwrap() = duration.mul_f64(progress);
                }
            }
        };

        // Start the download. The `await` here will *not* block until the download is complete,
        // but only until the download is started. The download will continue in the background.
        let download = StreamDownload::from_stream(
            stream,
            TempStorageProvider::default(),
            stream_download::Settings::default()
                .on_progress(callback)
                .prefetch_bytes(prefetch_size),
        )
        .await?;

        self.handle = Some(download.handle());
        Ok(download)
    }

    /// Returns a handle to the track's download if active.
    ///
    /// Returns None if download hasn't started.
    #[must_use]
    pub fn handle(&self) -> Option<StreamHandle> {
        self.handle.clone()
    }

    /// Returns whether the track download is complete.
    ///
    /// A track is complete when the buffered duration equals
    /// the total track duration.
    #[must_use]
    pub fn is_complete(&self) -> bool {
        self.buffered().as_secs() == self.duration.as_secs()
    }

    /// Resets the track's download state.
    ///
    /// Clears:
    /// * Download handle
    /// * File size information
    /// * Buffer progress
    ///
    /// Useful when needing to restart an interrupted download.
    ///
    /// # Panics
    ///
    /// Panics if the buffered lock is poisoned.
    pub fn reset_download(&mut self) {
        self.handle = None;
        self.file_size = None;
        *self.buffered.lock().unwrap() = Duration::ZERO;
    }

    /// Returns the total file size if known.
    ///
    /// Size becomes available after download starts and server
    /// provides Content-Length.
    #[must_use]
    pub fn file_size(&self) -> Option<u64> {
        self.file_size
    }
}

impl From<gateway::ListData> for Track {
    /// Creates a Track from gateway list data.
    ///
    /// Initializes track with:
    /// * Basic metadata (ID, title, artist, etc)
    /// * Default quality (Standard)
    /// * Default cipher (`BF_CBC_STRIPE`)
    /// * Empty download state
    fn from(item: gateway::ListData) -> Self {
        Self {
            id: item.track_id,
            track_token: item.track_token,
            title: item.title.to_string(),
            artist: item.artist.to_string(),
            album_title: item.album_title.to_string(),
            album_cover: item.album_cover,
            duration: item.duration,
            gain: item.gain.map(ToF32::to_f32_lossy),
            expiry: item.expiry,
            quality: AudioQuality::Standard,
            buffered: Arc::new(Mutex::new(Duration::ZERO)),
            file_size: None,
            cipher: Cipher::BF_CBC_STRIPE,
            handle: None,
        }
    }
}

impl fmt::Display for Track {
    /// Formats track for display, showing ID, artist and title.
    ///
    /// Format: "{id}: "{artist} - {title}""
    ///
    /// # Example
    ///
    /// ```text
    /// 12345: "Artist Name - Track Title"
    /// ```
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}: \"{} - {}\"", self.id, self.artist, self.title)
    }
}