koan-server 0.66.1

GraphQL, Subsonic REST, and MCP server for koan music player.
Documentation
//! Hearts: favouriting a track, record or artist from the web UI.
//!
//! A heart is the same favourite the apps make: the toggle goes through the
//! Subsonic API's `star`, so the account's row changes, a track the local
//! account favourites is starred upstream, and the account's apps are told to
//! sync. Every copy of a heart on the page carries `data-fav`, and the answer
//! replaces them all, so a track listed twice lights up twice.

use std::collections::HashSet;
use std::sync::LazyLock;

use askama::Template;
use axum::Extension;
use axum::extract::{Path, Query, State};
use axum::response::Response;
use koan_core::auth::Role;
use koan_core::db::queries;

use super::{UiState, events, open, patch};
use crate::auth::AuthUser;
use crate::html::{Html, Render};
use crate::share::blocking;
use crate::subsonic::EntityKind;

/// What the signed-in account has favourited, for drawing hearts. `None` for
/// an account that may not favourite, which is drawn none.
pub(super) struct Hearts {
    pub tracks: HashSet<i64>,
    pub albums: HashSet<i64>,
    pub artists: HashSet<i64>,
}

impl Hearts {
    pub fn load(conn: &rusqlite::Connection, user: &AuthUser) -> Option<Self> {
        if !user.role.has_permission(Role::User) {
            return None;
        }
        Some(Self {
            tracks: queries::load_favourites(conn, user.user_id).ok()?,
            albums: queries::favourite_album_id_set(conn, user.user_id).ok()?,
            artists: queries::favourite_artist_id_set(conn, user.user_id).ok()?,
        })
    }

    pub fn track(&self, id: i64) -> Html {
        heart(Kind::Song, id, self.tracks.contains(&id), false)
    }

    /// A record's heart, over the corner of its tile.
    pub fn tile(&self, id: i64) -> Html {
        heart(Kind::Album, id, self.albums.contains(&id), true)
    }

    pub fn album(&self, id: i64) -> Html {
        heart(Kind::Album, id, self.albums.contains(&id), false)
    }

    pub fn artist(&self, id: i64) -> Html {
        heart(Kind::Artist, id, self.artists.contains(&id), false)
    }

    /// A favourite's heart beside a track row's ⋯ on a narrow screen, where
    /// the row's own heart is in its menu: a mark, not a button. The stylesheet
    /// shows it while the row's hidden heart is pressed, so it follows a
    /// toggle made from the menu.
    pub fn mark(&self) -> &'static Html {
        static MARK: LazyLock<Html> = LazyLock::new(|| Mark.html());
        &MARK
    }
}

#[derive(Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub(super) enum Kind {
    Song,
    Album,
    Artist,
}

impl Kind {
    fn as_str(self) -> &'static str {
        match self {
            Kind::Song => "song",
            Kind::Album => "album",
            Kind::Artist => "artist",
        }
    }

    fn entity(self) -> EntityKind {
        match self {
            Kind::Song => EntityKind::Song,
            Kind::Album => EntityKind::Album,
            Kind::Artist => EntityKind::Artist,
        }
    }

    fn noun(self) -> &'static str {
        match self {
            Kind::Song => "track",
            Kind::Album => "record",
            Kind::Artist => "artist",
        }
    }
}

#[derive(Template)]
#[template(path = "favourite/heart.html")]
struct Heart {
    kind: &'static str,
    id: i64,
    on: bool,
    label: String,
    tile: bool,
}

#[derive(Template)]
#[template(path = "favourite/mark.html")]
struct Mark;

fn label(kind: Kind, on: bool) -> String {
    let verb = if on { "Unfavourite" } else { "Favourite" };
    format!("{verb} this {}", kind.noun())
}

/// A heart for one thing, filled while it is a favourite. Pressing it asks
/// for the opposite. `tile` places it over the corner of a record's tile.
fn heart(kind: Kind, id: i64, on: bool, tile: bool) -> Html {
    Heart {
        kind: kind.as_str(),
        id,
        on,
        label: label(kind, on),
        tile,
    }
    .html()
}

#[derive(serde::Deserialize)]
pub(super) struct On {
    on: u8,
}

/// Favourite or unfavourite one thing, and redraw every heart for it.
pub(super) async fn toggle(
    State(s): State<UiState>,
    Extension(user): Extension<AuthUser>,
    Path((kind, id)): Path<(Kind, i64)>,
    Query(On { on }): Query<On>,
) -> Response {
    let on = on != 0;
    let selector = format!("[data-fav=\"{}-{id}\"]", kind.as_str());
    if !user.role.has_permission(Role::User) {
        return events(Vec::new());
    }
    let done = blocking(move || {
        let db = open(&s.pool)?;
        Some(crate::subsonic::favourite(
            &db,
            user.user_id,
            &user.username,
            kind.entity(),
            id,
            on,
        ))
    })
    .await;
    match done {
        Some(Ok(())) => events(vec![patch(
            &heart(kind, id, on, false),
            Some((&selector, "outer")),
        )]),
        Some(Err(e)) => {
            log::info!("web UI: favourite {} {id}: {e}", kind.as_str());
            events(Vec::new())
        }
        None => events(Vec::new()),
    }
}