diff --git a/endsong_ui/src/summarize.rs b/endsong_ui/src/summarize.rs index c766aa8..7950fd3 100644 --- a/endsong_ui/src/summarize.rs +++ b/endsong_ui/src/summarize.rs @@ -15,9 +15,9 @@ struct ArtistSummary { name: Arc, /// Number of top songs/albums to be displayed top: usize, - /// Array of top song names with their playcount + /// List of top song names with their playcount sorted by the playcount descending songs: Vec<(Arc, usize)>, - /// Array of top album names with their playcount + /// List of top album names with their playcount sorted by the playcount descending albums: Vec<(Arc, usize)>, /// Count of this artist's plays plays: usize, diff --git a/endsong_web/src/artist.rs b/endsong_web/src/artist.rs index 4331002..65feb48 100644 --- a/endsong_web/src/artist.rs +++ b/endsong_web/src/artist.rs @@ -1,16 +1,18 @@ -//! Contains templates for `/artist` route +//! Contains templates for `/artist` routes #![allow(clippy::module_name_repetitions, reason = "looks nicer")] -use crate::{not_found, AppState}; +use crate::{encode_url, not_found, AppState}; use std::sync::Arc; use axum::{ extract::{Path, Query, State}, response::{IntoResponse, Response}, + Form, }; use endsong::prelude::*; +use itertools::Itertools; use plotly::{Layout, Plot, Scatter}; use rinja_axum::Template; use serde::Deserialize; @@ -21,8 +23,9 @@ use tracing::debug; #[derive(Deserialize)] pub struct ArtistQuery { /// The artist's index in the [`Vec`] returned by [`find::artist`] - id: usize, + artist_id: Option, } + /// [`Template`] for if there are multiple artist with different /// capitalization in [`base`] #[derive(Template)] @@ -51,8 +54,16 @@ struct ArtistTemplate<'a> { last_listen: DateTime, /// This artist's ranking compared to other artists (playcount) position: usize, + /// Link to albums + link_albums: String, + /// Link to songs + link_songs: String, + /// Link to absolute plot + link_absolute: String, + /// Link to relative plot + link_relative: String, } -/// GET `/artist/:artist_name(?id=usize)` +/// GET `/artist/[:artist_name][?artist_id=usize]` /// /// Artist page /// @@ -69,12 +80,12 @@ struct ArtistTemplate<'a> { pub async fn base( State(state): State>, Path(artist_name): Path, - options: Option>, + Query(options): Query, ) -> Response { debug!( artist_name = artist_name, - query = options.is_some(), - "GET /artist/:artist_name(?query)" + artist_id = options.artist_id, + "GET /artist/[:artist_name][?artist_id=usize]" ); let entries = &state.entries; @@ -85,8 +96,8 @@ pub async fn base( let artist = if artists.len() == 1 { artists.first() - } else if let Some(Query(options)) = options { - artists.get(options.id) + } else if let Some(artist_id) = options.artist_id { + artists.get(artist_id) } else { None }; @@ -96,7 +107,7 @@ pub async fn base( return ArtistSelectionTemplate { artists }.into_response(); }; - let (plays, position) = *state.artists.get(artist).unwrap(); + let (plays, position) = *state.artist_ranking.get(artist).unwrap(); let percentage_of_plays = format!( "{:.2}", (plays as f64 / gather::all_plays(entries) as f64) * 100.0 @@ -115,6 +126,24 @@ pub async fn base( .unwrap() .timestamp; + let encoded_artist = encode_url(&artist.name); + let (link_albums, link_songs, link_absolute, link_relative) = + if let Some(artist_id) = options.artist_id { + ( + format!("/artist/{encoded_artist}/albums?artist_id={artist_id}"), + format!("/artist/{encoded_artist}/songs?artist_id={artist_id}"), + format!("/artist/{encoded_artist}/absolute_plot?artist_id={artist_id}"), + format!("/artist/{encoded_artist}/relative_plot?artist_id={artist_id}"), + ) + } else { + ( + format!("/artist/{encoded_artist}/albums"), + format!("/artist/{encoded_artist}/songs"), + format!("/artist/{encoded_artist}/absolute_plot"), + format!("/artist/{encoded_artist}/relative_plot"), + ) + }; + ArtistTemplate { plays, position, @@ -122,23 +151,27 @@ pub async fn base( time_played: gather::listening_time(entries, artist), first_listen, last_listen, + link_albums, + link_songs, + link_absolute, + link_relative, artist, } .into_response() } -/// GET `/artist/:artist_name(?id=usize)/absolute_plot` +/// GET `/artist/[:artist_name]/absolute_lot[?artist_id=usize]` /// /// Has to be in-lined in another base.html-derived template pub async fn absolute_plot( State(state): State>, Path(artist_name): Path, - options: Option>, + Query(options): Query, ) -> Response { debug!( artist_name = artist_name, - query = options.is_some(), - "GET /artist/:artist_name(?query)/absolute_plot" + artist_id = options.artist_id, + "GET /artist/[:artist_name]/absolute_lot[?artist_id=usize]" ); let entries = &state.entries; @@ -149,8 +182,8 @@ pub async fn absolute_plot( let artist = if artists.len() == 1 { artists.first() - } else if let Some(Query(options)) = options { - artists.get(options.id) + } else if let Some(artist_id) = options.artist_id { + artists.get(artist_id) } else { None }; @@ -188,18 +221,18 @@ pub async fn absolute_plot( axum_extra::response::Html(plot_html).into_response() } -/// GET `/artist/:artist_name(?id=usize)/relative_plot` +/// GET `/artist/[:artist_name]/relative_plot[?artist_id=usize]` /// /// Has to be in-lined in another base.html-derived template pub async fn relative_plot( State(state): State>, Path(artist_name): Path, - options: Option>, + Query(options): Query, ) -> Response { debug!( artist_name = artist_name, - query = options.is_some(), - "GET /artist/:artist_name(?query)/relative_plot" + artist_id = options.artist_id, + "GET /artist/[:artist_name]/relative_plot[?artist_id=usize]" ); let entries = &state.entries; @@ -210,8 +243,8 @@ pub async fn relative_plot( let artist = if artists.len() == 1 { artists.first() - } else if let Some(Query(options)) = options { - artists.get(options.id) + } else if let Some(artist_id) = options.artist_id { + artists.get(artist_id) } else { None }; @@ -261,3 +294,194 @@ pub async fn relative_plot( axum_extra::response::Html(plot_html).into_response() } + +/// Form for getting albums/songs +#[derive(Deserialize)] +pub struct ArtistForm { + /// Amount of top albums/songs to return + top: Option, + /// Whether to sum song plays across albums + /// + /// Client sends either "on" or empty, + /// so `Some(_) => true` and `None => false` + sum_across_albums: Option, +} + +/// [`Template`] for [`albums`] +/// +/// Has to be in-lined in another base.html-derived template +/// +/// ```rinja +/// {% for (link, album, plays) in albums -%} +///
  • +/// {{ album.name }} | {{ plays }} plays +///
  • +/// {% endfor %} +/// ``` +#[derive(Template)] +#[template(in_doc = true, ext = "html", print = "none")] +struct AlbumsTemplate { + /// List of the artist's albums sorted by the playcount descending + /// + /// Elements: link to album page, [`Album`] instance, plays + albums: Vec<(String, Album, usize)>, +} +/// POST `/artist/[:artist_name]/albums[&artist_id=usize][?top=usize]` +/// +/// Lists of top albums +/// +/// Has to be in-lined in another base.html-derived template +pub async fn albums( + State(state): State>, + Path(artist_name): Path, + Query(options): Query, + Form(form): Form, +) -> Response { + debug!( + artist_name = artist_name, + artist_id = options.artist_id, + top = form.top, + "POST /artist/[:artist_name]/albums[&artist_id=usize][?top=usize]" + ); + + let entries = &state.entries; + + let Some(artists) = entries.find().artist(&artist_name) else { + return not_found().await.into_response(); + }; + + let artist = if artists.len() == 1 { + artists.first() + } else if let Some(artist_id) = options.artist_id { + artists.get(artist_id) + } else { + None + }; + + let Some(artist) = artist else { + // query if multiple artists with different capitalization + return ArtistSelectionTemplate { artists }.into_response(); + }; + + let top = form.top.unwrap_or(1000); + + let get_album_link = |album: &Album| { + if let Some(artist_id) = options.artist_id { + format!( + "/album/{}/{}?artist_id={artist_id}", + encode_url(&album.artist.name), + encode_url(&album.name) + ) + } else { + format!( + "/album/{}/{}", + encode_url(&album.artist.name), + encode_url(&album.name) + ) + } + }; + + let album_map = gather::albums_from_artist(entries, artist); + let albums = album_map + .into_iter() + .sorted_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))) + .take(top) + .map(|(album, plays)| (get_album_link(&album), album, plays)) + .collect(); + + AlbumsTemplate { albums }.into_response() +} + +/// [`Template`] for [`songs`] +/// +/// Has to be in-lined in another base.html-derived template +/// +/// ```rinja +/// {% for (link, song, plays) in songs -%} +///
  • +/// {{ song.name }} | {{ plays }} plays +///
  • +/// {% endfor %} +/// ``` +#[derive(Template)] +#[template(in_doc = true, ext = "html", print = "none")] +struct SongsTemplate { + /// List of the artist's songs sorted by the playcount descending + /// + /// Elements: link to song page, [`Song`] instance, plays + songs: Vec<(String, Song, usize)>, +} +/// POST `/artist/[:artist_name]/songs[&artist_id=usize][?top=usize][&sum_across_albums=String]` +/// +/// Lists of top albums +/// +/// Has to be in-lined in another base.html-derived template +pub async fn songs( + State(state): State>, + Path(artist_name): Path, + Query(options): Query, + Form(form): Form, +) -> Response { + debug!( + artist_name = artist_name, + artist_id = options.artist_id, + top = form.top, + sum_across_albums = form.sum_across_albums, + "POST /artist/[:artist_name]/songs[&artist_id=usize][?top=usize][&sum_across_albums=String]" + ); + + let entries = &state.entries; + + let Some(artists) = entries.find().artist(&artist_name) else { + return not_found().await.into_response(); + }; + + let artist = if artists.len() == 1 { + artists.first() + } else if let Some(artist_id) = options.artist_id { + artists.get(artist_id) + } else { + None + }; + + let Some(artist) = artist else { + // query if multiple artists with different capitalization + return ArtistSelectionTemplate { artists }.into_response(); + }; + + let top = form.top.unwrap_or(1000); + + let get_song_link = |song: &Song| { + if let Some(artist_id) = options.artist_id { + format!( + "/song/{}/{}?artist_id={artist_id}", + encode_url(&song.album.artist.name), + encode_url(&song.name) + ) + } else { + format!( + "/song/{}/{}", + encode_url(&song.album.artist.name), + encode_url(&song.name) + ) + } + }; + + let song_map = if form.sum_across_albums.is_some() { + gather::songs_from_artist_summed_across_albums(entries, artist) + } else { + gather::songs_from(entries, artist) + }; + let songs = song_map + .into_iter() + .sorted_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))) + .take(top) + .map(|(song, plays)| (get_song_link(&song), song, plays)) + .collect(); + + SongsTemplate { songs }.into_response() +} diff --git a/endsong_web/src/artists.rs b/endsong_web/src/artists.rs index 0ff8a5a..4f29159 100644 --- a/endsong_web/src/artists.rs +++ b/endsong_web/src/artists.rs @@ -8,6 +8,7 @@ use axum::{ extract::{Form, State}, response::IntoResponse, }; +use endsong::prelude::*; use rinja::Template; use serde::Deserialize; use tracing::debug; @@ -35,48 +36,45 @@ pub struct ArtistListForm { /// /// Template: /// ```rinja -/// {% for artist in artist_names %} -///
  • {{ artist }}
  • +/// {% for (link, artist, plays) in artists %} +///
  • {{ artist.name }} | {{ plays }} plays
  • /// {% endfor %} /// ``` #[derive(Template)] #[template(in_doc = true, ext = "html", print = "none")] struct ElementsTemplate { - /// List of arist names constrained by the query - artist_names: Vec>, + /// List of artists constrained by the query + /// + /// Elements: Link to artist page, [`Artist`] instance, playcount + artists: Vec<(String, Artist, usize)>, } /// POST `/artists` /// /// List of artists +#[expect( + clippy::missing_panics_doc, + reason = "will not panic since guaranteed artist in HashMap" +)] pub async fn elements( State(state): State>, Form(form): Form, ) -> impl IntoResponse { debug!(search = form.search, "POST /artists"); - let artists = &state.artist_names; - let lowercase_search = form.search.to_lowercase(); - let artist_names = artists + let artists = state + .artists .iter() - .filter(|artist| artist.to_lowercase().contains(&lowercase_search)) - .cloned() + .filter(|artist| artist.name.to_lowercase().contains(&lowercase_search)) + .map(|artist| { + ( + format!("/artist/{artist}"), + artist.clone(), + state.artist_ranking.get(artist).unwrap().0, + ) + }) .collect(); - ElementsTemplate { artist_names } -} - -/// Cistom filters used in [`rinja`] templates -mod filters { - use urlencoding::encode; - - /// Custom URL encoding - /// - /// Mostly for encoding `/` in something like `AC/DC` - /// to make a working link - #[expect(clippy::unnecessary_wraps, reason = "required rinja syntax")] - pub fn encodeurl(name: &str) -> rinja::Result { - Ok(encode(name).to_string()) - } + ElementsTemplate { artists } } diff --git a/endsong_web/src/lib.rs b/endsong_web/src/lib.rs index 240aac5..3db1f50 100644 --- a/endsong_web/src/lib.rs +++ b/endsong_web/src/lib.rs @@ -36,16 +36,16 @@ use tracing::debug; pub struct AppState { /// Reference to the [`SongEntries`] instance used pub entries: Arc, - /// Sorted (ascending alphabetically) list of all artist names in the dataset - pub artist_names: Arc>>, + /// Sorted (ascending alphabetically) list of all artists in the dataset + pub artists: Arc>, /// Map of artists with their playcount (.0) and their position/ranking descending (.1) - pub artists: Arc>, + pub artist_ranking: Arc>, } impl AppState { /// Creates a new [`AppState`] within an [`Arc`] #[must_use] pub fn new(entries: SongEntries) -> Arc { - let artists: HashMap = gather::artists(&entries) + let artist_ranking: HashMap = gather::artists(&entries) .into_iter() .sorted_unstable_by_key(|(art, plays)| (std::cmp::Reverse(*plays), art.clone())) .enumerate() @@ -54,8 +54,15 @@ impl AppState { .collect(); Arc::new(Self { - artists: Arc::new(artists), - artist_names: Arc::new(entries.artists()), + artist_ranking: Arc::new(artist_ranking), + artists: Arc::new( + entries + .iter() + .map(Artist::from) + .unique() + .sorted_unstable() + .collect_vec(), + ), entries: Arc::new(entries), }) } @@ -92,3 +99,12 @@ pub async fn index(State(state): State>) -> impl IntoResponse { playcount: gather::all_plays(entries), } } + +/// Custom URL encoding +/// +/// Mostly for encoding `/` in something like `AC/DC` +/// to make a working link +#[must_use] +pub fn encode_url(name: &str) -> String { + urlencoding::encode(name).into_owned() +} diff --git a/endsong_web/src/main.rs b/endsong_web/src/main.rs index e5c3e5a..505abbd 100644 --- a/endsong_web/src/main.rs +++ b/endsong_web/src/main.rs @@ -60,6 +60,8 @@ async fn main() { .route("/artists", get(artists::base)) .route("/artists", post(artists::elements)) .route("/artist/:artist_name", get(artist::base)) + .route("/artist/:artist_name/albums", post(artist::albums)) + .route("/artist/:artist_name/songs", post(artist::songs)) .route( "/artist/:artist_name/absolute_plot", get(artist::absolute_plot), diff --git a/endsong_web/static/tailwind_style.css b/endsong_web/static/tailwind_style.css index 7b05c83..d3779c2 100644 --- a/endsong_web/static/tailwind_style.css +++ b/endsong_web/static/tailwind_style.css @@ -566,6 +566,10 @@ video { margin-left: 1rem; } +.ml-7 { + margin-left: 1.75rem; +} + .block { display: block; } @@ -582,6 +586,10 @@ video { width: 100%; } +.list-decimal { + list-style-type: decimal; +} + .list-disc { list-style-type: disc; } @@ -598,6 +606,10 @@ video { align-items: center; } +.justify-center { + justify-content: center; +} + .justify-around { justify-content: space-around; } @@ -626,6 +638,14 @@ video { gap: 2rem; } +.gap-16 { + gap: 4rem; +} + +.gap-6 { + gap: 1.5rem; +} + .self-center { align-self: center; } @@ -704,6 +724,11 @@ video { line-height: 1.75rem; } +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + .text-xl { font-size: 1.25rem; line-height: 1.75rem; diff --git a/endsong_web/templates/artist.html b/endsong_web/templates/artist.html index 8702c1e..c511cde 100644 --- a/endsong_web/templates/artist.html +++ b/endsong_web/templates/artist.html @@ -49,6 +49,120 @@

    General info

    +
    +
    +

    Albums

    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
      +
      +
      +

      Songs

      +
      + + +
      +
      +
      + +
      +
      + +
      +
      + +
      +
      + +
      +
      +
        +
        +
        @@ -67,7 +181,7 @@

        General info

        type="submit" hx-target="this" hx-swap="outerHTML" - hx-get="/artist/{{ artist.name }}/relative_plot" + hx-get="{{ link_relative }}" > Show relative plot diff --git a/endsong_web/templates/artist_selection.html b/endsong_web/templates/artist_selection.html index 4b6e1b1..8a12c21 100644 --- a/endsong_web/templates/artist_selection.html +++ b/endsong_web/templates/artist_selection.html @@ -6,7 +6,7 @@

        Which artist do you mean?

        {% endblock %} diff --git a/endsong_web/templates/base.html b/endsong_web/templates/base.html index 9912e2f..14f8b1c 100644 --- a/endsong_web/templates/base.html +++ b/endsong_web/templates/base.html @@ -24,7 +24,7 @@

        Endsong Web

        -
        +
        {% block content %}

        Placeholder

        {% endblock %}