kopuz-server 0.7.0

A modern, lightweight music player built with Rust and Dioxus.
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
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
//! The unified media-source facade (issue #347, Phase 2).
//!
//! [`MediaSource`] is the SINGLE per-source backend: one impl per source kind
//! (`LocalSource`, the per-remote `JellyfinSource`/`SubsonicSource`/`YtSource`,
//! and a creds-less `OfflineServerSource`). There is no separate remote-client
//! trait — a per-remote impl wraps its raw HTTP client directly, so dispatch
//! happens once in [`resolve`] and adding a service is one new impl.
//!
//! The method split is three-way:
//! * Uniform source-scoped DB ops (favorites, the cache mutators) are default
//!   methods keyed on [`source`](MediaSource::source) — written once, inherited.
//! * The ops every source implements but differently (`add_to_playlist`,
//!   `create_playlist`, `remove_from_playlist`, `resolve_stream`, `validate`,
//!   `fetch_favorites`, `push_favorite`) are required.
//! * Optional, capability-gated ops (`reorder_playlist`, and future
//!   radio/discover/download) default to [`SourceError::Unsupported`]; only a
//!   source that declares the matching [`Capabilities`] flag overrides them, and
//!   the UI gates the affordance on that flag so the default is never reached.
//!
//! Each impl declares its own [`capabilities`](MediaSource::capabilities) — the
//! UI reads them off the resolved source instead of branching on `is_server()`
//! or `match service`. Adding a backend is one impl + its caps literal.
//!
//! Reactivity stays out (this crate is Dioxus-free): callers bump generations /
//! nudge the sync task after a successful op.

use async_trait::async_trait;

use config::{AppConfig, MusicService, Source};
use db::Db;

use crate::server_ops::ServerConn;

pub mod capabilities;
mod jellyfin;
mod local;
mod offline;
mod soundcloud;
mod subsonic;
mod types;
mod youtube_music;
use jellyfin::JellyfinSource;
use local::LocalSource;
use offline::OfflineServerSource;
use soundcloud::SoundcloudSource;
use subsonic::SubsonicSource;
pub use types::*;
use youtube_music::YtSource;

/// The source-agnostic backend the app drives. Impls supply
/// [`source`](Self::source) + [`db`](Self::db) (the seam the default methods
/// build on), [`capabilities`](Self::capabilities), and the remote-reaching
/// ops; the uniform DB ops are inherited.
#[async_trait]
pub trait MediaSource: Send + Sync {
    /// The typed source this acts on; its [`as_str`](Source::as_str) is the
    /// favorites partition key (`"local"` or the server id).
    fn source(&self) -> &Source;

    /// The storage handle the default methods write through. An impl seam, not
    /// a call-site API — go through the operation methods, not the raw `Db`.
    #[doc(hidden)]
    fn db(&self) -> &Db;

    /// What this source supports — gated on by the UI (no `is_server()` split).
    fn capabilities(&self) -> Capabilities;

    // --- remote-reaching ops (required) -------------------------------------

    /// Append refs to an existing playlist. Local writes the DB; a server calls
    /// the remote and mirrors what landed into the DB cache. Returns the refs
    /// that landed.
    async fn add_to_playlist(
        &self,
        playlist_id: &str,
        item_refs: &[String],
    ) -> Result<Vec<String>, SourceError>;

    /// Create a playlist seeded with `item_refs`, returning its id (a fresh
    /// UUID for local, the remote's id for a server, also mirrored into the DB).
    async fn create_playlist(
        &self,
        name: &str,
        item_refs: &[String],
    ) -> Result<String, SourceError>;

    /// Remove one track from a playlist. The per-service identity differs (YT:
    /// video id, Jellyfin: entry id, Subsonic: position), so the whole track +
    /// its current position are passed and each impl takes what it needs; the
    /// DB cache is kept in sync.
    async fn remove_from_playlist(
        &self,
        playlist_id: &str,
        track: &reader::Track,
        position: usize,
    ) -> Result<(), SourceError>;

