🏡 index : ~doyle/shalom.git

author Jordan Doyle <jordan@doyle.la> 2024-01-11 3:20:28.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2024-01-11 3:20:28.0 +00:00:00
commit
a82e1a87ba3b97404558db86765a016226c0b178 [patch]
tree
4710c227f3cb44a0c5698d590974ea5fdbba5d45
parent
e3ca6f0088db7b60cce057b666b109af19f558ca
download
a82e1a87ba3b97404558db86765a016226c0b178.tar.gz

Implement searching for music via Spotify



Diff

 config-example.toml                    |   3 +-
 shalom/src/config.rs                   |  11 +-
 shalom/src/hass_client.rs              |  28 ++-
 shalom/src/main.rs                     |  21 +-
 shalom/src/oracle.rs                   |  18 +-
 shalom/src/pages/room.rs               |   5 +-
 shalom/src/pages/room/listen.rs        | 383 +++++++++++++++++++++++++++-------
 shalom/src/pages/room/listen/search.rs |  46 +++-
 shalom/src/subscriptions.rs            |  62 +++---
 9 files changed, 454 insertions(+), 123 deletions(-)

diff --git a/config-example.toml b/config-example.toml
index 135142d..5fae77d 100644
--- a/config-example.toml
+++ b/config-example.toml
@@ -1,3 +1,6 @@
[home-assistant]
uri = "http://192.168.1.100/"
token = "abcdef"

[spotify]
token = "abcdef"
diff --git a/shalom/src/config.rs b/shalom/src/config.rs
index 3fae16e..be2dadc 100644
--- a/shalom/src/config.rs
+++ b/shalom/src/config.rs
@@ -8,13 +8,20 @@ pub const LAST_FM_API_KEY: &str = "732433605ea7893c761d340a05752695";
pub const LAST_FM_SHARED_SECRET: &str = "420fdb301e6b4a62a888bf51def71670";
pub const FANART_PROJECT_KEY: &str = "df5eb171c6e0e49122ad59830cdf789f";

#[derive(Deserialize)]
#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "kebab-case")]
pub struct Config {
    pub home_assistant: HomeAssistantConfig,
    pub spotify: SpotifyConfig,
}

#[derive(Deserialize)]
#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "kebab-case")]
pub struct SpotifyConfig {
    pub token: String,
}

