Implement history API, add initial view stack stuff

This commit is contained in:
Mikko Ahlroth 2024-01-28 17:14:04 +02:00
parent 80f77dbf1e
commit 6f4cea8614
14 changed files with 476 additions and 187 deletions

View file

@ -1,2 +1,2 @@
gleam 0.33.0 gleam 0.34.1
nodejs 20.10.0 nodejs 20.10.0

View file

@ -20,6 +20,7 @@ gleam_fetch = "~> 0.3"
plinth = "~> 0.1" plinth = "~> 0.1"
varasto = "~> 2.0" varasto = "~> 2.0"
lustre = "~> 3.1" lustre = "~> 3.1"
birl = "~> 1.3"
[dev-dependencies] [dev-dependencies]
gleeunit = "~> 1.0" gleeunit = "~> 1.0"

View file

@ -2,19 +2,27 @@
# You typically do not need to edit this file # You typically do not need to edit this file
packages = [ 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_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_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 = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" }, { 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 = "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 = "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 = "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 = "plinth", version = "0.1.6", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "gleam_javascript"], otp_app = "plinth", source = "hex", outer_checksum = "EAF2A3229F618C6831B6AE23E9DB7DA793C9D4453CC7C40D6E6FE16F1E2524B5" }, { 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 = "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] [requirements]
birl = { version = "~> 1.3" }
gleam_fetch = { version = "~> 0.3" } gleam_fetch = { version = "~> 0.3" }
gleam_http = { version = "~> 3.5" } gleam_http = { version = "~> 3.5" }
gleam_javascript = { version = "~> 0.7" } gleam_javascript = { version = "~> 0.7" }

View file

@ -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()
}

View file

@ -1,5 +1,5 @@
import gleam/list 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 ibroadcast/library/library.{type Library as APILibrary} as api_library
import elekf/library.{Library} import elekf/library.{Library}
import elekf/transfer/album import elekf/transfer/album
@ -14,11 +14,10 @@ pub fn from(library: APILibrary) {
Library(albums: albums, artists: artists, tracks: tracks) 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 data
|> map.to_list() |> dict.to_list()
|> list.fold( |> list.fold(dict.new(), fn(acc, item) {
map.new(), dict.insert(acc, item.0, transferrer(item.1))
fn(acc, item) { map.insert(acc, item.0, transferrer(item.1)) }, })
)
} }

View file