    /// Resolve a playable stream for one item id (local = a file path, server =
    /// the remote's URL / deciphered stream).
    async fn resolve_stream(&self, item_id: &str) -> Result<StreamInfo, SourceError>;

    /// Check stored creds against the source (local is always [`Valid`](AuthOutcome::Valid)).
    async fn validate(&self) -> AuthOutcome;

    /// All favorited item ids on the remote (YT pages internally). Local has no
    /// remote set, so it returns empty.
    async fn fetch_favorites(&self) -> Result<Vec<String>, SourceError>;

    /// Push one favorite to the remote (the reconciler's flush). A no-op for
    /// local (its favorites are already the DB rows).
    async fn push_favorite(&self, item_id: &str, on: bool) -> Result<(), SourceError>;

    // --- capability-gated ops (default = unsupported) -----------------------

    /// Persist a playlist reorder: `ordered_refs` is the full new membership;
    /// `moved`/`new_index` identify the one entry that changed position. Only
    /// sources whose [`Capabilities::playlists`] is [`PlaylistOps::Reorder`]
    /// override this; the rest inherit the unsupported default.
    async fn reorder_playlist(
        &self,
        _playlist_id: &str,
        _ordered_refs: &[String],
        _moved: &reader::Track,
        _new_index: usize,
    ) -> Result<(), SourceError> {
        Err(SourceError::unsupported("playlist reorder"))
    }

    /// Start a radio/mix seeded from a track, returning the generated queue. Only
    /// sources whose [`Capabilities::radio`] is set override this; the rest
    /// inherit the unsupported default.
    async fn start_radio(&self, _seed_ref: &str) -> Result<Vec<reader::Track>, SourceError> {
        Err(SourceError::unsupported("radio"))
    }

    /// The track's canonical public web URL, when this source has shareable web
    /// pages (e.g. a YouTube Music watch link). `None` otherwise — callers fall
    /// back to a metadata lookup (MusicBrainz). Sync: it's a pure id→URL mapping.
    fn web_url(&self, _track: &reader::Track) -> Option<String> {
        None
    }

    /// Search this source for `query`, returning matching tracks and albums. The
    /// default searches the source's library corpus (the behavior local, Jellyfin
    /// and Subsonic all share); catalog-backed remotes (YT) override to query the
    /// remote instead. Every source supports search — there is no unsupported case.
    async fn search(
        &self,
        query: &str,
    ) -> Result<(Vec<reader::Track>, Vec<reader::Album>), SourceError> {
        let q = query.trim().to_lowercase();
        if q.is_empty() {
            return Ok((Vec::new(), Vec::new()));
        }
        let tracks = self.db().search_corpus(self.source()).await?;
        let albums = self.db().albums(self.source()).await?;
        Ok(search_filter(&q, tracks, albums))
    }

    /// The discover/home feed. Default unsupported — gated by
    /// [`Capabilities::discover`]; only catalog remotes (YT) override.
    async fn discover_home(&self) -> Result<crate::ytmusic::discover::DiscoverHome, SourceError> {
        Err(SourceError::unsupported("discover"))
    }

    /// The next page of the discover feed for a continuation token. Default
    /// unsupported (see [`discover_home`](Self::discover_home)).
    async fn discover_continuation(
        &self,
        _token: &str,
    ) -> Result<crate::ytmusic::discover::DiscoverHome, SourceError> {
        Err(SourceError::unsupported("discover"))
    }

    /// The tracks of a remote album / browse id (discover surfaces). Default
    /// unsupported; only catalog remotes (YT) override.
    async fn fetch_album_tracks(
        &self,
        _browse_id: &str,
    ) -> Result<Vec<reader::Track>, SourceError> {
        Err(SourceError::unsupported("album tracks"))
    }

    /// The full remote album (header metadata + tracks) for the YT-Music-style
    /// album page. Default unsupported; only catalog remotes (YT) override.
    async fn fetch_album(&self, _browse_id: &str) -> Result<RemoteAlbum, SourceError> {
        Err(SourceError::unsupported("album"))
    }

