From 6f4cea8614b1cd145f3f98c4c19bd4e14758ff32 Mon Sep 17 00:00:00 2001 From: Mikko Ahlroth Date: Sun, 28 Jan 2024 17:14:04 +0200 Subject: [PATCH] Implement history API, add initial view stack stuff --- .tool-versions | 2 +- gleam.toml | 1 + manifest.toml | 20 +- src/elekf/api/history.gleam | 63 +++++ src/elekf/transfer/library.gleam | 13 +- src/elekf/web/authed_view.gleam | 349 +++++++++++++++------------ src/elektrofoni.gleam | 4 + src/ibroadcast/auth/status.gleam | 6 + src/ibroadcast/history.gleam | 141 +++++++++++ src/ibroadcast/library/library.gleam | 8 +- src/ibroadcast/map_format.gleam | 10 +- src/ibroadcast/time.gleam | 26 ++ src/ibroadcast/utils.gleam | 15 +- style.css | 5 + 14 files changed, 476 insertions(+), 187 deletions(-) create mode 100644 src/elekf/api/history.gleam create mode 100644 src/ibroadcast/auth/status.gleam create mode 100644 src/ibroadcast/history.gleam create mode 100644 src/ibroadcast/time.gleam diff --git a/.tool-versions b/.tool-versions index 6131d83..1865fec 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -gleam 0.33.0 +gleam 0.34.1 nodejs 20.10.0 diff --git a/gleam.toml b/gleam.toml index 477428f..01103b9 100644 --- a/gleam.toml +++ b/gleam.toml @@ -20,6 +20,7 @@ gleam_fetch = "~> 0.3" plinth = "~> 0.1" varasto = "~> 2.0" lustre = "~> 3.1" +birl = "~> 1.3" [dev-dependencies] gleeunit = "~> 1.0" diff --git a/manifest.toml b/manifest.toml index 1c07e7e..1733c95 100644 --- a/manifest.toml +++ b/manifest.toml @@ -2,19 +2,27 @@ # You typically do not need to edit this file packages = [ - { name = "gleam_fetch", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_javascript", "gleam_http"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "E6B10E4D02F83FFABABDCFC515BE8A8F13F5BB39513E85DDC94B1B3872A20328" }, + { name = "argv", version = "1.0.1", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "A6E9009E50BBE863EB37D963E4315398D41A3D87D0075480FC244125808F964A" }, + { name = "birl", version = "1.4.0", build_tools = ["gleam"], requirements = ["ranger", "gleam_stdlib"], otp_app = "birl", source = "hex", outer_checksum = "0D643A9FECCAF75E5AF108CD2AFE39B04190B16EDC8284C03D8EF09815B0D832" }, + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_community_colour"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, + { name = "gleam_community_colour", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "A49A5E3AE8B637A5ACBA80ECB9B1AFE89FD3D5351FF6410A42B84F666D40D7D5" }, + { name = "gleam_fetch", version = "0.3.1", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "24F83C6EF5BC274EF4D712B374D7CDB795136883DA64E2BA6435AF8E6C57E6E2" }, { name = "gleam_http", version = "3.5.3", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "C2FC3322203B16F897C1818D9810F5DEFCE347F0751F3B44421E1261277A7373" }, - { name = "gleam_javascript", version = "0.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "B5E05F479C52217C02BA2E8FC650A716BFB62D4F8D20A90909C908598E12FBE0" }, - { name = "gleam_json", version = "0.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" }, + { name = "gleam_javascript", version = "0.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EEA30D1ABF62B06FC378764D598DF041303CFA33A6586BFF4C4BFEFFA83DBDBE" }, + { name = "gleam_json", version = "0.7.0", build_tools = ["gleam"], requirements = ["thoas", "gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" }, { name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" }, { name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" }, - { name = "lustre", version = "3.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "B2A498D83088FB13140645D74AE90FB5A171A715BD12508EB6284EAA8D94EDC8" }, - { name = "plinth", version = "0.1.6", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "gleam_javascript"], otp_app = "plinth", source = "hex", outer_checksum = "EAF2A3229F618C6831B6AE23E9DB7DA793C9D4453CC7C40D6E6FE16F1E2524B5" }, + { name = "glint", version = "0.14.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "snag", "gleam_community_colour", "gleam_community_ansi"], otp_app = "glint", source = "hex", outer_checksum = "21AB16D5A50D4EF34DF935915FDBEE06B2DAEDEE3FCC8584C6E635A866566B38" }, + { name = "lustre", version = "3.1.3", build_tools = ["gleam"], requirements = ["argv", "gleam_community_ansi", "glint", "gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "873B047E6598FA7041E5685C8C9E030AF753A9CF2EF0F6DBB3A4E6AC92461F0D" }, + { name = "plinth", version = "0.1.9", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_javascript", "gleam_json"], otp_app = "plinth", source = "hex", outer_checksum = "89BE43DC719539A676A9515C619315A2A7188A6FF5D7499F8261241BC0B2F1A9" }, + { name = "ranger", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "28E615AE7590ED922AF1510DDF606A2ECBBC2A9609AF36D412EDC925F06DFD20" }, + { name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" }, { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" }, - { name = "varasto", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "plinth", "gleam_json"], otp_app = "varasto", source = "hex", outer_checksum = "B17CDA56C2CD5BEFABF299E14FD8797E7FE6ABE1B861CA1C9E07441AE6FDDE6B" }, + { name = "varasto", version = "2.0.0", build_tools = ["gleam"], requirements = ["plinth", "gleam_json", "gleam_stdlib"], otp_app = "varasto", source = "hex", outer_checksum = "B17CDA56C2CD5BEFABF299E14FD8797E7FE6ABE1B861CA1C9E07441AE6FDDE6B" }, ] [requirements] +birl = { version = "~> 1.3" } gleam_fetch = { version = "~> 0.3" } gleam_http = { version = "~> 3.5" } gleam_javascript = { version = "~> 0.7" } diff --git a/src/elekf/api/history.gleam b/src/elekf/api/history.gleam new file mode 100644 index 0000000..1114c90 --- /dev/null +++ b/src/elekf/api/history.gleam @@ -0,0 +1,63 @@ +//// History events are for changing the play history in the service. These will +//// also scrobble the tracks to Last.FM. + +import gleam/int +import gleam/dict +import gleam/list +import gleam/result +import birl +import ibroadcast/authed_request.{type RequestConfig} +import ibroadcast/history as history_api +import ibroadcast/http.{type Requestor} +import elekf/library/track.{type Track} + +/// Type of history event. +pub type HistoryEventType { + /// The track was played. + Play + /// The track was skipped. + Skip +} + +/// An event that occurred for a track. +pub type HistoryEvent { + HistoryEvent( + track_id: Int, + track: Track, + type_: HistoryEventType, + when: birl.Time, + ) +} + +pub type History = + List(HistoryEvent) + +/// Send an update to the history API. +pub fn update_history( + history: History, + request_config: RequestConfig, + requestor: Requestor(err_type), +) { + history_api.set_history(to_api_format(history), request_config, requestor) +} + +fn to_api_format(history: History) -> history_api.History { + list.fold(history, dict.new(), fn(acc, event) { + let day = birl.get_day(event.when) + let type_constructor = case event.type_ { + Play -> history_api.Play + Skip -> history_api.Skip + } + let history_day = + result.unwrap( + dict.get(acc, day), + history_api.HistoryDay(day: day, plays: dict.new(), detail: dict.new()), + ) + |> history_api.add_to_day( + int.to_string(event.track_id), + type_constructor(event.when), + ) + dict.insert(acc, day, history_day) + }) + |> dict.values() +} diff --git a/src/elekf/transfer/library.gleam b/src/elekf/transfer/library.gleam index c7e4465..7f9de1f 100644 --- a/src/elekf/transfer/library.gleam +++ b/src/elekf/transfer/library.gleam @@ -1,5 +1,5 @@ import gleam/list -import gleam/map.{type Map} +import gleam/dict.{type Dict} import ibroadcast/library/library.{type Library as APILibrary} as api_library import elekf/library.{Library} import elekf/transfer/album @@ -14,11 +14,10 @@ pub fn from(library: APILibrary) { Library(albums: albums, artists: artists, tracks: tracks) } -fn transfer_map(data: Map(Int, a), transferrer: fn(a) -> b) -> Map(Int, b) { +fn transfer_map(data: Dict(Int, a), transferrer: fn(a) -> b) -> Dict(Int, b) { data - |> map.to_list() - |> list.fold( - map.new(), - fn(acc, item) { map.insert(acc, item.0, transferrer(item.1)) }, - ) + |> dict.to_list() + |> list.fold(dict.new(), fn(acc, item) { + dict.insert(acc, item.0, transferrer(item.1)) + }) } diff --git a/src/elekf/web/authed_view.gleam b/src/elekf/web/authed_view.gleam index 15aa073..a0a6e78 100644 --- a/src/elekf/web/authed_view.gleam +++ b/src/elekf/web/authed_view.gleam @@ -4,16 +4,20 @@ import gleam/io import gleam/list import gleam/option +import gleam/float +import gleam/int import gleam/javascript/promise import lustre/element.{text} import lustre/element/html.{div, nav, p} import lustre/attribute import lustre/effect import lustre/event -import elekf/web/common.{type PlayQueue} +import birl import ibroadcast/library/library as library_api import ibroadcast/authed_request.{type RequestConfig, RequestConfig} +import elekf/web/common.{type PlayQueue} import elekf/api/base_request_config.{base_request_config} +import elekf/api/history import elekf/utils/http import elekf/library.{type Library} import elekf/library/artist.{type Artist} @@ -32,6 +36,15 @@ import elekf/web/events/start_play import elekf/web/events/show_artist import elekf/web/utils import elekf/web/components/icon.{Alt, icon} +import elektrofoni + +/// Status of the current track that is being played. +pub type CurrentTrackStatus { + /// We are tracking the play status. + Tracking(played: Float, last_time: Float) + /// History status update has been sent for this track. + HistorySent +} pub type PlayInfo { PlayInfo( @@ -40,6 +53,7 @@ pub type PlayInfo { play_queue: PlayQueue, play_index: Int, player: player.Model, + current_track_status: CurrentTrackStatus, ) } @@ -55,7 +69,7 @@ pub type Model { settings: option.Option(common.Settings), request_config: RequestConfig, play_status: PlayStatus, - library_view: library_view.View, + view_stack: List(library_view.View), ) } @@ -76,7 +90,7 @@ pub fn init(auth_data: common.AuthData) { settings: option.None, request_config: form_request_config(auth_data), play_status: NoTracks, - library_view: library_view.Tracks, + view_stack: [library_view.Tracks], ) #(model, load_library(model)) @@ -88,7 +102,7 @@ pub fn update(model: Model, msg) { let new_config = form_request_config(auth_data) let #(play_status, player_effect) = case model.play_status { - HasTracks(PlayInfo(track_id, track, play_queue, play_index, p)) -> { + HasTracks(PlayInfo(player: p, ..) as play_info) -> { let #(new_model, e) = utils.update_child( p, @@ -96,16 +110,7 @@ pub fn update(model: Model, msg) { player.update, PlayerMsg, ) - #( - HasTracks(PlayInfo( - track_id, - track, - play_queue, - play_index, - new_model, - )), - e, - ) + #(HasTracks(PlayInfo(..play_info, player: new_model)), e) } NoTracks -> #(NoTracks, effect.none()) } @@ -140,47 +145,50 @@ pub fn update(model: Model, msg) { #(Model(..model, play_status: status), effect) } ShowArtist(artist) -> #( - Model(..model, library_view: library_view.SingleArtist(artist)), + Model( + ..model, + view_stack: list.append(model.view_stack, [ + library_view.SingleArtist(artist), + ]), + ), effect.none(), ) PlayerMsg(player.NextTrack) | PlayerMsg(player.PrevTrack) -> { - if_player( - model, - fn(info) { - let next_index = case msg { - PlayerMsg(player.NextTrack) -> info.play_index + 1 - _ -> info.play_index - 1 + if_player(model, fn(info) { + let next_index = case msg { + PlayerMsg(player.NextTrack) -> info.play_index + 1 + _ -> info.play_index - 1 + } + case list.at(info.play_queue, next_index) { + Ok(_) -> { + let #(status, effect) = + handle_start_play(model, info.play_queue, next_index) + #(Model(..model, play_status: status), effect) } - case list.at(info.play_queue, next_index) { - Ok(_) -> { - let #(status, effect) = - handle_start_play(model, info.play_queue, next_index) - #(Model(..model, play_status: status), effect) - } - Error(_) -> #(model, effect.none()) - } - }, - ) + Error(_) -> #(model, effect.none()) + } + }) } PlayerMsg(msg) -> { - if_player( - model, - fn(info) { - let #(player_model, player_effect) = - utils.update_child(info.player, msg, player.update, PlayerMsg) + if_player(model, fn(info) { + let #(player_model, player_effect) = + utils.update_child(info.player, msg, player.update, PlayerMsg) - #( - Model( - ..model, - play_status: HasTracks(PlayInfo(..info, player: player_model)), - ), - player_effect, - ) - }, - ) + let play_info = + update_play_info(info, msg) + |> maybe_send_history_status(model.request_config) + + #( + Model( + ..model, + play_status: HasTracks(PlayInfo(..play_info, player: player_model)), + ), + player_effect, + ) + }) } ChangeView(view) -> { - #(Model(..model, library_view: view), effect.none()) + #(Model(..model, view_stack: [view]), effect.none()) } } } @@ -191,113 +199,78 @@ pub fn view(model: Model) { NoTracks -> attribute.attribute("data-player-status", "closed") } - div( - [attribute.id("authed-view-wrapper"), player_open_attribute], - [ - div( - [attribute.id("library-loading-indicator")], - [ - case model.loading_library { - True -> p([], [text("Loading library…")]) - False -> text("") - }, - ], - ), - div( - [attribute.id("authed-view-library")], - [ - nav( - [attribute.id("library-top-nav")], - [ - button_group.view( - "", - [], - [ - button.view( - "", - [event.on_click(ChangeView(library_view.Tracks))], - [icon("music-note-beamed", Alt("Tracks"))], - ), - button.view( - "", - [event.on_click(ChangeView(library_view.Artists))], - [icon("file-person", Alt("Artists"))], - ), - button.view( - "", - [event.on_click(ChangeView(library_view.Albums))], - [icon("disc", Alt("Albums"))], - ), - ], - ), - ], - ), - case model.library_view { - library_view.Tracks -> - tracks_view.render( - model.library, - model.settings, - [ - attribute.id("tracks-view"), - attribute.class("glass-bg"), - start_play.on(StartPlay), - ], - ) - library_view.Artists -> - artists_view.render( - model.library, - model.settings, - [ - attribute.id("artists-view"), - attribute.class("glass-bg"), - show_artist.on(ShowArtist), - ], - ) - library_view.Albums -> - albums_view.render( - model.library, - model.settings, - [ - attribute.id("albums-view"), - attribute.class("glass-bg"), - start_play.on(StartPlay), - ], - ) - library_view.SingleArtist(artist_info) -> - single_artist_view.render( - model.library, - artist_info, - model.settings, - [ - attribute.id("single-artist-view"), - attribute.class("glass-bg"), - start_play.on(StartPlay), - ], - ) - }, - ], - ), - div( - [ - attribute.id("authed-view-player"), - attribute.class("glass-bg glass-shadow glass-blur glass-border"), - ], - [ - case model.play_status { - HasTracks(PlayInfo(player: player, ..)) -> - div( - [attribute.id("player")], - [ - player.view(player) - |> element.map(PlayerMsg), - ], - ) - NoTracks -> text("") - }, - ], - ), - ], - ) + div([attribute.id("authed-view-wrapper"), player_open_attribute], [ + div([attribute.id("library-loading-indicator")], [ + case model.loading_library { + True -> p([], [text("Loading library…")]) + False -> text("") + }, + ]), + div([attribute.id("authed-view-library")], [ + nav([attribute.id("library-top-nav")], [ + button_group.view("", [], [ + button.view("", [event.on_click(ChangeView(library_view.Tracks))], [ + icon("music-note-beamed", Alt("Tracks")), + ]), + button.view("", [event.on_click(ChangeView(library_view.Artists))], [ + icon("file-person", Alt("Artists")), + ]), + button.view("", [event.on_click(ChangeView(library_view.Albums))], [ + icon("disc", Alt("Albums")), + ]), + ]), + ]), + ..list.map(model.view_stack, fn(view) { + case view { + library_view.Tracks -> + tracks_view.render(model.library, model.settings, [ + attribute.id("tracks-view"), + attribute.class("glass-bg"), + start_play.on(StartPlay), + ]) + library_view.Artists -> + artists_view.render(model.library, model.settings, [ + attribute.id("artists-view"), + attribute.class("glass-bg"), + show_artist.on(ShowArtist), + ]) + library_view.Albums -> + albums_view.render(model.library, model.settings, [ + attribute.id("albums-view"), + attribute.class("glass-bg"), + start_play.on(StartPlay), + ]) + library_view.SingleArtist(artist_info) -> + single_artist_view.render( + model.library, + artist_info, + model.settings, + [ + attribute.id("single-artist-view"), + attribute.class("glass-bg"), + start_play.on(StartPlay), + ], + ) + } + }) + ]), + div( + [ + attribute.id("authed-view-player"), + attribute.class("glass-bg glass-shadow glass-blur glass-border"), + ], + [ + case model.play_status { + HasTracks(PlayInfo(player: player, ..)) -> + div([attribute.id("player")], [ + player.view(player) + |> element.map(PlayerMsg), + ]) + NoTracks -> text("") + }, + ], + ), + ]) } fn handle_start_play(model: Model, queue: PlayQueue, position: Int) { @@ -307,7 +280,12 @@ fn handle_start_play(model: Model, queue: PlayQueue, position: Int) { HasTracks(PlayInfo(player: p, ..)) -> p NoTracks -> { let assert option.Some(settings) = model.settings - player.init(track_id, track, settings, model.request_config, model.library, + player.init( + track_id, + track, + settings, + model.request_config, + model.library, ) } } @@ -320,7 +298,17 @@ fn handle_start_play(model: Model, queue: PlayQueue, position: Int) { PlayerMsg, ) - #(HasTracks(PlayInfo(track_id, track, queue, position, new_model)), e) + #( + HasTracks(PlayInfo( + track_id, + track, + queue, + position, + new_model, + Tracking(0.0, 0.0), + )), + e, + ) } fn if_player( @@ -349,3 +337,54 @@ fn form_request_config(auth_data: common.AuthData) { base_config: base_request_config(auth_data.device.name), ) } + +fn update_play_info(play_info: PlayInfo, msg: player.Msg) -> PlayInfo { + case msg { + player.StartPlay(..) -> + PlayInfo(..play_info, current_track_status: Tracking(0.0, 0.0)) + player.UpdateTime(time) -> { + case play_info.current_track_status { + Tracking(played, last_time) -> { + let diff = time -. last_time + + // At maximum, only allow one second to be added, to account for the + // user skipping forward + let to_add = float.clamp(diff, min: 0.0, max: 1.0) + let new_played = played +. to_add + PlayInfo( + ..play_info, + current_track_status: Tracking(new_played, time), + ) + } + HistorySent -> play_info + } + } + _ -> play_info + } +} + +fn maybe_send_history_status(play_info: PlayInfo, request_config: RequestConfig) { + case play_info.current_track_status { + Tracking(played, ..) -> { + case played /. int.to_float(play_info.track.length) { + percentage if percentage >=. elektrofoni.play_record_ratio -> { + history.update_history( + [ + history.HistoryEvent( + track_id: play_info.track_id, + track: play_info.track, + type_: history.Play, + when: birl.utc_now(), + ), + ], + request_config, + http.requestor(), + ) + PlayInfo(..play_info, current_track_status: HistorySent) + } + _ -> play_info + } + } + HistorySent -> play_info + } +} diff --git a/src/elektrofoni.gleam b/src/elektrofoni.gleam index 58b9bd8..22f5e4f 100644 --- a/src/elektrofoni.gleam +++ b/src/elektrofoni.gleam @@ -18,3 +18,7 @@ pub const bitrate = 256 /// The expiry of the MP3 URLs generated for streaming, from the current moment /// onwards, in milliseconds. pub const track_expiry_length = 10_800_000 + +/// From 0.0 to 1.0, how much of a song has to be played for it to be tracked as +/// a single "play" (which is recorded in Last.FM also). +pub const play_record_ratio = 0.5 diff --git a/src/ibroadcast/auth/status.gleam b/src/ibroadcast/auth/status.gleam new file mode 100644 index 0000000..6708c01 --- /dev/null +++ b/src/ibroadcast/auth/status.gleam @@ -0,0 +1,6 @@ +import gleam/json +import ibroadcast/request_params.{type RequestParams} + +pub fn request_params() -> RequestParams { + [#("mode", json.string("status"))] +} diff --git a/src/ibroadcast/history.gleam b/src/ibroadcast/history.gleam new file mode 100644 index 0000000..71dab75 --- /dev/null +++ b/src/ibroadcast/history.gleam @@ -0,0 +1,141 @@ +//// The history argument can be used to make changes to play history. There is +//// no separate endpoint for it in the API and in this implementation the +//// `status` endpoint is used. +//// +//// See https://devguide.ibroadcast.com/?p=api#Play-History + +import gleam/dict.{type Dict} +import gleam/json +import gleam/int +import gleam/list +import gleam/result +import gleam/dynamic +import gleam/option +import gleam/javascript/promise +import birl +import ibroadcast/request.{DecodeFailed} +import ibroadcast/authed_request.{type RequestConfig} +import ibroadcast/http.{type Requestor} +import ibroadcast/auth/status +import ibroadcast/request_params +import ibroadcast/servers +import ibroadcast/time + +/// One play event. +pub type Event { + /// The track was played at this time. + Play(when: birl.Time) + /// The track was skipped at this time. + Skip(when: birl.Time) +} + +pub type Events = + Dict(String, List(Event)) + +/// Track plays that occurred on a given day. `plays` contains the totals keyed +/// by track ID, `detail` contains the details of played and skipped tracks, +/// again keyed by track ID. +pub type HistoryDay { + HistoryDay(day: birl.Day, plays: Dict(String, Int), detail: Events) +} + +pub type History = + List(HistoryDay) + +/// Set play history. +pub fn set_history( + history: History, + config: RequestConfig, + requestor: Requestor(err_type), +) { + use resp <- promise.try_await(authed_request.authed_request( + servers.api, + request_params(history), + config, + requestor, + )) + + promise.resolve( + json.decode(resp.body, payload_decoder()) + |> result.map_error(DecodeFailed), + ) +} + +/// Get the request params for a history request (using the `status` endpoint). +pub fn request_params(history: History) -> request_params.RequestParams { + let history = json.array(history, day_serializer) + [#("history", history), ..status.request_params()] +} + +/// Add an event to a given history day. +pub fn add_to_day(day: HistoryDay, track_id: String, event: Event) -> HistoryDay { + let plays = case event { + Play(_) -> { + let old_times = result.unwrap(dict.get(day.plays, track_id), 0) + dict.insert(day.plays, track_id, old_times + 1) + } + Skip(_) -> day.plays + } + + let detail = + dict.update(day.detail, track_id, fn(old_detail) { + case old_detail { + option.Some(old_detail) -> list.append(old_detail, [event]) + option.None -> [event] + } + }) + + HistoryDay(..day, plays: plays, detail: detail) +} + +fn payload_decoder() { + dynamic.field("result", dynamic.bool) +} + +fn day_serializer(day: HistoryDay) -> json.Json { + json.object([ + #( + "day", + json.string( + int.to_string(day.day.year) + <> "-" + <> { + int.to_string(day.day.month) + |> time.pad_time_part() + } + <> "-" + <> { + int.to_string(day.day.date) + |> time.pad_time_part() + }, + ), + ), + #( + "plays", + json.object( + list.map(dict.to_list(day.plays), fn(play) { + #(play.0, json.int(play.1)) + }), + ), + ), + #( + "detail", + json.object( + list.map(dict.to_list(day.detail), fn(item) { + #( + item.0, + json.array(item.1, fn(event) { + json.object([ + #("ts", json.string(time.format_timestamp(event.when))), + case event { + Play(..) -> #("event", json.string("play")) + Skip(..) -> #("event", json.string("skip")) + }, + ]) + }), + ) + }), + ), + ), + ]) +} diff --git a/src/ibroadcast/library/library.gleam b/src/ibroadcast/library/library.gleam index 370a024..49fcb70 100644 --- a/src/ibroadcast/library/library.gleam +++ b/src/ibroadcast/library/library.gleam @@ -1,6 +1,6 @@ import gleam/javascript/promise import gleam/json -import gleam/map.{type Map} +import gleam/dict.{type Dict} import gleam/dynamic import gleam/result import gleam/option @@ -60,9 +60,9 @@ pub type Track { pub type Library { Library( - albums: Map(Int, Album), - artists: Map(Int, Artist), - tracks: Map(Int, Track), + albums: Dict(Int, Album), + artists: Dict(Int, Artist), + tracks: Dict(Int, Track), ) } diff --git a/src/ibroadcast/map_format.gleam b/src/ibroadcast/map_format.gleam index 5491335..4ad79a2 100644 --- a/src/ibroadcast/map_format.gleam +++ b/src/ibroadcast/map_format.gleam @@ -1,5 +1,5 @@ import gleam/result -import gleam/map.{type Map} +import gleam/dict.{type Dict} import gleam/int import gleam/dynamic.{type Decoder} @@ -8,19 +8,19 @@ import gleam/dynamic.{type Decoder} pub fn decoder( key_decoder: Decoder(k), val_decoder: Decoder(v), -) -> Decoder(Map(k, v)) { +) -> Decoder(Dict(k, v)) { fn(data) { // First decode into a `Map(Dynamic, Dynamic)` - use dynamic_map <- result.map(dynamic.map(dynamic.dynamic, dynamic.dynamic)( + use dynamic_map <- result.map(dynamic.dict(dynamic.dynamic, dynamic.dynamic)( data, )) // Fold over that dynamic map. The accumulator will be the desired `Map(k, v)` - use map, dyn_key, dyn_val <- map.fold(dynamic_map, map.new()) + use map, dyn_key, dyn_val <- dict.fold(dynamic_map, dict.new()) // Attempt to decode the current value case key_decoder(dyn_key), val_decoder(dyn_val) { // If it succeeds insert the new entry - Ok(key), Ok(val) -> map.insert(map, key, val) + Ok(key), Ok(val) -> dict.insert(map, key, val) // Otherwise just ignore it and carry on _, _ -> map } diff --git a/src/ibroadcast/time.gleam b/src/ibroadcast/time.gleam new file mode 100644 index 0000000..3aef656 --- /dev/null +++ b/src/ibroadcast/time.gleam @@ -0,0 +1,26 @@ +import gleam/int +import gleam/string +import birl + +/// Format a timestamp according to the needs of the API. +/// The format is `2024-01-26 23:37:15` with no offset. +pub fn format_timestamp(time: birl.Time) -> String { + let date_str = birl.to_naive_date_string(time) + let time_of_day = birl.get_time_of_day(time) + let hours_str = + int.to_string(time_of_day.hour) + |> pad_time_part() + let mins_str = + int.to_string(time_of_day.minute) + |> pad_time_part() + let secs_str = + int.to_string(time_of_day.second) + |> pad_time_part() + + date_str <> " " <> hours_str <> ":" <> mins_str <> ":" <> secs_str +} + +/// Pad a time part (months, days, hours, minutes, seconds) to two characters. +pub fn pad_time_part(str: String) -> String { + string.pad_left(str, to: 2, with: "0") +} diff --git a/src/ibroadcast/utils.gleam b/src/ibroadcast/utils.gleam index 64a797a..83854bb 100644 --- a/src/ibroadcast/utils.gleam +++ b/src/ibroadcast/utils.gleam @@ -1,4 +1,4 @@ -import gleam/map +import gleam/dict import gleam/list import ibroadcast/request_params.{type RequestParams} @@ -6,12 +6,9 @@ import ibroadcast/request_params.{type RequestParams} pub fn combine_params(params: List(RequestParams)) -> RequestParams { params |> list.flatten() - |> list.fold( - map.new(), - fn(acc, item) { - let #(key, val) = item - map.insert(acc, key, val) - }, - ) - |> map.to_list() + |> list.fold(dict.new(), fn(acc, item) { + let #(key, val) = item + dict.insert(acc, key, val) + }) + |> dict.to_list() } diff --git a/style.css b/style.css index e20a6cf..6fa7284 100644 --- a/style.css +++ b/style.css @@ -153,6 +153,11 @@ single-artist-view { overflow-y: auto; } +/* Ensure that the view stack only shows the topmost view and the nav bar. */ +#authed-view-library > *:not(:first-child, :last-child) { + display: none; +} + #authed-view-player { position: absolute; bottom: 0;