@ -4,16 +4,20 @@
import gleam/io import gleam/io
import gleam/list import gleam/list
import gleam/option import gleam/option
import gleam/float
import gleam/int
import gleam/javascript/promise import gleam/javascript/promise
import lustre/element.{text} import lustre/element.{text}
import lustre/element/html.{div, nav, p} import lustre/element/html.{div, nav, p}
import lustre/attribute import lustre/attribute
import lustre/effect import lustre/effect
import lustre/event import lustre/event
import elekf/web/common.{type PlayQueue} import birl
import ibroadcast/library/library as library_api import ibroadcast/library/library as library_api
import ibroadcast/authed_request.{type RequestConfig, RequestConfig} 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/base_request_config.{base_request_config}
import elekf/api/history
import elekf/utils/http import elekf/utils/http
import elekf/library.{type Library} import elekf/library.{type Library}
import elekf/library/artist.{type Artist} 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/events/show_artist
import elekf/web/utils import elekf/web/utils
import elekf/web/components/icon.{Alt, icon} 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 { pub type PlayInfo {
PlayInfo( PlayInfo(
@ -40,6 +53,7 @@ pub type PlayInfo {
play_queue: PlayQueue, play_queue: PlayQueue,
play_index: Int, play_index: Int,
player: player.Model, player: player.Model,
current_track_status: CurrentTrackStatus,
) )
} }
@ -55,7 +69,7 @@ pub type Model {
settings: option.Option(common.Settings), settings: option.Option(common.Settings),
request_config: RequestConfig, request_config: RequestConfig,
play_status: PlayStatus, 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, settings: option.None,
request_config: form_request_config(auth_data), request_config: form_request_config(auth_data),
play_status: NoTracks, play_status: NoTracks,
library_view: library_view.Tracks, view_stack: [library_view.Tracks],
) )
#(model, load_library(model)) #(model, load_library(model))
@ -88,7 +102,7 @@ pub fn update(model: Model, msg) {
let new_config = form_request_config(auth_data) let new_config = form_request_config(auth_data)
let #(play_status, player_effect) = case model.play_status { 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) = let #(new_model, e) =
utils.update_child( utils.update_child(
p, p,
@ -96,16 +110,7 @@ pub fn update(model: Model, msg) {
player.update, player.update,
PlayerMsg, PlayerMsg,
) )
#( #(HasTracks(PlayInfo(..play_info, player: new_model)), e)
HasTracks(PlayInfo(
track_id,
track,
play_queue,
play_index,
new_model,
)),
e,
)
} }
NoTracks -> #(NoTracks, effect.none()) NoTracks -> #(NoTracks, effect.none())
} }
@ -140,47 +145,50 @@ pub fn update(model: Model, msg) {
#(Model(..model, play_status: status), effect) #(Model(..model, play_status: status), effect)
} }
ShowArtist(artist) -> #( 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(), effect.none(),
) )
PlayerMsg(player.NextTrack) | PlayerMsg(player.PrevTrack) -> { PlayerMsg(player.NextTrack) | PlayerMsg(player.PrevTrack) -> {
if_player( if_player(model, fn(info) {
model, let next_index = case msg {
fn(info) { PlayerMsg(player.NextTrack) -> info.play_index + 1
let next_index = case msg { _ -> info.play_index - 1
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) { Error(_) -> #(model, effect.none())
Ok(_) -> { }
let #(status, effect) = })
handle_start_play(model, info.play_queue, next_index)
#(Model(..model, play_status: status), effect)
}
Error(_) -> #(model, effect.none())
}
},
)
} }
PlayerMsg(msg) -> { PlayerMsg(msg) -> {
if_player( if_player(model, fn(info) {
model, let #(player_model, player_effect) =
fn(info) { utils.update_child(info.player, msg, player.update, PlayerMsg)
let #(player_model, player_effect) =
utils.update_child(info.player, msg, player.update, PlayerMsg)
#( let play_info =
Model( update_play_info(info, msg)
..model, |> maybe_send_history_status(model.request_config)
play_status: HasTracks(PlayInfo(..info, player: player_model)),
), #(
player_effect, Model(
) ..model,
}, play_status: HasTracks(PlayInfo(..play_info, player: player_model)),
) ),
player_effect,
)
})
} }
ChangeView(view) -> { 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") NoTracks -> attribute.attribute("data-player-status", "closed")
} }
div( div([attribute.id("authed-view-wrapper"), player_open_attribute], [
[attribute.id("authed-view-wrapper"), player_open_attribute], div([attribute.id("library-loading-indicator")], [
[ case model.loading_library {
div( True -> p([], [text("Loading library…")])
[attribute.id("library-loading-indicator")], False -> text("")
[ },
case model.loading_library { ]),
True -> p([], [text("Loading library…")]) div([attribute.id("authed-view-library")], [
False -> text("") nav([attribute.id("library-top-nav")], [
}, button_group.view("", [], [
], button.view("", [event.on_click(ChangeView(library_view.Tracks))], [
), icon("music-note-beamed", Alt("Tracks")),
div( ]),
[attribute.id("authed-view-library")], button.view("", [event.on_click(ChangeView(library_view.Artists))], [
[ icon("file-person", Alt("Artists")),
nav( ]),
[attribute.id("library-top-nav")], button.view("", [event.on_click(ChangeView(library_view.Albums))], [
[ icon("disc", Alt("Albums")),
button_group.view( ]),
"", ]),
[], ]),
[ ..list.map(model.view_stack, fn(view) {
button.view( case view {
"", library_view.Tracks ->
[event.on_click(ChangeView(library_view.Tracks))], tracks_view.render(model.library, model.settings, [
[icon("music-note-beamed", Alt("Tracks"))], attribute.id("tracks-view"),
), attribute.class("glass-bg"),
button.view( start_play.on(StartPlay),
"", ])
[event.on_click(ChangeView(library_view.Artists))], library_view.Artists ->
[icon("file-person", Alt("Artists"))], artists_view.render(model.library, model.settings, [
), attribute.id("artists-view"),
button.view( attribute.class("glass-bg"),
"", show_artist.on(ShowArtist),
[event.on_click(ChangeView(library_view.Albums))], ])
[icon("disc", Alt("Albums"))], library_view.Albums ->
), albums_view.render(model.library, model.settings, [
], attribute.id("albums-view"),
), attribute.class("glass-bg"),
], start_play.on(StartPlay),
), ])
case model.library_view { library_view.SingleArtist(artist_info) ->
library_view.Tracks -> single_artist_view.render(
tracks_view.render( model.library,
model.library, artist_info,
model.settings, model.settings,
[ [
attribute.id("tracks-view"), attribute.id("single-artist-view"),
attribute.class("glass-bg"), attribute.class("glass-bg"),
start_play.on(StartPlay), start_play.on(StartPlay),
], ],
) )
library_view.Artists -> }
artists_view.render( })
model.library, ]),
model.settings, div(
[ [
attribute.id("artists-view"), attribute.id("authed-view-player"),
attribute.class("glass-bg"), attribute.class("glass-bg glass-shadow glass-blur glass-border"),
show_artist.on(ShowArtist), ],
], [
) case model.play_status {
library_view.Albums -> HasTracks(PlayInfo(player: player, ..)) ->
albums_view.render( div([attribute.id("player")], [
model.library, player.view(player)
model.settings, |> element.map(PlayerMsg),
[ ])
attribute.id("albums-view"), NoTracks -> text("")
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) { 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 HasTracks(PlayInfo(player: p, ..)) -> p
NoTracks -> { NoTracks -> {
let assert option.Some(settings) = model.settings 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, 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( fn if_player(
@ -349,3 +337,54 @@ fn form_request_config(auth_data: common.AuthData) {
base_config: base_request_config(auth_data.device.name), 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
}
}

View file

@ -18,3 +18,7 @@ pub const bitrate = 256
/// The expiry of the MP3 URLs generated for streaming, from the current moment /// The expiry of the MP3 URLs generated for streaming, from the current moment
/// onwards, in milliseconds. /// onwards, in milliseconds.
pub const track_expiry_length = 10_800_000 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

View file

@ -0,0 +1,6 @@
import gleam/json
import ibroadcast/request_params.{type RequestParams}
pub fn request_params() -> RequestParams {
[#("mode", json.string("status"))]
}

View file

@ -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"))
},
])
}),
)
}),
),
),
])
}

View file

@ -1,6 +1,6 @@
import gleam/javascript/promise import gleam/javascript/promise
import gleam/json import gleam/json
import gleam/map.{type Map} import gleam/dict.{type Dict}
import gleam/dynamic import gleam/dynamic
import gleam/result import gleam/result
import gleam/option import gleam/option
@ -60,9 +60,9 @@ pub type Track {
pub type Library { pub type Library {
Library( Library(
albums: Map(Int, Album), albums: Dict(Int, Album),
artists: Map(Int, Artist), artists: Dict(Int, Artist),
tracks: Map(Int, Track), tracks: Dict(Int, Track),
) )
} }

View file

@ -1,5 +1,5 @@
import gleam/result import gleam/result
import gleam/map.{type Map} import gleam/dict.{type Dict}
import gleam/int import gleam/int
import gleam/dynamic.{type Decoder} import gleam/dynamic.{type Decoder}
@ -8,19 +8,19 @@ import gleam/dynamic.{type Decoder}
pub fn decoder( pub fn decoder(
key_decoder: Decoder(k), key_decoder: Decoder(k),
val_decoder: Decoder(v), val_decoder: Decoder(v),
) -> Decoder(Map(k, v)) { ) -> Decoder(Dict(k, v)) {
fn(data) { fn(data) {
// First decode into a `Map(Dynamic, Dynamic)` // 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, data,
)) ))
// Fold over that dynamic map. The accumulator will be the desired `Map(k, v)` // 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 // Attempt to decode the current value
case key_decoder(dyn_key), val_decoder(dyn_val) { case key_decoder(dyn_key), val_decoder(dyn_val) {
// If it succeeds insert the new entry // 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 // Otherwise just ignore it and carry on
_, _ -> map _, _ -> map
} }

26
src/ibroadcast/time.gleam Normal file
View file

@ -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")
}

View file

@ -1,4 +1,4 @@
import gleam/map import gleam/dict
import gleam/list import gleam/list
import ibroadcast/request_params.{type RequestParams} import ibroadcast/request_params.{type RequestParams}
@ -6,12 +6,9 @@ import ibroadcast/request_params.{type RequestParams}
pub fn combine_params(params: List(RequestParams)) -> RequestParams { pub fn combine_params(params: List(RequestParams)) -> RequestParams {
params params
|> list.flatten() |> list.flatten()
|> list.fold( |> list.fold(dict.new(), fn(acc, item) {
map.new(), let #(key, val) = item
fn(acc, item) { dict.insert(acc, key, val)
let #(key, val) = item })
map.insert(acc, key, val) |> dict.to_list()
},
)
|> map.to_list()
} }

View file

@ -153,6 +153,11 @@ single-artist-view {
overflow-y: auto; 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 { #authed-view-player {
position: absolute; position: absolute;
bottom: 0; bottom: 0;