    /// Resolve an opened album reference — a raw browse id, a `ytmusic:album:MPRE…`
    /// (search rows with an album link), or a synthesized `ytmusic:album:<hash>`
    /// (search rows without one) — to its full remote album. `None` when it can't
    /// be resolved or has no tracks. The id→browse-id→album dance lives here so the
    /// UI never reaches into the per-service catalog. Default unsupported; only
    /// catalog remotes (YT) override.
    async fn fetch_album_by_ref(&self, _id: &str) -> Result<Option<RemoteAlbum>, SourceError> {
        Err(SourceError::unsupported("album by ref"))
    }

    /// Resolve a saved album's title + artist to its full remote album (header +
    /// every track) for the YT-Music-style album page — the local library stores
    /// YT albums by hash with no browse id, so the page needs the remote listing.
    /// `None` when it can't be resolved or has no tracks. Default unsupported; only
    /// catalog remotes (YT) override.
    async fn fetch_album_by_meta(
        &self,
        _title: &str,
        _artist: &str,
    ) -> Result<Option<RemoteAlbum>, SourceError> {
        Err(SourceError::unsupported("album by meta"))
    }

    /// One page of a remote playlist: `cursor = None` for the first page, then
    /// the returned cursor for each next (`None` once exhausted). Lets a UI loop
    /// stream a long playlist (play page 1 instantly, queue the rest) without a
    /// non-`Send` callback. Default unsupported; only catalog remotes (YT)
    /// override. Cross-page dedup is the caller's job.
    async fn fetch_playlist_page(
        &self,
        _playlist_id: &str,
        _cursor: Option<String>,
    ) -> Result<(Vec<reader::Track>, Option<String>), SourceError> {
        Err(SourceError::unsupported("playlist paging"))
    }

    /// Resolve an artist name to a remote channel id (discover artist links).
    /// Default unsupported; only catalog remotes (YT) override.
    async fn resolve_artist_channel_id(&self, _query: &str) -> Result<Option<String>, SourceError> {
        Err(SourceError::unsupported("artist channel"))
    }

    /// Resolve a saved album's title + artist to a remote album browse id, so
    /// the album page can fetch the album's full track list (the local library
    /// stores YT albums by hash, with no browse id). Default unsupported; only
    /// catalog remotes (YT) override.
    async fn resolve_album_browse_id(
        &self,
        _album: &str,
        _artist: &str,
    ) -> Result<Option<String>, SourceError> {
        Err(SourceError::unsupported("album browse id"))
    }

    /// A remote artist profile (banner, top songs, albums, related) by channel
    /// id. Default unsupported; only catalog remotes (YT) override.
    async fn fetch_artist(
        &self,
        _channel_id: &str,
    ) -> Result<crate::ytmusic::discover::YtArtist, SourceError> {
        Err(SourceError::unsupported("artist profile"))
    }

    // --- remote reads (default = nothing; servers override) ----------------

    /// Pull the source's entire remote library (albums, tracks, artist images),
    /// transformed into model types. Default empty — only library remotes
    /// (Jellyfin/Subsonic) override; the caller persists + prunes the result.
    async fn fetch_library(&self) -> Result<LibrarySnapshot, SourceError> {
        Ok(LibrarySnapshot::default())
    }

    /// Fetch a playlist's tracks from the remote. Local playlists resolve their
    /// refs from the DB, so the default is empty — only server impls override.
    async fn fetch_playlist_entries(
        &self,
        _playlist_id: &str,
    ) -> Result<Vec<reader::Track>, SourceError> {
        Ok(Vec::new())
    }

    /// One page of a playlist's entries, for the streaming reconcile. The caller
    /// passes `None` then each returned cursor. The default returns all entries in
    /// a single page (`next = None`) via [`fetch_playlist_entries`] — fine for
    /// sources whose listing isn't paginated (Jellyfin/Subsonic); YT overrides it
    /// with a true per-page InnerTube walk so a 700-track list streams in.
    async fn fetch_playlist_entries_page(
        &self,
        playlist_id: &str,
        _cursor: Option<String>,
    ) -> Result<PlaylistPage, SourceError> {
        Ok(PlaylistPage {
            tracks: self.fetch_playlist_entries(playlist_id).await?,
            next: None,
        })
    }