#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "kebab-case")]
pub struct HomeAssistantConfig {
    pub uri: String,
diff --git a/shalom/src/hass_client.rs b/shalom/src/hass_client.rs
index c12a01f..7df8b0b 100644
--- a/shalom/src/hass_client.rs
+++ b/shalom/src/hass_client.rs
@@ -295,6 +295,7 @@ pub enum CallServiceRequestMediaPlayer {
    MediaSeek(CallServiceRequestMediaPlayerMediaSeek),
    ShuffleSet(CallServiceRequestMediaPlayerShuffleSet),
    RepeatSet(CallServiceRequestMediaPlayerRepeatSet),
    PlayMedia(CallServiceRequestMediaPlayerPlayMedia),
    MediaPlay,
    MediaPause,
    MediaNextTrack,
@@ -328,6 +329,33 @@ pub struct CallServiceRequestMediaPlayerRepeatSet {
    pub repeat: MediaPlayerRepeat,
}

#[derive(Serialize)]
pub struct CallServiceRequestMediaPlayerPlayMedia {
    pub media_content_id: String,
    pub media_content_type: CallServiceRequestMediaPlayerPlayMediaType,
    pub enqueue: CallServiceRequestMediaPlayerPlayMediaEnqueue,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CallServiceRequestMediaPlayerPlayMediaType {
    Music,
    Tvshow,
    Video,
    Episode,
    Channel,
    Playlist,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CallServiceRequestMediaPlayerPlayMediaEnqueue {
    Add,
    Next,
    Play,
    Replace,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MediaPlayerRepeat {
diff --git a/shalom/src/main.rs b/shalom/src/main.rs
index 46b7a4e..98bb1d4 100644
--- a/shalom/src/main.rs
+++ b/shalom/src/main.rs
@@ -26,6 +26,7 @@ pub struct Shalom {
    oracle: Option<Arc<Oracle>>,
    home_room: Option<&'static str>,
    theme: Theme,
    config: Option<Arc<Config>>,
}

impl Shalom {
@@ -40,6 +41,7 @@ impl Shalom {
        ActivePage::Room(pages::room::Room::new(
            room,
            self.oracle.as_ref().unwrap().clone(),
            self.config.as_ref().unwrap().clone(),
        ))
    }

@@ -160,6 +162,14 @@ impl Shalom {
                    Message::UpdateLightResult,
                )
            }
            pages::room::listen::Event::PlayTrack(id, uri) => {
                let oracle = self.oracle.as_ref().unwrap().clone();

                Command::perform(
                    async move { oracle.speaker(id).play_track(uri).await },
                    Message::PlayTrackResult,
                )
            }
        }
    }
}
@@ -177,6 +187,7 @@ impl Application for Shalom {
            oracle: None,
            home_room: Some("living_room"),
            theme: Theme::default(),
            config: None,
        };

        // this is only best-effort to try and prevent blocking when loading
@@ -186,8 +197,8 @@ impl Application for Shalom {
        let command = Command::perform(
            async {
                let config = load_config().await;
                let client = hass_client::create(config.home_assistant).await;
                Oracle::new(client.clone()).await
                let client = hass_client::create(config.home_assistant.clone()).await;
                (Oracle::new(client.clone()).await, config)
            },
            Message::Loaded,
        );
@@ -203,8 +214,9 @@ impl Application for Shalom {
    fn update(&mut self, message: Self::Message) -> Command<Self::Message> {
        #[allow(clippy::single_match)]
        match (message, &mut self.page, &mut self.context_menu) {
            (Message::Loaded(oracle), _, _) => {
            (Message::Loaded((oracle, config)), _, _) => {
                self.oracle = Some(oracle);
                self.config = Some(Arc::new(config));
                self.page = self.build_home_route();
                Command::none()
            }
@@ -288,7 +300,7 @@ async fn load_config() -> Config {

#[derive(Debug, Clone)]
pub enum Message {
    Loaded(Arc<Oracle>),
    Loaded((Arc<Oracle>, Config)),
    CloseContextMenu,
    OpenOmniPage,
    OpenHomePage,
@@ -296,6 +308,7 @@ pub enum Message {
    RoomEvent(pages::room::Message),
    LightControlMenu(context_menus::light_control::Message),
    UpdateLightResult(()),
    PlayTrackResult(()),
}

#[derive(Debug)]
diff --git a/shalom/src/oracle.rs b/shalom/src/oracle.rs
index 3ee40ab..32c079f 100644
--- a/shalom/src/oracle.rs
+++ b/shalom/src/oracle.rs
@@ -30,9 +30,10 @@ use crate::{
        },
        CallServiceRequestData, CallServiceRequestLight, CallServiceRequestLightTurnOn,
        CallServiceRequestMediaPlayer, CallServiceRequestMediaPlayerMediaSeek,
        CallServiceRequestMediaPlayerRepeatSet, CallServiceRequestMediaPlayerShuffleSet,
        CallServiceRequestMediaPlayerVolumeMute, CallServiceRequestMediaPlayerVolumeSet, Event,
        HassRequestKind, MediaPlayerRepeat,
        CallServiceRequestMediaPlayerPlayMedia, CallServiceRequestMediaPlayerPlayMediaEnqueue,
        CallServiceRequestMediaPlayerPlayMediaType, CallServiceRequestMediaPlayerRepeatSet,
        CallServiceRequestMediaPlayerShuffleSet, CallServiceRequestMediaPlayerVolumeMute,
        CallServiceRequestMediaPlayerVolumeSet, Event, HassRequestKind, MediaPlayerRepeat,
    },
    widgets::colour_picker::clamp_to_u8,
};
@@ -460,6 +461,17 @@ impl EloquentSpeaker<'_> {
        self.call(CallServiceRequestMediaPlayer::MediaPreviousTrack)
            .await;
    }

    pub async fn play_track(&self, uri: String) {
        self.call(CallServiceRequestMediaPlayer::PlayMedia(
            CallServiceRequestMediaPlayerPlayMedia {
                media_content_id: uri,
                media_content_type: CallServiceRequestMediaPlayerPlayMediaType::Music,
                enqueue: CallServiceRequestMediaPlayerPlayMediaEnqueue::Play,
            },
        ))
        .await;
    }
}

fn build_room(
diff --git a/shalom/src/pages/room.rs b/shalom/src/pages/room.rs
index bb8fafc..77f3fec 100644
--- a/shalom/src/pages/room.rs
+++ b/shalom/src/pages/room.rs
@@ -12,6 +12,7 @@ use iced::{
};

use crate::{
    config::Config,
    oracle::Oracle,
    subscriptions::MaybePendingImage,
    widgets::{
@@ -30,12 +31,12 @@ pub struct Room {
}

impl Room {
    pub fn new(id: &'static str, oracle: Arc<Oracle>) -> Self {
    pub fn new(id: &'static str, oracle: Arc<Oracle>, config: Arc<Config>) -> Self {
        let room = oracle.room(id).clone();

        Self {
            id,
            listen: listen::Listen::new(oracle.clone(), &room),
            listen: listen::Listen::new(oracle.clone(), &room, config),
            lights: lights::Lights::new(oracle, &room),
            room,
            current_page: Page::Listen,
diff --git a/shalom/src/pages/room/listen.rs b/shalom/src/pages/room/listen.rs
index 84a7124..471fdc4 100644
--- a/shalom/src/pages/room/listen.rs
+++ b/shalom/src/pages/room/listen.rs
@@ -1,22 +1,28 @@
mod search;

use std::{convert::identity, sync::Arc, time::Duration};
use std::{borrow::Cow, convert::identity, sync::Arc, time::Duration};

use iced::{
    futures::StreamExt,
    futures::{future, stream, stream::FuturesUnordered, FutureExt, StreamExt},
    subscription,
    widget::{container, image::Handle, lazy, Column, Text},
    Element, Length, Renderer, Subscription, Theme,
};
use itertools::Itertools;
use serde::Deserialize;
use url::Url;
use yoke::{Yoke, Yokeable};

use crate::{
    config::Config,
    hass_client::MediaPlayerRepeat,
    magic::header_search::header_search,
    oracle::{MediaPlayerSpeaker, MediaPlayerSpeakerState, Oracle, Room},
    pages::room::listen::search::SearchResult,
    subscriptions::{download_image, find_fanart_urls, find_musicbrainz_artist, MaybePendingImage},
    theme::{darken_image, trim_transparent_padding, Image},
    subscriptions::{
        download_image, find_fanart_urls, find_musicbrainz_artist, load_image, MaybePendingImage,
    },
    theme::{darken_image, trim_transparent_padding},
    widgets,
};

@@ -29,12 +35,12 @@ pub struct Listen {
    musicbrainz_artist_id: Option<String>,
    pub background: Option<MaybePendingImage>,
    artist_logo: Option<MaybePendingImage>,
    search_query: String,
    search_open: bool,
    search: SearchState,
    config: Arc<Config>,
}

impl Listen {
    pub fn new(oracle: Arc<Oracle>, room: &Room) -> Self {
    pub fn new(oracle: Arc<Oracle>, room: &Room, config: Arc<Config>) -> Self {
        let speaker = room.speaker(&oracle);

        Self {
@@ -45,27 +51,31 @@ impl Listen {
            musicbrainz_artist_id: None,
            background: None,
            artist_logo: None,
            search_query: String::new(),
            search_open: false,
            search: SearchState::Closed,
            config,
        }
    }

    pub fn header_magic(&self, text: Text<'static>) -> Element<'static, Message> {
        lazy(
            (self.search_open, self.search_query.clone()),
            move |(open, query)| {
                header_search(
                    Message::OnSearchTerm,
                    Message::OnSearchVisibleChange,
                    *open,
                    query,
                    text.clone(),
                )
            },
        )
        lazy(self.search.clone(), move |search| {
            let (open, query) = if let Some(v) = search.search() {
                (true, v)
            } else {
                (false, "")
            };

            header_search(
                Message::OnSearchTerm,
                Message::OnSearchVisibleChange,
                open,
                query,
                text.clone(),
            )
        })
        .into()
    }

    #[allow(clippy::too_many_lines)]
    pub fn update(&mut self, event: Message) -> Option<Event> {
        match event {
            Message::AlbumArtImageLoaded(handle) => {
@@ -162,68 +172,62 @@ impl Listen {
                None
            }
            Message::OnSearchTerm(v) => {
                self.search_query = v;
                self.search = self.search.open(v);
                None
            }
            Message::OnSearchVisibleChange(v) => {
                self.search_open = v;
                self.search_query = String::new();
                self.search = if v {
                    SearchState::Open {
                        search: String::new(),
                        results: vec![],
                        needs_result: false,
                        waiting_for_result: false,
                    }
                } else {
                    SearchState::Closed
                };
                None
            }
            Message::SpotifySearchResult(res) => {
                if let SearchState::Open {
                    results,
                    needs_result,
                    ..
                } = &mut self.search
                {
                    if *needs_result {
                        results.clear();
                        *needs_result = false;
                    }

                    results.push(res);
                }

                None
            }
            Message::SpotifySearchResultDone => {
                if let SearchState::Open {
                    waiting_for_result, ..
                } = &mut self.search
                {
                    *waiting_for_result = false;
                }

                None
            }
            Message::OnPlayTrack(uri) => Some(Event::PlayTrack(self.speaker.as_ref()?.0, uri)),
        }
    }

    pub fn view(&self, style: &Theme) -> Element<'_, Message, Renderer> {
        if self.search_open && !self.search_query.is_empty() {
            let results = vec![
                SearchResult::album(Image::AlbumArtTest.into(), "Some Album".to_string()),
                SearchResult::track(
                    Image::AlbumArtTest.into(),
                    "Some Track".to_string(),
                    "Some Artist".to_string(),
                ),
                SearchResult::playlist(Image::AlbumArtTest.into(), "Some Playlist".to_string()),
                SearchResult::album(Image::AlbumArtTest.into(), "Some Album".to_string()),
                SearchResult::track(
                    Image::AlbumArtTest.into(),
                    "Some Track".to_string(),
                    "Some Artist".to_string(),
                ),
                SearchResult::playlist(Image::AlbumArtTest.into(), "Some Playlist".to_string()),
                SearchResult::album(Image::AlbumArtTest.into(), "Some Album".to_string()),
                SearchResult::track(
                    Image::AlbumArtTest.into(),
                    "Some Track".to_string(),
                    "Some Artist".to_string(),
                ),
                SearchResult::playlist(Image::AlbumArtTest.into(), "Some Playlist".to_string()),
                SearchResult::album(Image::AlbumArtTest.into(), "Some Album".to_string()),
                SearchResult::track(
                    Image::AlbumArtTest.into(),
                    "Some Track".to_string(),
                    "Some Artist".to_string(),
                ),
                SearchResult::playlist(Image::AlbumArtTest.into(), "Some Playlist".to_string()),
                SearchResult::album(Image::AlbumArtTest.into(), "Some Album".to_string()),
                SearchResult::track(
                    Image::AlbumArtTest.into(),
                    "Some Track".to_string(),
                    "Some Artist".to_string(),
                ),
                SearchResult::playlist(Image::AlbumArtTest.into(), "Some Playlist".to_string()),
                SearchResult::album(Image::AlbumArtTest.into(), "Some Album".to_string()),
                SearchResult::track(
                    Image::AlbumArtTest.into(),
                    "Some Track".to_string(),
                    "Some Artist".to_string(),
                ),
                SearchResult::playlist(Image::AlbumArtTest.into(), "Some Playlist".to_string()),
            ];

            container(search::search(style.clone(), results))
                .padding([0, 40, 40, 40])
                .width(Length::Fill)
                .into()
        if self.search.is_open() {
            container(
                search::search(style.clone(), self.search.results())
                    .on_track_press(Message::OnPlayTrack),
            )
            .padding([0, 40, 40, 40])
            .width(Length::Fill)
            .into()
        } else if let Some((_, speaker)) = self.speaker.clone() {
            container(
                widgets::media_player::media_player(speaker, self.album_art_image.clone())
@@ -313,6 +317,17 @@ impl Listen {
            Subscription::none()
        };

        let spotify_result = if let SearchState::Open {
            search,
            waiting_for_result: true,
            ..
        } = &self.search
        {
            search_spotify(search.to_string(), &self.config.spotify.token)
        } else {
            Subscription::none()
        };

        Subscription::batch([
            album_art_subscription,
            speaker_subscription,
@@ -320,11 +335,60 @@ impl Listen {
            background_subscription,
            logo_subscription,
            fanart_subscription,
            spotify_result,
        ])
    }
}

#[derive(Copy, Clone)]
#[derive(Debug, Hash, Clone)]
pub enum SearchState {
    Open {
        search: String,
        results: Vec<SearchResult>,
        needs_result: bool,
        waiting_for_result: bool,
    },
    Closed,
}

impl SearchState {
    pub fn is_open(&self) -> bool {
        matches!(self, Self::Open { search, .. } if !search.is_empty())
    }

    pub fn results(&self) -> &[SearchResult] {
        match self {
            Self::Open { results, .. } => results.as_slice(),
            Self::Closed => &[],
        }
    }

    pub fn search(&self) -> Option<&str> {
        match self {
            SearchState::Open { search, .. } => Some(search),
            SearchState::Closed => None,
        }
    }

    pub fn open(&self, search: String) -> SearchState {
        match self {
            Self::Open { results, .. } => Self::Open {
                search,
                results: results.clone(),
                needs_result: true,
                waiting_for_result: true,
            },
            Self::Closed => Self::Open {
                search,
                results: vec![],
                needs_result: true,
                waiting_for_result: true,
            },
        }
    }
}

#[derive(Clone)]
pub enum Event {
    SetSpeakerVolume(&'static str, f32),
    SetSpeakerPosition(&'static str, Duration),
@@ -334,6 +398,7 @@ pub enum Event {
    SetSpeakerRepeat(&'static str, MediaPlayerRepeat),
    SpeakerNextTrack(&'static str),
    SpeakerPreviousTrack(&'static str),
    PlayTrack(&'static str, String),
}

#[derive(Clone, Debug)]
@@ -354,4 +419,172 @@ pub enum Message {
    OnSpeakerPreviousTrack,
    OnSearchTerm(String),
    OnSearchVisibleChange(bool),
    SpotifySearchResult(SearchResult),
    SpotifySearchResultDone,
    OnPlayTrack(String),
}

fn search_spotify(search: String, token: &str) -> Subscription<Message> {
    let token = token.to_string();

    subscription::run_with_id(
        format!("search-{search}"),
        stream::once(async move {
            let mut url = Url::parse("https://api.spotify.com/v1/search").unwrap();
            url.query_pairs_mut()
                .append_pair("q", &search)
                .append_pair("type", "album,artist,playlist,track")
                .append_pair("market", "GB")
                .append_pair("limit", "20");

            let res = reqwest::Client::new()
                .get(url)
                .header("Authorization", format!("Bearer {token}"))
                .send()
                .await
                .unwrap()
                .text()
                .await
                .unwrap();

            eprintln!("{}", std::str::from_utf8(res.as_ref()).unwrap());

            Yoke::attach_to_cart(res, |s| serde_json::from_str(s).unwrap())
        })
        .flat_map(|res: Yoke<SpotifySearchResult<'static>, String>| {
            let res = res.get();
            let results = FuturesUnordered::new();

            for track in &res.tracks.items {
                let image_url = track.album.images[0].url.to_string();
                let track_name = track.name.to_string();
                let artist_name = track.artists.iter().map(|v| &v.name).join(", ");
                let uri = track.uri.to_string();

                results.push(
                    async move {
                        let image = load_image(image_url, identity).await;
                        SearchResult::track(image, track_name, artist_name, uri)
                    }
                    .boxed(),
                );
            }

            for artist in &res.artists.items {
                let image_url = artist.images[0].url.to_string();
                let artist_name = artist.name.to_string();
                let uri = artist.uri.to_string();

                results.push(
                    async move {
                        let image = load_image(image_url, identity).await;
                        SearchResult::artist(image, artist_name, uri)
                    }
                    .boxed(),
                );
            }

            for albums in &res.albums.items {
                let image_url = albums.images[0].url.to_string();
                let album_name = albums.name.to_string();
                let uri = albums.uri.to_string();

                results.push(
                    async move {
                        let image = load_image(image_url, identity).await;
                        SearchResult::album(image, album_name, uri)
                    }
                    .boxed(),
                );
            }

            for playlist in &res.playlists.items {
                let image_url = playlist.images[0].url.to_string();
                let playlist_name = playlist.name.to_string();
                let uri = playlist.uri.to_string();

                results.push(
                    async move {
                        let image = load_image(image_url, identity).await;
                        SearchResult::playlist(image, playlist_name, uri)
                    }
                    .boxed(),
                );
            }

            results.map(Message::SpotifySearchResult)
        })
        .chain(stream::once(future::ready(
            Message::SpotifySearchResultDone,
        ))),
    )
}

#[derive(Deserialize, Yokeable)]
pub struct SpotifySearchResult<'a> {
    #[serde(borrow)]
    tracks: SpotifySearchResultWrapper<SpotifyTrack<'a>>,
    #[serde(borrow)]
    artists: SpotifySearchResultWrapper<SpotifyArtist<'a>>,
    #[serde(borrow)]
    albums: SpotifySearchResultWrapper<SpotifyAlbum<'a>>,
    #[serde(borrow)]
    playlists: SpotifySearchResultWrapper<SpotifyPlaylist<'a>>,
}

#[derive(Deserialize)]
pub struct SpotifySearchResultWrapper<T> {
    items: Vec<T>,
}

#[allow(dead_code)]
#[derive(Deserialize, Yokeable)]
pub struct SpotifyTrack<'a> {
    #[serde(borrow)]
    album: SpotifyAlbum<'a>,
    #[serde(borrow)]
    artists: Vec<SpotifyArtist<'a>>,
    #[serde(borrow)]
    name: Cow<'a, str>,
    #[serde(borrow)]
    uri: Cow<'a, str>,
}

#[derive(Deserialize, Yokeable)]
#[allow(dead_code)]
pub struct SpotifyAlbum<'a> {
    #[serde(borrow)]
    name: Cow<'a, str>,
    #[serde(borrow, default)]
    images: Vec<SpotifyImage<'a>>,
    #[serde(borrow, default)]
    artists: Vec<SpotifyArtist<'a>>,
    #[serde(borrow)]
    uri: Cow<'a, str>,
}

#[derive(Deserialize, Yokeable)]
pub struct SpotifyPlaylist<'a> {
    #[serde(borrow)]
    name: Cow<'a, str>,
    #[serde(borrow, default)]
    images: Vec<SpotifyImage<'a>>,
    #[serde(borrow)]
    uri: Cow<'a, str>,
}

#[derive(Deserialize)]
pub struct SpotifyArtist<'a> {
    #[serde(borrow)]
    name: Cow<'a, str>,
    #[serde(borrow, default)]
    images: Vec<SpotifyImage<'a>>,
    #[serde(borrow)]
    uri: Cow<'a, str>,
}

#[derive(Deserialize)]
pub struct SpotifyImage<'a> {
    #[serde(borrow)]
    url: Cow<'a, str>,
}
diff --git a/shalom/src/pages/room/listen/search.rs b/shalom/src/pages/room/listen/search.rs
index 486970b..5a41efd 100644
--- a/shalom/src/pages/room/listen/search.rs
+++ b/shalom/src/pages/room/listen/search.rs
@@ -11,7 +11,7 @@ use iced::{

use crate::widgets::mouse_area::mouse_area;

pub fn search<M: Clone + 'static>(theme: Theme, results: Vec<SearchResult>) -> Search<M> {
pub fn search<M: Clone + 'static>(theme: Theme, results: &[SearchResult]) -> Search<'_, M> {
    Search {
        on_track_press: None,
        theme,
@@ -19,13 +19,20 @@ pub fn search<M: Clone + 'static>(theme: Theme, results: Vec<SearchResult>) -> S
    }
}

pub struct Search<M> {
pub struct Search<'a, M> {
    on_track_press: Option<fn(String) -> M>,
    theme: Theme,
    results: Vec<SearchResult>,
    results: &'a [SearchResult],
}

impl<M: Clone + 'static> Component<M, Renderer> for Search<M> {
impl<M> Search<'_, M> {
    pub fn on_track_press(mut self, f: fn(String) -> M) -> Self {
        self.on_track_press = Some(f);
        self
    }
}

impl<M: Clone + 'static> Component<M, Renderer> for Search<'_, M> {
    type State = ();
    type Event = Event;

@@ -44,7 +51,7 @@ impl<M: Clone + 'static> Component<M, Renderer> for Search<M> {
            }

            let track = mouse_area(search_item_container(result_card(result, &self.theme)))
                .on_press(Event::OnTrackPress("hello world".to_string()));
                .on_press(Event::OnTrackPress(result.uri.to_string()));

            col = col.push(track);
        }
@@ -53,13 +60,13 @@ impl<M: Clone + 'static> Component<M, Renderer> for Search<M> {
    }
}

impl<M: 'static + Clone> From<Search<M>> for Element<'static, M, Renderer> {
    fn from(value: Search<M>) -> Self {
impl<'a, M: 'static + Clone> From<Search<'a, M>> for Element<'a, M, Renderer> {
    fn from(value: Search<'a, M>) -> Self {
        component(value)
    }
}

#[derive(Clone)]
#[derive(Clone, Debug)]
pub enum Event {
    OnTrackPress(String),
}
@@ -118,42 +125,58 @@ impl container::StyleSheet for SearchContainer {
}

#[allow(clippy::module_name_repetitions)]
#[derive(Debug, Clone, Hash)]
pub struct SearchResult {
    image: Handle,
    title: String,
    uri: String,
    metadata: ResultMetadata,
}

impl SearchResult {
    pub fn track(image: Handle, title: String, artist: String) -> Self {
    pub fn track(image: Handle, title: String, artist: String, uri: String) -> Self {
        Self {
            image,
            title,
            uri,
            metadata: ResultMetadata::Track(artist),
        }
    }

    pub fn playlist(image: Handle, title: String) -> Self {
    pub fn playlist(image: Handle, title: String, uri: String) -> Self {
        Self {
            image,
            title,
            uri,
            metadata: ResultMetadata::Playlist,
        }
    }

    pub fn album(image: Handle, title: String) -> Self {
    pub fn artist(image: Handle, title: String, uri: String) -> Self {
        Self {
            image,
            title,
            uri,
            metadata: ResultMetadata::Artist,
        }
    }

    pub fn album(image: Handle, title: String, uri: String) -> Self {
        Self {
            image,
            title,
            uri,
            metadata: ResultMetadata::Album,
        }
    }
}

#[derive(Debug, Clone, Hash)]
pub enum ResultMetadata {
    Track(String),
    Playlist,
    Album,
    Artist,
}

impl Display for ResultMetadata {
@@ -162,6 +185,7 @@ impl Display for ResultMetadata {
            ResultMetadata::Track(v) => write!(f, "Track • {v}"),
            ResultMetadata::Playlist => write!(f, "Playlist"),
            ResultMetadata::Album => write!(f, "Album"),
            ResultMetadata::Artist => write!(f, "Artist"),
        }
    }
}
diff --git a/shalom/src/subscriptions.rs b/shalom/src/subscriptions.rs
index 222710f..60c442a 100644
--- a/shalom/src/subscriptions.rs
+++ b/shalom/src/subscriptions.rs
@@ -4,6 +4,7 @@ use iced::{futures::stream, subscription, widget::image, Subscription};
use lru::LruCache;
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use reqwest::IntoUrl;
use url::Url;

use crate::config::FANART_PROJECT_KEY;
@@ -28,41 +29,50 @@ pub fn download_image<M: 'static>(
    post_process: fn(::image::RgbaImage) -> ::image::RgbaImage,
    resp: impl FnOnce(image::Handle) -> M + Send + 'static,
) -> Subscription<M> {
    static CACHE: Lazy<Mutex<LruCache<Url, image::Handle>>> =
        Lazy::new(|| Mutex::new(LruCache::new(NonZeroUsize::new(10).unwrap())));

    subscription::run_with_id(
        url.to_string(),
        stream::once(async move {
            eprintln!("{url} dl");

            if let Some(handle) = CACHE.lock().get(&url) {
                return (resp)(handle.clone());
            }
            (resp)(load_image(url.clone(), post_process).await)
        }),
    )
}

            let bytes = reqwest::get(url.clone())
                .await
                .unwrap()
                .bytes()
                .await
                .unwrap();
pub async fn load_image<T: IntoUrl>(
    url: T,
    post_process: fn(::image::RgbaImage) -> ::image::RgbaImage,
) -> image::Handle {
    static CACHE: Lazy<Mutex<LruCache<Url, image::Handle>>> =
        Lazy::new(|| Mutex::new(LruCache::new(NonZeroUsize::new(50).unwrap())));

            let handle = tokio::task::spawn_blocking(move || {
                eprintln!("parsing image");
                let img = ::image::load_from_memory(&bytes).unwrap();
                eprintln!("post processing");
                let data = post_process(img.into_rgba8());
                let (h, w) = data.dimensions();
                image::Handle::from_pixels(h, w, data.into_raw())
            })
            .await
            .unwrap();
    let url = url.into_url().unwrap();

            CACHE.lock().push(url.clone(), handle.clone());
    if let Some(handle) = CACHE.lock().get(&url) {
        return handle.clone();
    }

            (resp)(handle)
        }),
    )
    let bytes = reqwest::get(url.clone())
        .await
        .unwrap()
        .bytes()
        .await
        .unwrap();

    let handle = tokio::task::spawn_blocking(move || {
        eprintln!("parsing image");
        let img = ::image::load_from_memory(&bytes).unwrap();
        eprintln!("post processing");
        let data = post_process(img.into_rgba8());
        let (h, w) = data.dimensions();
        image::Handle::from_pixels(h, w, data.into_raw())
    })
    .await
    .unwrap();

    CACHE.lock().push(url.clone(), handle.clone());

    handle
}

pub fn find_musicbrainz_artist<M: 'static>(