From 64fde40bf7d02ba7c45bde16a9546bb0f64f7df9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Toma=C5=9Bko?= Date: Tue, 15 Oct 2024 02:03:39 +0200 Subject: [PATCH] Add basic /album page --- endsong_web/src/album.rs | 199 ++++++++++++++++++++ endsong_web/src/artist.rs | 27 ++- endsong_web/src/lib.rs | 1 + endsong_web/src/main.rs | 7 +- endsong_web/templates/album.html | 54 ++++++ endsong_web/templates/album_selection.html | 16 ++ endsong_web/templates/artist_selection.html | 6 +- 7 files changed, 299 insertions(+), 11 deletions(-) create mode 100644 endsong_web/src/album.rs create mode 100644 endsong_web/templates/album.html create mode 100644 endsong_web/templates/album_selection.html diff --git a/endsong_web/src/album.rs b/endsong_web/src/album.rs new file mode 100644 index 0000000..015f54e --- /dev/null +++ b/endsong_web/src/album.rs @@ -0,0 +1,199 @@ +//! Contains template for /album routes + +#![allow(clippy::module_name_repetitions, reason = "looks nicer")] + +use crate::{artist::ArtistSelectionTemplate, encode_url, not_found, AppState}; + +use std::sync::Arc; + +use axum::{ + extract::{Path, Query, State}, + response::{IntoResponse, Redirect, Response}, +}; +use endsong::prelude::*; +use rinja_axum::Template; +use serde::Deserialize; +use tracing::debug; + +/// To choose an artist and album if there are multiple with same capitalization +#[derive(Deserialize)] +pub struct AlbumQuery { + /// The artist's index in the [`Vec`] returned by [`find::artist`] + artist_id: Option, + /// The albums's index in the [`Vec`] returned by [`find::album`] + album_id: Option, +} + +/// [`Template`] for if there are multiple artist with different +/// capitalization in [`base`] +#[derive(Template)] +#[template(path = "album_selection.html", print = "none")] +struct AlbumSelectionTemplate { + /// Albums with same name, but different capitalization + /// + /// Will only happen if you didn't do [`SongEntries::sum_different_capitalization`] + /// + /// See [`find::album`] + albums: Vec, + /// Link to the album page (without `album_id`) + link_base_album: String, +} +/// [`Template`] for [`base`] +#[derive(Template)] +#[template(path = "album.html", print = "none")] +struct AlbumTemplate<'a> { + /// Reference to the given Album + album: &'a Album, + /// This album's playcount + plays: usize, + /// Percentage of this album's plays to the total playcount + percentage_of_plays: String, + /// Percentage of this album's plays to the artist playcount + percentage_of_artist_plays: String, + /// Time spent listening to this artist + time_played: TimeDelta, + /// Date of first artist entry + first_listen: DateTime, + /// Date of most recent artist entry + last_listen: DateTime, + /// Link to artist page + link_artist: String, +} +/// GET `/album/[:artist_name]/[:album_name][?artist_id=usize][?album_id=usize]` +/// +/// Artist page +/// +/// Returns an [`AlbumTemplate`] with a valid `artist_name` and `album_name`, +/// an [`ArtistSelectionTemplate`] if there are +/// multiple artists with this name +/// but different capitalization, +/// an [`AlbumSelectionTemplate`] if there are +/// multiple artists with this name +/// but different capitalization, +/// and [`not_found`] if the artist or album is not in the dataset +#[expect(clippy::cast_precision_loss, reason = "necessary for % calc")] +#[expect( + clippy::missing_panics_doc, + reason = "unwraps which should never panic" +)] +pub async fn base( + State(state): State>, + Path((artist_name, album_name)): Path<(String, String)>, + Query(options): Query, +) -> Response { + debug!( + artist_name = artist_name, + album_name = album_name, + artist_id = options.artist_id, + album_id = options.album_id, + "/album/[:artist_name]/[:album_name][?artist_id=usize][?album_id=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 { + link_base_artist: format!( + "/album/{}/{}", + encode_url(&artist_name), + encode_url(&album_name) + ), + artists, + } + .into_response(); + }; + + let Some(albums) = entries.find().album(&album_name, &artist_name) else { + return not_found().await.into_response(); + }; + + let album = if albums.len() == 1 { + albums.first() + } else if let Some(album_id) = options.album_id { + albums.get(album_id) + } else { + None + }; + + let encoded_artist = encode_url(&artist.name); + + let Some(album) = album else { + let encoded_album = encode_url(&albums.first().unwrap().name); + + let link_base_album = if let Some(artist_id) = options.artist_id { + format!("/album/{encoded_artist}/{encoded_album}?artist_id={artist_id}") + } else { + format!("/album/{encoded_artist}/{encoded_album}") + }; + + return AlbumSelectionTemplate { + albums, + link_base_album, + } + .into_response(); + }; + + // http://localhost:3000/album/TiA/%E6%B5%81%E6%98%9F + // (i.e. could only happen on manual link entry) + if &album.artist != artist { + return Redirect::permanent(&format!( + "/artist/{encoded_artist}?artist_id={}", + options.artist_id.unwrap() + )) + .into_response(); + } + + let plays = gather::plays(entries, album); + let percentage_of_plays = format!( + "{:.2}", + (plays as f64 / gather::all_plays(entries) as f64) * 100.0 + ); + let percentage_of_artist_plays = format!( + "{:.2}", + (plays as f64 / gather::plays(entries, artist) as f64) * 100.0 + ); + + // unwrap ok bc already made sure artist exists earlier + let first_listen = entries + .iter() + .find(|entry| album.is_entry(entry)) + .unwrap() + .timestamp; + let last_listen = entries + .iter() + .rev() + .find(|entry| album.is_entry(entry)) + .unwrap() + .timestamp; + + let link_artist = if let Some(artist_id) = options.artist_id { + format!("/artist/{encoded_artist}?artist_id={artist_id}") + } else { + format!("/artist/{encoded_artist}") + }; + + AlbumTemplate { + plays, + percentage_of_plays, + percentage_of_artist_plays, + time_played: gather::listening_time(entries, album), + first_listen, + last_listen, + link_artist, + album, + } + .into_response() +} diff --git a/endsong_web/src/artist.rs b/endsong_web/src/artist.rs index 65feb48..a0cc98b 100644 --- a/endsong_web/src/artist.rs +++ b/endsong_web/src/artist.rs @@ -30,11 +30,24 @@ pub struct ArtistQuery { /// capitalization in [`base`] #[derive(Template)] #[template(path = "artist_selection.html", print = "none")] -struct ArtistSelectionTemplate { +pub struct ArtistSelectionTemplate { /// Artists with same name, but different capitalization /// /// See [`find::artist`] - artists: Vec, + pub artists: Vec, + /// Link to the artist page (without `artist_id`) + pub link_base_artist: String, +} +impl ArtistSelectionTemplate { + /// Creates a new [`ArtistSelectionTemplate`] with generated `link_base_artist` + #[expect(clippy::missing_panics_doc, reason = "unwrap will never panic")] + #[must_use] + pub fn new(artists: Vec) -> Self { + Self { + link_base_artist: format!("/artist/{}", encode_url(&artists.first().unwrap().name)), + artists, + } + } } /// [`Template`] for [`base`] #[derive(Template)] @@ -104,7 +117,7 @@ pub async fn base( let Some(artist) = artist else { // query if multiple artists with different capitalization - return ArtistSelectionTemplate { artists }.into_response(); + return ArtistSelectionTemplate::new(artists).into_response(); }; let (plays, position) = *state.artist_ranking.get(artist).unwrap(); @@ -190,7 +203,7 @@ pub async fn absolute_plot( let Some(artist) = artist else { // query if multiple artists with different capitalization - return ArtistSelectionTemplate { artists }.into_response(); + return ArtistSelectionTemplate::new(artists).into_response(); }; // see endsong_ui::trace::absolute @@ -251,7 +264,7 @@ pub async fn relative_plot( let Some(artist) = artist else { // query if multiple artists with different capitalization - return ArtistSelectionTemplate { artists }.into_response(); + return ArtistSelectionTemplate::new(artists).into_response(); }; // see endsong_ui::trace::relative_to_all @@ -362,7 +375,7 @@ pub async fn albums( let Some(artist) = artist else { // query if multiple artists with different capitalization - return ArtistSelectionTemplate { artists }.into_response(); + return ArtistSelectionTemplate::new(artists).into_response(); }; let top = form.top.unwrap_or(1000); @@ -450,7 +463,7 @@ pub async fn songs( let Some(artist) = artist else { // query if multiple artists with different capitalization - return ArtistSelectionTemplate { artists }.into_response(); + return ArtistSelectionTemplate::new(artists).into_response(); }; let top = form.top.unwrap_or(1000); diff --git a/endsong_web/src/lib.rs b/endsong_web/src/lib.rs index 3db1f50..947a8fe 100644 --- a/endsong_web/src/lib.rs +++ b/endsong_web/src/lib.rs @@ -18,6 +18,7 @@ #![warn(clippy::allow_attributes)] #![allow(clippy::unused_async, reason = "axum handlers must be async")] +pub mod album; pub mod artist; pub mod artists; pub mod r#static; diff --git a/endsong_web/src/main.rs b/endsong_web/src/main.rs index 505abbd..c733d9e 100644 --- a/endsong_web/src/main.rs +++ b/endsong_web/src/main.rs @@ -17,7 +17,7 @@ #![warn(clippy::allow_attributes_without_reason)] #![warn(clippy::allow_attributes)] -use endsong_web::{artist, artists, index, not_found, r#static, AppState}; +use endsong_web::{album, artist, artists, index, not_found, r#static, AppState}; use axum::{routing::get, routing::post, Router}; use endsong::prelude::*; @@ -38,14 +38,14 @@ async fn main() { "macos" => "/Users/filip/Other/Endsong/", _ => "/mnt/c/temp/Endsong/", }; - let last: u8 = 0; + let last: u8 = 9; let paths: Vec = (0..=last) .map(|i| format!("{root}endsong_{i}.json")) .collect(); let entries = SongEntries::new(&paths) .unwrap_or_else(|e| panic!("{e}")) - .sum_different_capitalization() + // .sum_different_capitalization() .filter(30, TimeDelta::try_seconds(10).unwrap()); let state = AppState::new(entries); @@ -70,6 +70,7 @@ async fn main() { "/artist/:artist_name/relative_plot", get(artist::relative_plot), ) + .route("/album/:artist_name/:album_name", get(album::base)) .with_state(state) .fallback(not_found) .layer(compression); diff --git a/endsong_web/templates/album.html b/endsong_web/templates/album.html new file mode 100644 index 0000000..f0cf993 --- /dev/null +++ b/endsong_web/templates/album.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% block title %} {{ album.artist.name }} - {{ album.name }}{% endblock %} + +{% block content %} +