    /// Fetch the source's playlists (id, name, image tag) from the remote — the
    /// listing only; entries come from [`fetch_playlist_entries`]. Default empty.
    async fn fetch_playlists(&self) -> Result<Vec<PlaylistMeta>, SourceError> {
        Ok(Vec::new())
    }

    /// Fetch artist → image-URL pairs from the remote (for the "artist photo"
    /// view). Default empty (local reads them from the DB; YT has none).
    async fn fetch_artist_images(&self) -> Result<Vec<(String, String)>, SourceError> {
        Ok(Vec::new())
    }

    /// Resolve a single artist's photo URL by name. Default None; the catalog
    /// remote (YT) implements it so the Artists grid can show real YT photos.
    async fn fetch_artist_image(&self, _name: &str) -> Result<Option<String>, SourceError> {
        Ok(None)
    }

    /// One page of favorites — for [`FavoritesSync::Paginated`] sources (YT). The
    /// caller passes `None` then each returned cursor; default is a single empty
    /// page (instant sources use [`fetch_favorites`] instead).
    async fn fetch_favorites_page(
        &self,
        _cursor: Option<String>,
    ) -> Result<FavoritesPage, SourceError> {
        Ok(FavoritesPage {
            tracks: Vec::new(),
            next: None,
        })
    }

    // --- uniform ops (default): a plain source-scoped DB read/write ---------

    /// One album's tracks (disc/track-ordered), read from this source's library
    /// cache. Uniform across sources — the UI goes through the source rather than
    /// touching the DB directly.
    async fn album_tracks(&self, album_id: &str) -> Result<Vec<reader::Track>, SourceError> {
        self.db()
            .album_tracks(self.source(), album_id)
            .await
            .map_err(SourceError::from)
    }

    /// This source's favorite refs — its partition of the favorites table. The
    /// partition key is the source's own identity, so callers never compute it.
    async fn favorites(&self) -> Result<Vec<String>, SourceError> {
        self.db()
            .favorites(self.source().as_str())
            .await
            .map_err(SourceError::from)
    }

    /// Whether `ref_` is currently favorited for this source.
    async fn is_favorite(&self, ref_: &str) -> bool {
        self.db()
            .is_favorite(self.source().as_str(), ref_)
            .await
            .unwrap_or(false)
    }

    /// Toggle a favorite — always a local DB write (optimistic, offline-capable):
    /// for a server source the row is marked pending and the reconciler pushes
    /// it via [`push_favorite`](Self::push_favorite) once the server is reachable.
    async fn set_favorite(&self, ref_: &str, on: bool) -> Result<(), SourceError> {
        self.db()
            .set_favorite(self.source().as_str(), ref_, on)
            .await
            .map_err(SourceError::from)
    }

    /// Report a track as now-playing to the remote's scrobble endpoint. Default
    /// no-op — only remotes that scrobble (Subsonic) override. Best-effort: the
    /// caller ignores the result.
    async fn scrobble_now_playing(&self, _item_id: &str) -> Result<(), SourceError> {
        Ok(())
    }

    /// Report a track as played (submission scrobble). Default no-op; only
    /// scrobbling remotes (Subsonic) override.
    async fn scrobble(&self, _item_id: &str) -> Result<(), SourceError> {
        Ok(())
    }

    /// Keep the remote session warm (periodic ping). Default no-op; only remotes
    /// with sessions (Jellyfin) override.
    async fn keepalive(&self) -> Result<(), SourceError> {
        Ok(())
    }

    /// Report playback start of `item_id` to the remote's session API. Default
    /// no-op; only session remotes (Jellyfin) override.
    async fn report_playback_start(&self, _item_id: &str) -> Result<(), SourceError> {
        Ok(())
    }

    /// Report playback stopped at `position_ticks` (100ns units). Default no-op;
    /// only session remotes (Jellyfin) override.
    async fn report_playback_stopped(
        &self,
        _item_id: &str,
        _position_ticks: u64,
    ) -> Result<(), SourceError> {
        Ok(())
    }

    /// Report playback progress at `position_ticks` (100ns units). Default no-op;
    /// only session remotes (Jellyfin) override.
    async fn report_playback_progress(
        &self,
        _item_id: &str,
        _position_ticks: u64,
        _is_paused: bool,
    ) -> Result<(), SourceError> {
        Ok(())
    }

    /// Replace one playlist's membership (reorders, full rebuilds). DB-cache op.
    async fn set_playlist_tracks(
        &self,
        playlist_id: &str,
        refs: &[String],
    ) -> Result<(), SourceError> {
        self.db()
            .set_playlist_tracks(self.source(), playlist_id, refs)
            .await
            .map_err(SourceError::from)
    }

    /// Remove refs from one playlist. DB-cache op.
    async fn remove_playlist_tracks(
        &self,
        playlist_id: &str,
        refs: &[String],
    ) -> Result<(), SourceError> {
        self.db()
            .remove_playlist_tracks(self.source(), playlist_id, refs)
            .await
            .map_err(SourceError::from)
    }

    /// Delete one playlist (membership cascades). DB-cache op.
    async fn delete_playlist(&self, playlist_id: &str) -> Result<(), SourceError> {
        self.db()
            .delete_playlist(self.source(), playlist_id)
            .await
            .map_err(SourceError::from)
    }

    /// Set a playlist's cover from a local image file. The default records the
    /// path as the DB cover; sources backed by a remote that stores playlist
    /// artwork (Jellyfin) override to push the image upstream first.
    async fn set_playlist_cover(
        &self,
        playlist_id: &str,
        name: &str,
        image_path: &std::path::Path,
        image_tag: Option<&str>,
    ) -> Result<(), SourceError> {
        let cover = image_path.to_string_lossy();
        self.db()
            .upsert_playlist_meta(self.source(), playlist_id, name, Some(&cover), image_tag)
            .await
            .map_err(SourceError::from)
    }

    /// Upsert this source's tracks (e.g. after a metadata edit). DB-cache op.
    async fn upsert_tracks(&self, tracks: &[reader::Track]) -> Result<(), SourceError> {
        self.db()
            .upsert_tracks(self.source(), tracks)
            .await
            .map_err(SourceError::from)
    }

    /// Delete this source's tracks by key. Returns rows removed. DB-cache op.
    async fn delete_tracks(&self, keys: &[String]) -> Result<u64, SourceError> {
        self.db()
            .delete_tracks(self.source(), keys)
            .await
            .map_err(SourceError::from)
    }

    /// Delete an album and its tracks for this source. DB-cache op.
    async fn delete_album(&self, album_id: &str) -> Result<(), SourceError> {
        self.db()
            .delete_album(self.source(), album_id)
            .await
            .map_err(SourceError::from)
    }

    /// Upsert this source's albums into the DB cache. DB-cache op.
    async fn upsert_albums(&self, albums: &[reader::Album]) -> Result<(), SourceError> {
        self.db()
            .upsert_albums(self.source(), albums)
            .await
            .map_err(SourceError::from)
    }

    /// Drop this source's rows absent from the kept sets — the post-sync reconcile
    /// against the remote's current contents. DB-cache op.
    async fn prune(
        &self,
        keep_track_keys: &[String],
        keep_album_ids: &[String],
    ) -> Result<(), SourceError> {
        self.db()
            .prune_source(self.source(), keep_track_keys, keep_album_ids)
            .await
            .map_err(SourceError::from)
    }

    /// Record (or clear) the cached image for an artist. DB-cache op.
    async fn set_artist_image(
        &self,
        artist_norm: &str,
        kind: &str,
        image_ref: Option<&str>,
    ) -> Result<(), SourceError> {
        self.db()
            .set_artist_image(artist_norm, kind, image_ref)
            .await
            .map_err(SourceError::from)
    }