+ {{ album.artist.name }} - {{ album.name }} +

+
+
+

General info

+
    +
  • Playcount: {{ plays }}
  • +
  • + Time spent listening: +
      + {% let minutes = time_played.num_minutes() %} {% let hours = + time_played.num_hours() %} {% let days = time_played.num_days() %} +
    • + +
    • + {% if hours != 0 %} +
    • + +
    • + {% endif %} {% if days != 0 %} +
    • + +
    • + {% endif %} +
    +
  • +
  • + First listen: + +
  • +
  • + Last listen: + +
  • +
  • % of total plays: {{ percentage_of_plays }}%
  • +
  • % of {{ album.artist }} plays: {{ percentage_of_artist_plays }}%
  • +
+
+
+{% endblock %} diff --git a/endsong_web/templates/album_selection.html b/endsong_web/templates/album_selection.html new file mode 100644 index 0000000..4c47d81 --- /dev/null +++ b/endsong_web/templates/album_selection.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block title %} Album Selection {% endblock %} + +{% block content %} +

Which album do you mean?

+ +{% endblock %} diff --git a/endsong_web/templates/artist_selection.html b/endsong_web/templates/artist_selection.html index 8a12c21..a3ac648 100644 --- a/endsong_web/templates/artist_selection.html +++ b/endsong_web/templates/artist_selection.html @@ -6,7 +6,11 @@

Which artist do you mean?

{% endblock %}