    /// Mark a track downloaded at `path` (or clear it with `None`). DB-cache op.
    async fn set_offline_track(&self, id: &str, path: Option<&str>) -> Result<(), SourceError> {
        self.db()
            .set_offline_track(id, path)
            .await
            .map_err(SourceError::from)
    }

    /// Create a playlist folder (local organisation). DB-cache op.
    async fn create_folder(&self, id: &str, name: &str) -> Result<(), SourceError> {
        self.db()
            .create_folder(id, name)
            .await
            .map_err(SourceError::from)
    }

    /// Rename a playlist folder. DB-cache op.
    async fn rename_folder(&self, id: &str, name: &str) -> Result<(), SourceError> {
        self.db()
            .rename_folder(id, name)
            .await
            .map_err(SourceError::from)
    }

    /// Delete a playlist folder (its playlists become unfiled). DB-cache op.
    async fn delete_folder(&self, id: &str) -> Result<(), SourceError> {
        self.db().delete_folder(id).await.map_err(SourceError::from)
    }

    /// Move a playlist into a folder (or out of one with `None`). DB-cache op.
    async fn set_playlist_folder(
        &self,
        playlist_ref: &str,
        folder_id: Option<&str>,
    ) -> Result<(), SourceError> {
        self.db()
            .set_playlist_folder(playlist_ref, folder_id)
            .await
            .map_err(SourceError::from)
    }

    /// Set (or clear) this source's album cover, marking it manual or not. DB-cache op.
    async fn update_album_cover(
        &self,
        album_id: &str,
        cover_path: Option<&str>,
        manual: bool,
    ) -> Result<(), SourceError> {
        self.db()
            .update_album_cover(self.source(), album_id, cover_path, manual)
            .await
            .map_err(SourceError::from)
    }

    /// Drop this source's favorites not seen since `epoch` (post-pull reconcile).
    async fn sweep_favorites(&self, epoch: i64) -> Result<(), SourceError> {
        self.db()
            .sweep_favorites(self.source().as_str(), epoch)
            .await
            .map_err(SourceError::from)
    }

    /// Replace this source's clean favorites with `refs`, keeping pending (dirty)
    /// local toggles. DB-cache op.
    async fn replace_favorites_clean(&self, refs: &[String]) -> Result<(), SourceError> {
        self.db()
            .replace_favorites_clean(self.source().as_str(), refs)
            .await
            .map_err(SourceError::from)
    }

    /// Increment a track's play count, keyed by its uid. DB-cache op.
    async fn bump_listen_count(&self, track_uid: &str) -> Result<(), SourceError> {
        self.db()
            .bump_listen_count(track_uid)
            .await
            .map_err(SourceError::from)
    }

    /// Record a play in this source's recently-played history. DB-cache op.
    async fn record_recent(&self, track_key: &str) -> Result<(), SourceError> {
        self.db()
            .push_recent(self.source(), track_key)
            .await
            .map_err(SourceError::from)
    }

    /// Write a metadata-cache row — e.g. a sync timestamp. DB-cache op.
    async fn set_meta(
        &self,
        cache_key: &str,
        kind: &str,
        payload: &str,
    ) -> Result<(), SourceError> {
        self.db()
            .meta_put(cache_key, kind, payload)
            .await
            .map_err(SourceError::from)
    }

    /// Stamp one page of this source's favorites (rank + epoch) during a
    /// paginated pull. DB-cache op.
    async fn upsert_favorites_page(
        &self,
        refs: &[String],
        start_rank: i64,
        epoch: i64,
    ) -> Result<(), SourceError> {
        self.db()
            .upsert_favorites_page(self.source().as_str(), refs, start_rank, epoch)
            .await
            .map_err(SourceError::from)
    }

    /// Streaming upsert of one page of a playlist's entries, stamped with `epoch`
    /// (mirrors [`upsert_favorites_page`]). Resolved against this source's
    /// partition — callers never spell out a `source`.
    async fn upsert_playlist_tracks_page(
        &self,
        playlist_id: &str,
        refs: &[String],
        start_position: i64,
        epoch: i64,
    ) -> Result<(), SourceError> {
        self.db()
            .upsert_playlist_tracks_page(self.source(), playlist_id, refs, start_position, epoch)
            .await
            .map_err(SourceError::from)
    }

    /// End-of-walk sweep for a playlist: drop entries not re-stamped with `epoch`.
    async fn sweep_playlist_tracks(
        &self,
        playlist_id: &str,
        epoch: i64,
    ) -> Result<(), SourceError> {
        self.db()
            .sweep_playlist_tracks(self.source(), playlist_id, epoch)
            .await
            .map_err(SourceError::from)
    }

    /// Upsert this source's playlist listing metadata (name/cover/image tag).
    /// DB-cache op.
    async fn upsert_playlist_meta(
        &self,
        pl_id: &str,
        name: &str,
        cover_path: Option<&str>,
        image_tag: Option<&str>,
    ) -> Result<(), SourceError> {
        self.db()
            .upsert_playlist_meta(self.source(), pl_id, name, cover_path, image_tag)
            .await
            .map_err(SourceError::from)
    }
}

/// Mirror a successful remote playlist-add into the DB cache (so the DB-reading
/// UI reflects it without waiting for a sync).
pub(super) async fn mirror_added(
    db: &Db,
    source: &Source,
    pid: &str,
    added: &[String],
) -> Result<(), SourceError> {
    if !added.is_empty() {
        db.add_playlist_tracks(source, pid, added).await?;
    }
    Ok(())
}

/// Encode a cover URL into the `urlhex_…` tag the Subsonic cover seam decodes
/// back (the synthetic album/track cover reference for Subsonic/Custom).
pub(super) fn encode_cover_url_tag(url: &str) -> String {
    let mut hex = String::with_capacity(url.len() * 2);
    for b in url.as_bytes() {
        hex.push_str(&format!("{b:02x}"));
    }
    format!("urlhex_{hex}")
}

/// Filter a library corpus by a lowercased `query` — the shared search behavior
/// for corpus-backed sources (local, Jellyfin, Subsonic). Matches tracks on
/// title/artist/album/genre (≤100) and albums on title/artist/genre, deduped by
/// title (≤30). Covers are resolved by the caller via the cover seam.
fn search_filter(
    query: &str,
    tracks: Vec<reader::Track>,
    albums: Vec<reader::Album>,
) -> (Vec<reader::Track>, Vec<reader::Album>) {
    let album_genre: std::collections::HashMap<&String, &str> =
        albums.iter().map(|a| (&a.id, a.genre.as_str())).collect();

    let result_tracks: Vec<reader::Track> = tracks
        .iter()
        .filter(|t| {
            t.title.to_lowercase().contains(query)
                || t.artist.to_lowercase().contains(query)
                || t.album.to_lowercase().contains(query)
                || album_genre
                    .get(&t.album_id)
                    .map(|g| g.to_lowercase().contains(query))
                    .unwrap_or(false)
        })
        .take(100)
        .cloned()
        .collect();

    let mut seen = std::collections::HashSet::new();
    let result_albums: Vec<reader::Album> = albums
        .iter()
        .filter(|a| {
            (a.title.to_lowercase().contains(query)
                || a.artist.to_lowercase().contains(query)
                || a.genre.to_lowercase().contains(query))
                && seen.insert(a.title.trim().to_lowercase())
        })
        .take(30)
        .cloned()
        .collect();

    (result_tracks, result_albums)
}

/// Mirror a remote playlist-create into the DB cache.
pub(super) async fn mirror_created(
    db: &Db,
    source: &Source,
    id: &str,
    name: &str,
    refs: &[String],
) -> Result<(), SourceError> {
    db.upsert_playlist_meta(source, id, name, None, None)
        .await?;
    db.set_playlist_tracks(source, id, refs)
        .await
        .map_err(SourceError::from)
}

// ============================ Resolvers ================================

/// The server id of the active source — its own id, falling back to the
/// configured server's id (matches the legacy single-server config).
fn active_server_id(config: &AppConfig) -> Option<String> {
    config
        .active_source
        .server_id()
        .map(String::from)
        .or_else(|| config.server.as_ref().and_then(|s| s.id.clone()))
}

/// Build the per-remote source for `conn` — the ONE place service dispatch happens.
fn remote_source(db: Db, source: Source, conn: &ServerConn) -> Box<dyn MediaSource> {
    match conn.service {
        MusicService::Jellyfin => Box::new(JellyfinSource::new(db, source, conn)),
        MusicService::Subsonic | MusicService::Custom => {
            Box::new(SubsonicSource::new(db, source, conn))
        }
        MusicService::YtMusic => Box::new(YtSource::new(db, source, conn)),
        MusicService::SoundCloud => Box::new(SoundcloudSource::new(db, source, conn)),
    }
}

/// The active [`MediaSource`], shared and reference-counted. Held once in a
/// `Signal<ActiveSource>` (context) and swapped only on a source-switch or cred
/// rotation, so call sites read the cached handle instead of rebuilding — and
/// for a server, re-standing-up an HTTP client — on every operation.
pub type ActiveSource = std::sync::Arc<dyn MediaSource>;

/// Ergonomic, track-centric wrappers over the source's DB-backed favorite truth,
/// so call sites read `track.is_favorite(&source).await` instead of pulling the
/// key out by hand. Defined here (not on `reader::Track`) because the truth lives
/// on [`MediaSource`], and `reader` can't depend on `server`.
#[async_trait]
pub trait TrackFavorite {
    /// Whether this track is currently favorited for `source` (an empty key —
    /// e.g. an unresolved track — is never a favorite).
    async fn is_favorite(&self, source: &ActiveSource) -> bool;

    /// Set this track's favorite state for `source` (an empty key is a no-op).
    async fn set_favorite(&self, source: &ActiveSource, on: bool) -> Result<(), SourceError>;
}

#[async_trait]
impl TrackFavorite for reader::Track {
    async fn is_favorite(&self, source: &ActiveSource) -> bool {
        let key = self.id.key();
        !key.trim().is_empty() && source.is_favorite(key.as_ref()).await
    }

    async fn set_favorite(&self, source: &ActiveSource, on: bool) -> Result<(), SourceError> {
        let key = self.id.key();
        if key.trim().is_empty() {
            return Ok(());
        }
        source.set_favorite(key.as_ref(), on).await
    }
}

/// The configured server's [`MediaSource`], or `None` when no usable creds
/// exist. Unlike [`active`] this ignores the active source — the reconciler
/// syncs the configured server even while a local page is open.
pub fn configured_server(db: Db, config: &AppConfig) -> Option<Box<dyn MediaSource>> {
    let conn = ServerConn::resolve(config)?;
    let source = Source::Server(active_server_id(config).unwrap_or_default());
    Some(remote_source(db, source, &conn))
}

/// The local (filesystem) [`MediaSource`] — for the statically-local pages,
/// which never act on a server and so need no config.
pub fn local(db: Db) -> Box<dyn MediaSource> {
    Box::new(LocalSource {
        db,
        source: Source::Local,
    })
}

/// The [`MediaSource`] backing a given [`Source`] key — the single factory.
/// Local needs no creds; a server resolves its creds from `config` (falling back
/// to the offline stand-in when none are usable, so favorites still queue).
/// Building a server source stands up an HTTP client, so resolve once and hold
/// the result (the cached [`ActiveSource`]) rather than calling per render.
pub fn resolve(db: Db, config: &AppConfig, source: &Source) -> Box<dyn MediaSource> {
    match source {
        Source::Local => local(db),
        Source::Server(id) => match ServerConn::resolve(config) {
            Some(conn) => remote_source(db, Source::Server(id.clone()), &conn),
            None => Box::new(OfflineServerSource {
                db,
                source: Source::Server(id.clone()),
            }),
        },
    }
}

/// The [`MediaSource`] for the app's active source.
pub fn active(db: Db, config: &AppConfig) -> Box<dyn MediaSource> {
    resolve(db, config, &config.active_source)
}