From 20ffb24b3046a7ef8bf17cc8ccbf3f27811617a5 Mon Sep 17 00:00:00 2001 From: Mikko Ahlroth Date: Sun, 8 Oct 2023 12:53:43 +0300 Subject: [PATCH] Hackfest --- .gitignore | 2 + README.md | 22 ++ gleam.toml | 24 ++ index.html | 27 +++ manifest.toml | 26 +++ normalize.css | 289 ++++++++++++++++++++++++ src/date_ffi.mjs | 38 ++++ src/elekf/api/auth/models.gleam | 7 + src/elekf/api/auth/storage.gleam | 64 ++++++ src/elekf/api/base_request_config.gleam | 14 ++ src/elekf/library.gleam | 39 ++++ src/elekf/library/album.gleam | 11 + src/elekf/library/artist.gleam | 3 + src/elekf/library/track.gleam | 25 ++ src/elekf/transfer/album.gleam | 14 ++ src/elekf/transfer/artist.gleam | 11 + src/elekf/transfer/library.gleam | 23 ++ src/elekf/transfer/track.gleam | 29 +++ src/elekf/utils/date.gleam | 51 +++++ src/elekf/utils/http.gleam | 16 ++ src/elekf/utils/navigator.gleam | 2 + src/elekf/utils/option.gleam | 7 + src/elekf/web/authed_view.gleam | 244 ++++++++++++++++++++ src/elekf/web/common.gleam | 9 + src/elekf/web/components/player.gleam | 172 ++++++++++++++ src/elekf/web/components/search.gleam | 51 +++++ src/elekf/web/login_view.gleam | 128 +++++++++++ src/elekf/web/main.gleam | 177 +++++++++++++++ src/elekf/web/utils.gleam | 17 ++ src/elekf/web/view.gleam | 4 + src/elektrofoni.gleam | 13 ++ src/ibroadcast/app_info.gleam | 3 + src/ibroadcast/auth/login_token.gleam | 88 ++++++++ src/ibroadcast/auth/logout.gleam | 6 + src/ibroadcast/authed_request.gleam | 35 +++ src/ibroadcast/device_info.gleam | 3 + src/ibroadcast/http.gleam | 8 + src/ibroadcast/library/library.gleam | 194 ++++++++++++++++ src/ibroadcast/map_format.gleam | 43 ++++ src/ibroadcast/request.gleam | 45 ++++ src/ibroadcast/request_params.gleam | 4 + src/ibroadcast/servers.gleam | 22 ++ src/ibroadcast/streaming.gleam | 32 +++ src/ibroadcast/utils.gleam | 17 ++ src/navigator_ffi.mjs | 3 + src/player_ffi.mjs | 5 + style.css | 61 +++++ test/elektrofoni_test.gleam | 12 + 48 files changed, 2140 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 gleam.toml create mode 100644 index.html create mode 100644 manifest.toml create mode 100644 normalize.css create mode 100644 src/date_ffi.mjs create mode 100644 src/elekf/api/auth/models.gleam create mode 100644 src/elekf/api/auth/storage.gleam create mode 100644 src/elekf/api/base_request_config.gleam create mode 100644 src/elekf/library.gleam create mode 100644 src/elekf/library/album.gleam create mode 100644 src/elekf/library/artist.gleam create mode 100644 src/elekf/library/track.gleam create mode 100644 src/elekf/transfer/album.gleam create mode 100644 src/elekf/transfer/artist.gleam create mode 100644 src/elekf/transfer/library.gleam create mode 100644 src/elekf/transfer/track.gleam create mode 100644 src/elekf/utils/date.gleam create mode 100644 src/elekf/utils/http.gleam create mode 100644 src/elekf/utils/navigator.gleam create mode 100644 src/elekf/utils/option.gleam create mode 100644 src/elekf/web/authed_view.gleam create mode 100644 src/elekf/web/common.gleam create mode 100644 src/elekf/web/components/player.gleam create mode 100644 src/elekf/web/components/search.gleam create mode 100644 src/elekf/web/login_view.gleam create mode 100644 src/elekf/web/main.gleam create mode 100644 src/elekf/web/utils.gleam create mode 100644 src/elekf/web/view.gleam create mode 100644 src/elektrofoni.gleam create mode 100644 src/ibroadcast/app_info.gleam create mode 100644 src/ibroadcast/auth/login_token.gleam create mode 100644 src/ibroadcast/auth/logout.gleam create mode 100644 src/ibroadcast/authed_request.gleam create mode 100644 src/ibroadcast/device_info.gleam create mode 100644 src/ibroadcast/http.gleam create mode 100644 src/ibroadcast/library/library.gleam create mode 100644 src/ibroadcast/map_format.gleam create mode 100644 src/ibroadcast/request.gleam create mode 100644 src/ibroadcast/request_params.gleam create mode 100644 src/ibroadcast/servers.gleam create mode 100644 src/ibroadcast/streaming.gleam create mode 100644 src/ibroadcast/utils.gleam create mode 100644 src/navigator_ffi.mjs create mode 100644 src/player_ffi.mjs create mode 100644 style.css create mode 100644 test/elektrofoni_test.gleam diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6782961 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.ez +build diff --git a/README.md b/README.md new file mode 100644 index 0000000..a52378c --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# elektrofoni + +[![Package Version](https://img.shields.io/hexpm/v/elektrofoni)](https://hex.pm/packages/elektrofoni) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/elektrofoni/) + +## Quick start + +```sh +gleam run # Run the project +gleam test # Run the tests +gleam shell # Run an Erlang shell +``` + +## Installation + +If available on Hex this package can be added to your Gleam project: + +```sh +gleam add elektrofoni +``` + +and its documentation can be found at . diff --git a/gleam.toml b/gleam.toml new file mode 100644 index 0000000..f952cb2 --- /dev/null +++ b/gleam.toml @@ -0,0 +1,24 @@ +name = "elektrofoni" +version = "1.0.0" +target = "javascript" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "username", repo = "project" } +# links = [{ title = "Website", href = "https://gleam.run" }] + +[dependencies] +gleam_stdlib = "~> 0.31" +lustre = "~> 3.0" +gleam_json = "~> 0.6" +gleam_http = "~> 3.5" +gleam_javascript = "~> 0.6" +gleam_fetch = "~> 0.2" +plinth = "~> 0.1" +varasto = "~> 1.0" + +[dev-dependencies] +gleeunit = "~> 0.10" diff --git a/index.html b/index.html new file mode 100644 index 0000000..26b1c36 --- /dev/null +++ b/index.html @@ -0,0 +1,27 @@ + + + + + + + Elektrofoni + + + + + + + + +
+ + + + + diff --git a/manifest.toml b/manifest.toml new file mode 100644 index 0000000..1cac144 --- /dev/null +++ b/manifest.toml @@ -0,0 +1,26 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "gleam_fetch", version = "0.2.1", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib", "gleam_javascript"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "F64E93C754D948B2D37ABC4ADD5482FE0FAED4B99C79E66012DDE96BEDC40544" }, + { name = "gleam_http", version = "3.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "FAE9AE3EB1CA90C2194615D20FFFD1E28B630E84DACA670B28D959B37BCBB02C" }, + { name = "gleam_javascript", version = "0.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "BFEBB63ABE4A1694E07DEFD19B160C2980304B5D775A89D4B02E7DE7C9D8008B" }, + { name = "gleam_json", version = "0.6.0", build_tools = ["gleam"], requirements = ["thoas", "gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "C6CC5BEECA525117E97D0905013AB3F8836537455645DDDD10FE31A511B195EF" }, + { name = "gleam_stdlib", version = "0.31.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6D1BC5B4D4179B9FEE866B1E69FE180AC2CE485AD90047C0B32B2CA984052736" }, + { name = "gleeunit", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "1397E5C4AC4108769EE979939AC39BF7870659C5AFB714630DEEEE16B8272AD5" }, + { name = "lustre", version = "3.0.6", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "2D2BEF77B5966773467574C2DC23A27FAB7C720DEF428E72C610DA1547E7E171" }, + { name = "plinth", version = "0.1.3", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "E81BA6A6CEAFFADBCB85B04DC817A4CDC43AFA7BB6AE56CE0B7C7E66D1C9ADD1" }, + { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" }, + { name = "varasto", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "plinth", "gleam_stdlib"], otp_app = "varasto", source = "hex", outer_checksum = "0621E5BFD0B9B7F7D19B8FC6369C6E2EAC5C1F3858A1E5E51342F5BCE10C3728" }, +] + +[requirements] +gleam_fetch = { version = "~> 0.2" } +gleam_http = { version = "~> 3.5" } +gleam_javascript = { version = "~> 0.6" } +gleam_json = { version = "~> 0.6" } +gleam_stdlib = { version = "~> 0.31" } +gleeunit = { version = "~> 0.10" } +lustre = { version = "~> 3.0" } +plinth = { version = "~> 0.1" } +varasto = { version = "~> 1.0" } diff --git a/normalize.css b/normalize.css new file mode 100644 index 0000000..0bce022 --- /dev/null +++ b/normalize.css @@ -0,0 +1,289 @@ +/*! modern-normalize v2.0.0 | MIT License | https://github.com/sindresorhus/modern-normalize */ + +/* +Document +======== +*/ + +/** +Use a better box model (opinionated). +*/ + +*, +::before, +::after { + box-sizing: border-box; +} + +html { + /* Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) */ + font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji"; + line-height: 1.15; /* 1. Correct the line height in all browsers. */ + -webkit-text-size-adjust: 100%; /* 2. Prevent adjustments of font size after orientation changes in iOS. */ + -moz-tab-size: 4; /* 3. Use a more readable tab size (opinionated). */ + tab-size: 4; /* 3 */ +} + +/* +Sections +======== +*/ + +body { + margin: 0; /* Remove the margin in all browsers. */ +} + +/* +Grouping content +================ +*/ + +/** +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +*/ + +hr { + height: 0; /* 1 */ + color: inherit; /* 2 */ +} + +/* +Text-level semantics +==================== +*/ + +/** +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr[title] { + text-decoration: underline dotted; +} + +/** +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/** +1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) +2. Correct the odd 'em' font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, + monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/** +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/** +Prevent 'sub' and 'sup' elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +Tabular data +============ +*/ + +/** +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +*/ + +table { + text-indent: 0; /* 1 */ + border-color: inherit; /* 2 */ +} + +/* +Forms +===== +*/ + +/** +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} + +/** +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/** +Correct the inability to style clickable types in iOS and Safari. +*/ + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +/** +Remove the inner border and padding in Firefox. +*/ + +::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** +Restore the focus styles unset by the previous rule. +*/ + +:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** +Remove the additional ':invalid' styles in Firefox. +See: https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737 +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/** +Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers. +*/ + +legend { + padding: 0; +} + +/** +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/** +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/** +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type="search"] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to 'inherit' in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* +Interactive +=========== +*/ + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +:root { + line-height: 1.5; +} + +h1, +h2, +h3, +h4, +h5, +figure, +p, +ol, +ul { + margin: 0; +} + +ol, +ul { + list-style: none; + padding-inline: 0; +} + +img { + display: block; + max-inline-size: 100%; +} diff --git a/src/date_ffi.mjs b/src/date_ffi.mjs new file mode 100644 index 0000000..279e82c --- /dev/null +++ b/src/date_ffi.mjs @@ -0,0 +1,38 @@ +import { Ok, Error } from "./gleam.mjs"; + +function construct(input) { + const d = new Date(input); + + if (isNaN(d.valueOf())) { + return new Error(undefined); + } else { + return new Ok(d); + } +} + +export const from_iso8601 = construct; +export const from_unix = construct; + +export function to_iso8601(date) { + return date.toISOString(); +} + +export function to_unix(date) { + return date.getTime(); +} + +export function unix_now() { + return Date.now(); +} + +export function now() { + return new Date(); +} + +export function equals(a, b) { + return a.getTime() === b.getTime(); +} + +export function bigger_than(a, b) { + return a > b; +} diff --git a/src/elekf/api/auth/models.gleam b/src/elekf/api/auth/models.gleam new file mode 100644 index 0000000..53a77bd --- /dev/null +++ b/src/elekf/api/auth/models.gleam @@ -0,0 +1,7 @@ +pub type User { + User(id: Int, token: String, session_uuid: String) +} + +pub type Device { + Device(name: String) +} diff --git a/src/elekf/api/auth/storage.gleam b/src/elekf/api/auth/storage.gleam new file mode 100644 index 0000000..38622ca --- /dev/null +++ b/src/elekf/api/auth/storage.gleam @@ -0,0 +1,64 @@ +import gleam/dynamic +import gleam/json +import plinth/javascript/storage +import varasto +import elekf/api/auth/models + +const storage_key = "__elektrofoni_auth_storage" + +pub type StorageFormat { + StorageFormat(user: models.User, device: models.Device) +} + +pub type AuthStorage = + varasto.TypedStorage(StorageFormat) + +pub fn get() { + let assert Ok(local) = storage.local() + varasto.new(local, reader(), writer()) +} + +pub fn read(storage: AuthStorage) { + varasto.get(storage, storage_key) +} + +pub fn write(storage: AuthStorage, data: StorageFormat) { + varasto.set(storage, storage_key, data) +} + +fn reader() { + dynamic.decode2( + StorageFormat, + dynamic.field("user", user_decoder()), + dynamic.field("device", device_decoder()), + ) +} + +fn writer() { + fn(val: StorageFormat) { + json.object([ + #( + "user", + json.object([ + #("id", json.int(val.user.id)), + #("token", json.string(val.user.token)), + #("session_uuid", json.string(val.user.session_uuid)), + ]), + ), + #("device", json.object([#("name", json.string(val.device.name))])), + ]) + } +} + +fn user_decoder() { + dynamic.decode3( + models.User, + dynamic.field("id", dynamic.int), + dynamic.field("token", dynamic.string), + dynamic.field("session_uuid", dynamic.string), + ) +} + +fn device_decoder() { + dynamic.decode1(models.Device, dynamic.field("name", dynamic.string)) +} diff --git a/src/elekf/api/base_request_config.gleam b/src/elekf/api/base_request_config.gleam new file mode 100644 index 0000000..000921a --- /dev/null +++ b/src/elekf/api/base_request_config.gleam @@ -0,0 +1,14 @@ +import elektrofoni +import ibroadcast/device_info.{DeviceInfo} +import ibroadcast/request.{RequestConfig} +import elekf/utils/navigator + +pub fn base_request_config(device_name: String) { + RequestConfig( + app_info: elektrofoni.app_info, + device_info: DeviceInfo( + name: device_name, + user_agent: navigator.user_agent(), + ), + ) +} diff --git a/src/elekf/library.gleam b/src/elekf/library.gleam new file mode 100644 index 0000000..a1393a6 --- /dev/null +++ b/src/elekf/library.gleam @@ -0,0 +1,39 @@ +import gleam/map.{Map} +import elekf/library/track.{Track} +import elekf/library/album.{Album} +import elekf/library/artist.{Artist} + +pub type Library { + Library( + albums: Map(Int, Album), + artists: Map(Int, Artist), + tracks: Map(Int, Track), + ) +} + +pub fn get_album(library: Library, id: Int) { + map.get(library.albums, id) +} + +pub fn get_artist(library: Library, id: Int) { + map.get(library.artists, id) +} + +pub fn get_track(library: Library, id: Int) { + map.get(library.tracks, id) +} + +pub fn assert_album(library: Library, id: Int) { + let assert Ok(album) = map.get(library.albums, id) + album +} + +pub fn assert_artist(library: Library, id: Int) { + let assert Ok(artist) = map.get(library.artists, id) + artist +} + +pub fn assert_track(library: Library, id: Int) { + let assert Ok(track) = map.get(library.tracks, id) + track +} diff --git a/src/elekf/library/album.gleam b/src/elekf/library/album.gleam new file mode 100644 index 0000000..b8329dc --- /dev/null +++ b/src/elekf/library/album.gleam @@ -0,0 +1,11 @@ +pub type Album { + Album( + name: String, + tracks: List(Int), + artist_id: Int, + trashed: Bool, + rating: Int, + disc: Int, + year: Int, + ) +} diff --git a/src/elekf/library/artist.gleam b/src/elekf/library/artist.gleam new file mode 100644 index 0000000..377b32d --- /dev/null +++ b/src/elekf/library/artist.gleam @@ -0,0 +1,3 @@ +pub type Artist { + Artist(name: String, tracks: List(Int), trashed: Bool, rating: Int) +} diff --git a/src/elekf/library/track.gleam b/src/elekf/library/track.gleam new file mode 100644 index 0000000..70630f8 --- /dev/null +++ b/src/elekf/library/track.gleam @@ -0,0 +1,25 @@ +pub type Track { + Track( + number: Int, + year: Int, + title: String, + title_lower: String, + genre: String, + length: Int, + album_id: Int, + artwork_id: Int, + artist_id: Int, + enid: Int, + uploaded_on: String, + trashed: Bool, + size: Int, + path: String, + uid: String, + rating: Int, + plays: Int, + file: String, + type_: String, + replay_gain: String, + uploaded_time: String, + ) +} diff --git a/src/elekf/transfer/album.gleam b/src/elekf/transfer/album.gleam new file mode 100644 index 0000000..54fa5bf --- /dev/null +++ b/src/elekf/transfer/album.gleam @@ -0,0 +1,14 @@ +import ibroadcast/library/library.{Album as APIAlbum} +import elekf/library/album.{Album} + +pub fn from(album: APIAlbum) { + Album( + name: album.name, + tracks: album.tracks, + artist_id: album.artist_id, + trashed: album.trashed, + rating: album.rating, + disc: album.disc, + year: album.year, + ) +} diff --git a/src/elekf/transfer/artist.gleam b/src/elekf/transfer/artist.gleam new file mode 100644 index 0000000..0cb8dfc --- /dev/null +++ b/src/elekf/transfer/artist.gleam @@ -0,0 +1,11 @@ +import ibroadcast/library/library.{Artist as APIArtist} +import elekf/library/artist.{Artist} + +pub fn from(artist: APIArtist) { + Artist( + name: artist.name, + tracks: artist.tracks, + trashed: artist.trashed, + rating: artist.rating, + ) +} diff --git a/src/elekf/transfer/library.gleam b/src/elekf/transfer/library.gleam new file mode 100644 index 0000000..917b6d8 --- /dev/null +++ b/src/elekf/transfer/library.gleam @@ -0,0 +1,23 @@ +import gleam/list +import gleam/map.{Map} +import ibroadcast/library/library.{Library as APILibrary} as api_library +import elekf/library.{Library} +import elekf/transfer/album +import elekf/transfer/artist +import elekf/transfer/track + +pub fn from(library: APILibrary) { + let albums = transfer_map(library.albums, album.from) + let artists = transfer_map(library.artists, artist.from) + let tracks = transfer_map(library.tracks, track.from) + Library(albums: albums, artists: artists, tracks: tracks) +} + +fn transfer_map(data: Map(Int, a), transferrer: fn(a) -> b) -> Map(Int, b) { + data + |> map.to_list() + |> list.fold( + map.new(), + fn(acc, item) { map.insert(acc, item.0, transferrer(item.1)) }, + ) +} diff --git a/src/elekf/transfer/track.gleam b/src/elekf/transfer/track.gleam new file mode 100644 index 0000000..8545d3b --- /dev/null +++ b/src/elekf/transfer/track.gleam @@ -0,0 +1,29 @@ +import gleam/string +import ibroadcast/library/library.{Track as APITrack} +import elekf/library/track.{Track} + +pub fn from(track: APITrack) { + Track( + number: track.number, + year: track.year, + title: track.title, + title_lower: string.lowercase(track.title), + genre: track.genre, + length: track.length, + album_id: track.album_id, + artwork_id: track.artwork_id, + artist_id: track.artist_id, + enid: track.enid, + uploaded_on: track.uploaded_on, + trashed: track.trashed, + size: track.size, + path: track.path, + uid: track.uid, + rating: track.rating, + plays: track.plays, + file: track.file, + type_: track.type_, + replay_gain: track.replay_gain, + uploaded_time: track.uploaded_time, + ) +} diff --git a/src/elekf/utils/date.gleam b/src/elekf/utils/date.gleam new file mode 100644 index 0000000..b622d2d --- /dev/null +++ b/src/elekf/utils/date.gleam @@ -0,0 +1,51 @@ +import gleam/result +import gleam/order.{Eq, Gt, Lt, Order} +import gleam/dynamic.{DecodeError, Dynamic} + +pub type Date + +@external(javascript, "../../date_ffi.mjs", "from_iso8601") +pub fn from_iso8601(a: String) -> Result(Date, Nil) + +@external(javascript, "../../date_ffi.mjs", "to_iso8601") +pub fn to_iso8601(d: Date) -> String + +@external(javascript, "../../date_ffi.mjs", "from_unix") +pub fn from_unix(a: Int) -> Result(Date, Nil) + +@external(javascript, "../../date_ffi.mjs", "to_unix") +pub fn to_unix(a: Date) -> Int + +@external(javascript, "../../date_ffi.mjs", "now") +pub fn now() -> Date + +@external(javascript, "../../date_ffi.mjs", "unix_now") +pub fn unix_now() -> Int + +@external(javascript, "../../date_ffi.mjs", "equals") +pub fn equals(a: Date, b: Date) -> Bool + +@external(javascript, "../../date_ffi.mjs", "bigger_than") +pub fn bigger_than(a: Date, b: Date) -> Bool + +/// Compare given dates, returning `Gt` if `a` > `b` +pub fn compare(a: Date, b: Date) -> Order { + case equals(a, b) { + True -> Eq + False -> { + case bigger_than(a, b) { + True -> Gt + False -> Lt + } + } + } +} + +pub fn decode(value: Dynamic) -> Result(Date, List(DecodeError)) { + use str <- result.try(dynamic.string(value)) + use date <- result.try( + from_iso8601(str) + |> result.replace_error([DecodeError("ISO 8601 formatted string", str, [])]), + ) + Ok(date) +} diff --git a/src/elekf/utils/http.gleam b/src/elekf/utils/http.gleam new file mode 100644 index 0000000..09cd861 --- /dev/null +++ b/src/elekf/utils/http.gleam @@ -0,0 +1,16 @@ +import gleam/fetch +import gleam/http/request +import gleam/javascript/promise +import ibroadcast/request as ibroadcast_request +import ibroadcast/http as ibroadcast_http + +pub type ResponseError = + ibroadcast_request.ResponseError(fetch.FetchError) + +pub fn requestor() -> ibroadcast_http.Requestor(fetch.FetchError) { + fn(req: request.Request(String)) { + use resp <- promise.try_await(fetch.send(req)) + use resp <- promise.try_await(fetch.read_text_body(resp)) + promise.resolve(Ok(resp)) + } +} diff --git a/src/elekf/utils/navigator.gleam b/src/elekf/utils/navigator.gleam new file mode 100644 index 0000000..dbc07b4 --- /dev/null +++ b/src/elekf/utils/navigator.gleam @@ -0,0 +1,2 @@ +@external(javascript, "../../navigator_ffi.mjs", "userAgent") +pub fn user_agent() -> String diff --git a/src/elekf/utils/option.gleam b/src/elekf/utils/option.gleam new file mode 100644 index 0000000..d8dd947 --- /dev/null +++ b/src/elekf/utils/option.gleam @@ -0,0 +1,7 @@ +import gleam/option + +/// Asserts that an option contains a value, and returns that value. +pub fn assert_some(val: option.Option(a)) -> a { + let assert option.Some(content) = val + content +} diff --git a/src/elekf/web/authed_view.gleam b/src/elekf/web/authed_view.gleam new file mode 100644 index 0000000..3ec1dcf --- /dev/null +++ b/src/elekf/web/authed_view.gleam @@ -0,0 +1,244 @@ +import gleam/io +import gleam/int +import gleam/list +import gleam/option +import gleam/map +import gleam/string +import gleam/javascript/promise +import lustre/element.{text} +import lustre/element/html.{div, h3, p} +import lustre/attribute +import lustre/event +import lustre/effect +import elekf/web/common +import ibroadcast/library/library as library_api +import ibroadcast/authed_request.{RequestConfig} +import elekf/api/base_request_config.{base_request_config} +import elekf/utils/http +import elekf/library.{Library} +import elekf/library/track.{Track} +import elekf/transfer/library as library_transfer +import elekf/web/components/player +import elekf/web/components/search +import elekf/web/utils + +pub type Player { + Player(inner: player.Model) + DisabledPlayer +} + +pub type Model { + Model( + loading_library: Bool, + library: option.Option(Library), + settings: option.Option(common.Settings), + request_config: RequestConfig, + player: Player, + search: search.Model, + ) +} + +pub type Msg { + UpdateAuthData(common.AuthData) + LibraryResult(Result(library_api.ResponseData, http.ResponseError)) + PlayerMsg(player.Msg) + Search(search.Msg) + StartPlay(Int, Track) + ShuffleAll +} + +pub fn init(auth_data: common.AuthData) { + let model = + Model( + loading_library: True, + library: option.None, + settings: option.None, + request_config: form_request_config(auth_data), + player: DisabledPlayer, + search: search.init(), + ) + + #(model, load_library(model)) +} + +pub fn update(model: Model, msg) { + case msg { + UpdateAuthData(auth_data) -> { + let new_config = form_request_config(auth_data) + + let #(player, player_effect) = case model.player { + Player(player_model) -> { + let #(new_model, e) = + utils.update_child( + player_model, + player.UpdateRequestConfig(new_config), + player.update, + PlayerMsg, + ) + #(Player(new_model), e) + } + DisabledPlayer -> #(DisabledPlayer, effect.none()) + } + #( + Model(..model, request_config: new_config, player: player), + player_effect, + ) + } + LibraryResult(Ok(data)) -> { + let settings = + common.Settings( + artwork_server: data.settings.artwork_server, + streaming_server: data.settings.streaming_server, + ) + #( + Model( + ..model, + library: option.Some(library_transfer.from(data.library)), + settings: option.Some(settings), + player: Player(player.init(settings, model.request_config)), + ), + effect.none(), + ) + } + LibraryResult(Error(error)) -> { + io.println_error("Library load failed:") + io.debug(error) + #(model, effect.none()) + } + StartPlay(track_id, track) -> { + let assert Player(inner) = model.player + + let #(player_model, player_effect) = + utils.update_child( + inner, + player.Play(track_id, track), + player.update, + PlayerMsg, + ) + + #(Model(..model, player: Player(player_model)), player_effect) + } + PlayerMsg(msg) -> { + let assert Player(inner) = model.player + + let #(player_model, player_effect) = + utils.update_child(inner, msg, player.update, PlayerMsg) + + #(Model(..model, player: Player(player_model)), player_effect) + } + Search(msg) -> { + let search_model = search.update(model.search, msg) + + #(Model(..model, search: search_model), effect.none()) + } + ShuffleAll -> { + let assert option.Some(lib) = model.library + let assert Player(inner) = model.player + + let tracks = + lib.tracks + |> map.to_list() + |> list.shuffle() + + let #(player_model, player_effect) = + utils.update_child( + inner, + player.AddToQueue(tracks), + player.update, + PlayerMsg, + ) + + #(Model(..model, player: Player(player_model)), player_effect) + } + } +} + +pub fn view(model: Model) { + let search_text = string.lowercase(model.search.search_text) + + div( + [attribute.id("authed-view-content")], + [ + case model.library { + option.None -> p([], [text("Loading library…")]) + option.Some(lib) -> + div( + [attribute.class("track-list")], + list.append( + [ + div( + [ + attribute.class("track-list-shuffle-all"), + event.on_click(ShuffleAll), + ], + [h3([attribute.class("track-title")], [text("Shuffle all")])], + ), + ], + list.map( + map.to_list(lib.tracks) + |> list.filter(fn(track) { + search_text == "" || string.contains( + { track.1 }.title_lower, + search_text, + ) + }), + fn(item) { + let #(id, track) = item + let album = library.assert_album(lib, track.album_id) + let artist = library.assert_artist(lib, track.artist_id) + div( + [ + attribute.id("track-list-" <> int.to_string(id)), + attribute.type_("button"), + event.on_click(StartPlay(id, track)), + attribute.attribute("role", "button"), + ], + [ + h3([attribute.class("track-title")], [text(track.title)]), + p([attribute.class("track-artist")], [text(artist.name)]), + p([attribute.class("track-album")], [text(album.name)]), + ], + ) + }, + ), + ), + ) + }, + div( + [attribute.id("search-positioner")], + [ + search.view(model.search) + |> element.map(Search), + ], + ), + case model.player { + Player(inner) -> + div( + [attribute.id("player")], + [ + player.view(inner) + |> element.map(PlayerMsg), + ], + ) + DisabledPlayer -> text("No player") + }, + ], + ) +} + +fn load_library(model: Model) { + use dispatch <- effect.from() + + library_api.get_library(model.request_config, http.requestor()) + |> promise.map(LibraryResult) + |> promise.tap(dispatch) + + Nil +} + +fn form_request_config(auth_data: common.AuthData) { + RequestConfig( + auth_info: authed_request.AuthInfo(auth_data.user.id, auth_data.user.token), + base_config: base_request_config(auth_data.device.name), + ) +} diff --git a/src/elekf/web/common.gleam b/src/elekf/web/common.gleam new file mode 100644 index 0000000..2fd5bf0 --- /dev/null +++ b/src/elekf/web/common.gleam @@ -0,0 +1,9 @@ +import elekf/api/auth/models as auth_models + +pub type AuthData { + AuthData(user: auth_models.User, device: auth_models.Device) +} + +pub type Settings { + Settings(artwork_server: String, streaming_server: String) +} diff --git a/src/elekf/web/components/player.gleam b/src/elekf/web/components/player.gleam new file mode 100644 index 0000000..f593cb0 --- /dev/null +++ b/src/elekf/web/components/player.gleam @@ -0,0 +1,172 @@ +import gleam/list +import gleam/uri +import lustre/element.{text} +import lustre/element/html.{audio, button, div, p} +import lustre/attribute +import lustre/event +import lustre/effect +import elektrofoni +import elekf/web/common +import elekf/library/track.{Track} +import elekf/utils/date +import ibroadcast/authed_request.{RequestConfig} +import ibroadcast/streaming + +pub type PlayInfo { + Playing(track_id: Int, track: Track, url: uri.Uri) + NoTrack +} + +pub type Model { + Model( + settings: common.Settings, + play_info: PlayInfo, + play_queue: List(#(Int, Track)), + loading_stream: Bool, + request_config: RequestConfig, + end_callback_registered: Bool, + ) +} + +pub type Msg { + Play(Int, Track) + AddToQueue(List(#(Int, Track))) + NextTrack + Clear + UpdateRequestConfig(RequestConfig) +} + +pub fn init(settings: common.Settings, request_config: RequestConfig) { + Model(settings, NoTrack, [], False, request_config, False) +} + +pub fn update(model: Model, msg) { + case msg { + Play(track_id, track) -> { + let register_effect = case model.end_callback_registered { + True -> effect.none() + False -> register_ended_callback() + } + + let url = form_url(track_id, track, model) + #( + Model( + ..model, + end_callback_registered: True, + play_info: Playing(track_id: track_id, track: track, url: url), + ), + register_effect, + ) + } + AddToQueue(tracks) -> { + case list.length(tracks) { + length if length > 0 -> { + let #(play_info, tracks) = case model.play_info { + Playing(..) -> #(model.play_info, tracks) + NoTrack -> { + let #([first], tail) = list.split(tracks, 1) + #( + Playing(first.0, first.1, form_url(first.0, first.1, model)), + tail, + ) + } + } + + #( + Model(..model, play_info: play_info, play_queue: tracks), + effect.none(), + ) + } + _ -> #(model, effect.none()) + } + } + NextTrack -> { + case model.play_queue { + [] -> #(model, effect.none()) + queue -> { + let #([first], tail) = list.split(queue, 1) + #( + Model( + ..model, + play_info: Playing( + first.0, + first.1, + form_url(first.0, first.1, model), + ), + play_queue: tail, + ), + effect.none(), + ) + } + } + } + Clear -> #( + Model(..model, play_info: NoTrack, play_queue: []), + effect.none(), + ) + UpdateRequestConfig(config) -> #( + Model(..model, request_config: config), + effect.none(), + ) + } +} + +pub fn view(model: Model) { + let src = case model.play_info { + Playing(url: url, ..) -> uri.to_string(url) + NoTrack -> "" + } + + div( + [attribute.id("player-wrapper")], + [ + p( + [attribute.id("player-wrapper-track-title")], + [ + text(case model.play_info { + Playing(track: track, ..) -> track.title + NoTrack -> "--" + }), + ], + ), + audio( + [ + attribute.id("player-elem"), + attribute.src(src), + attribute.controls(False), + attribute.autoplay(True), + ], + [], + ), + button( + [ + attribute.id("player-clear"), + attribute.type_("button"), + attribute.disabled(model.play_info == NoTrack), + event.on_click(Clear), + ], + [text("🛑")], + ), + ], + ) +} + +fn form_url(track_id: Int, track: Track, model: Model) { + streaming.form_url( + track.file, + track_id, + model.request_config, + model.settings.streaming_server, + elektrofoni.bitrate, + date.unix_now() + elektrofoni.track_expiry_length, + ) +} + +fn register_ended_callback() { + use dispatch <- effect.from() + + do_register_ended_callback(fn() { dispatch(NextTrack) }) +} + +@external(javascript, "../../../player_ffi.mjs", "registerEndedCallback") +fn do_register_ended_callback(callback: fn() -> Nil) -> Nil diff --git a/src/elekf/web/components/search.gleam b/src/elekf/web/components/search.gleam new file mode 100644 index 0000000..79d3fb6 --- /dev/null +++ b/src/elekf/web/components/search.gleam @@ -0,0 +1,51 @@ +import lustre/element.{text} +import lustre/element/html.{button, div, input} +import lustre/attribute +import lustre/event + +pub type Model { + Model(search_text: String, show_search: Bool) +} + +pub type Msg { + ToggleShow + UpdateSearch(String) +} + +pub fn init() { + Model(search_text: "", show_search: False) +} + +pub fn update(model, msg) { + case msg { + ToggleShow -> Model(..model, show_search: !model.show_search) + UpdateSearch(text) -> Model(..model, search_text: text) + } +} + +pub fn view(model: Model) { + let input_type = case model.show_search { + True -> "search" + False -> "hidden" + } + + div( + [attribute.id("search-bar")], + [ + div( + [attribute.id("search-bar-input-wrapper")], + [ + input([ + attribute.type_(input_type), + attribute.placeholder("Search"), + event.on_input(UpdateSearch), + ]), + ], + ), + button( + [attribute.type_("button"), event.on_click(ToggleShow)], + [text("🔎")], + ), + ], + ) +} diff --git a/src/elekf/web/login_view.gleam b/src/elekf/web/login_view.gleam new file mode 100644 index 0000000..0b4d61f --- /dev/null +++ b/src/elekf/web/login_view.gleam @@ -0,0 +1,128 @@ +import gleam/string +import gleam/javascript/promise +import lustre/element.{text} +import lustre/element/html.{button, form, hr, input, p} +import lustre/attribute +import lustre/event +import lustre/effect +import ibroadcast/auth/login_token +import elektrofoni +import elekf/api/base_request_config.{base_request_config} +import elekf/utils/http + +pub type Model { + Model( + device_name: String, + login_token: String, + logging_in: Bool, + login_failed: Bool, + error: String, + ) +} + +pub type Msg { + AttemptLogin + LoginResult(Result(login_token.ResponseData, http.ResponseError)) + OnDeviceNameInput(String) + OnLoginTokenInput(String) +} + +pub fn init() { + Model("", "", False, False, "") +} + +pub fn update(model, msg) { + case msg { + OnDeviceNameInput(name) -> #( + Model(..model, device_name: name), + effect.none(), + ) + OnLoginTokenInput(token) -> #( + Model(..model, login_token: token), + effect.none(), + ) + AttemptLogin -> #(Model(..model, logging_in: True), start_login(model)) + LoginResult(Ok(login_token.Failed)) -> #( + Model( + ..model, + login_failed: True, + error: "The credentials were not accepted.", + ), + effect.none(), + ) + LoginResult(Error(error)) -> #( + Model( + ..model, + logging_in: False, + login_failed: True, + error: "An error prevented logging in: " <> string.inspect(error), + ), + effect.none(), + ) + } +} + +pub fn view(model: Model) { + form( + [ + attribute.attribute("method", "post"), + attribute.id("login-form"), + event.on_submit(AttemptLogin), + ], + [ + input([ + attribute.id("device-name"), + attribute.name("device-name"), + attribute.placeholder("Device name (i.e. Kalle's Fairphone)"), + event.on_input(OnDeviceNameInput), + ]), + input([ + attribute.id("login-token"), + attribute.name("login-token"), + attribute.placeholder("Login token"), + event.on_input(OnLoginTokenInput), + ]), + button( + [ + attribute.type_("submit"), + attribute.disabled( + model.logging_in || model.device_name == "" || model.login_token == "", + ), + ], + [text("Log in")], + ), + case model.login_failed { + True -> + p( + [attribute.class("form-error")], + [text("Logging in failed due to: " <> model.error)], + ) + False -> text("") + }, + hr([]), + p( + [attribute.class("form-info")], + [ + text( + "To log in, obtain your login token from iBroadcast by going to Menu -> Apps.", + ), + ], + ), + ], + ) +} + +fn start_login(model: Model) { + use dispatch <- effect.from() + + login_token.log_in( + elektrofoni.app_id, + model.login_token, + base_request_config(model.device_name), + http.requestor(), + ) + |> promise.map(LoginResult) + |> promise.tap(dispatch) + + Nil +} diff --git a/src/elekf/web/main.gleam b/src/elekf/web/main.gleam new file mode 100644 index 0000000..a36647a --- /dev/null +++ b/src/elekf/web/main.gleam @@ -0,0 +1,177 @@ +import gleam/io +import gleam/option +import lustre +import lustre/effect +import lustre/element/html.{div} +import lustre/element +import lustre/attribute +import elekf/web/common +import elekf/web/login_view +import elekf/web/authed_view +import elekf/web/view +import elekf/web/utils +import elekf/api/auth/storage as auth_storage +import elekf/api/auth/models as auth_models +import elekf/utils/option as option_utils +import ibroadcast/auth/login_token + +type Model { + Model( + auth_storage: auth_storage.AuthStorage, + auth_data: option.Option(common.AuthData), + current_view: view.View, + login_view: login_view.Model, + authed_view: option.Option(authed_view.Model), + ) +} + +type Msg { + LoginView(login_view.Msg) + AuthedView(authed_view.Msg) +} + +pub fn main() { + let app = lustre.application(init, update, view) + let assert Ok(_) = lustre.start(app, "[data-lustre-app]", Nil) + + Nil +} + +fn init(_) { + let auth_storage = auth_storage.get() + + let stored_auth_data = auth_storage.read(auth_storage) + let auth_data = case stored_auth_data { + Ok(storage_format) -> + option.Some(common.AuthData( + user: auth_models.User( + id: storage_format.user.id, + token: storage_format.user.token, + session_uuid: storage_format.user.session_uuid, + ), + device: auth_models.Device(name: storage_format.device.name), + )) + Error(_) -> option.None + } + + let current_view = case auth_data { + option.Some(_) -> view.AuthedView + option.None -> view.LoginView + } + + let #(authed_view_model, authed_view_effect) = case auth_data { + option.Some(data) -> { + let #(m, e) = authed_view.init(data) + #(option.Some(m), effect.map(e, AuthedView)) + } + option.None -> #(option.None, effect.none()) + } + + #( + Model( + auth_storage: auth_storage, + auth_data: auth_data, + current_view: current_view, + login_view: login_view.init(), + authed_view: authed_view_model, + ), + authed_view_effect, + ) +} + +fn update(model: Model, msg) { + case msg { + LoginView(login_view.LoginResult(Ok(login_token.AuthData(data)))) -> { + logged_in(model, data.token, data.session) + } + LoginView(login_msg) -> { + let #(login_model, login_effect) = + utils.update_child( + model.login_view, + login_msg, + login_view.update, + LoginView, + ) + #(Model(..model, login_view: login_model), login_effect) + } + AuthedView(authed_msg) -> { + case model.authed_view { + option.Some(authed_view) -> { + let #(authed_model, authed_effect) = + utils.update_child( + authed_view, + authed_msg, + authed_view.update, + AuthedView, + ) + #( + Model(..model, authed_view: option.Some(authed_model)), + authed_effect, + ) + } + + option.None -> #(model, effect.none()) + } + } + } +} + +fn view(model: Model) { + html.main( + [], + [ + case model.current_view { + view.LoginView -> + div( + [attribute.id("login-view")], + [ + login_view.view(model.login_view) + |> element.map(LoginView), + ], + ) + view.AuthedView -> + div( + [attribute.id("authed-view")], + [ + authed_view.view(option_utils.assert_some(model.authed_view)) + |> element.map(AuthedView), + ], + ) + }, + ], + ) +} + +fn logged_in(model: Model, token: String, session: login_token.Session) { + let auth_data = + common.AuthData( + user: auth_models.User( + id: session.user_id, + token: token, + session_uuid: session.session_uuid, + ), + device: auth_models.Device(name: model.login_view.device_name), + ) + + case + auth_storage.write( + model.auth_storage, + auth_storage.StorageFormat(user: auth_data.user, device: auth_data.device), + ) + { + Ok(_) -> Nil + Error(err) -> { + io.println_error("Unable to store authentication credentials:") + io.debug(err) + } + } + + #( + Model( + ..model, + auth_data: option.Some(auth_data), + current_view: view.AuthedView, + ), + effect.none(), + ) +} diff --git a/src/elekf/web/utils.gleam b/src/elekf/web/utils.gleam new file mode 100644 index 0000000..1d8d5eb --- /dev/null +++ b/src/elekf/web/utils.gleam @@ -0,0 +1,17 @@ +import lustre/effect.{Effect} + +/// Update child view of a given view. +/// +/// The `model` and `msg` must be the child view's, the `updater` is the child +/// view's `update` function, and the `mapper` maps the child model's emitted +/// effect into the parent model's effect. +pub fn update_child( + model: a, + msg: b, + updater: fn(a, b) -> #(a, Effect(b)), + mapper: fn(b) -> d, +) { + let #(new_model, new_effect) = updater(model, msg) + let new_effect = effect.map(new_effect, mapper) + #(new_model, new_effect) +} diff --git a/src/elekf/web/view.gleam b/src/elekf/web/view.gleam new file mode 100644 index 0000000..859cc5f --- /dev/null +++ b/src/elekf/web/view.gleam @@ -0,0 +1,4 @@ +pub type View { + LoginView + AuthedView +} diff --git a/src/elektrofoni.gleam b/src/elektrofoni.gleam new file mode 100644 index 0000000..a235d24 --- /dev/null +++ b/src/elektrofoni.gleam @@ -0,0 +1,13 @@ +import ibroadcast/app_info.{AppInfo} + +pub const app_name = "elektrofoni" + +pub const app_version = "1.0.0" + +pub const app_id = 1098 + +pub const app_info = AppInfo(app_name, app_version) + +pub const bitrate = 256 + +pub const track_expiry_length = 10_800_000 diff --git a/src/ibroadcast/app_info.gleam b/src/ibroadcast/app_info.gleam new file mode 100644 index 0000000..acc6baa --- /dev/null +++ b/src/ibroadcast/app_info.gleam @@ -0,0 +1,3 @@ +pub type AppInfo { + AppInfo(client: String, version: String) +} diff --git a/src/ibroadcast/auth/login_token.gleam b/src/ibroadcast/auth/login_token.gleam new file mode 100644 index 0000000..eec6212 --- /dev/null +++ b/src/ibroadcast/auth/login_token.gleam @@ -0,0 +1,88 @@ +import gleam/dynamic +import gleam/result +import gleam/json +import gleam/javascript/promise +import ibroadcast/request_params.{RequestParams} +import ibroadcast/request.{DecodeFailed, RequestConfig, ResponseError} +import ibroadcast/http.{Requestor} +import ibroadcast/servers + +// pub type Settings { +// Settings(streaming_server: String, artwork_server: String) +// } + +// pub type User { +// User(id: Int) +// } + +pub type Session { + Session(user_id: Int, session_uuid: String) +} + +pub type User { + User(id: String, token: String, session: Session) +} + +pub type ResponseData { + AuthData(user: User) + Failed +} + +pub fn log_in( + app_id: Int, + login_token: String, + config: RequestConfig, + requestor: Requestor(err_type), +) -> promise.Promise(Result(ResponseData, ResponseError(err_type))) { + use resp <- promise.try_await(request.raw_request( + servers.api, + request_params(app_id, login_token), + config, + requestor, + )) + + promise.resolve( + json.decode(resp.body, log_in_payload_decoder) + |> result.map_error(DecodeFailed), + ) +} + +fn request_params(app_id: Int, login_token: String) -> RequestParams { + [ + #("mode", json.string("login_token")), + #("type", json.string("account")), + #("app_id", json.int(app_id)), + #("login_token", json.string(login_token)), + ] +} + +fn log_in_payload_decoder( + data: dynamic.Dynamic, +) -> Result(ResponseData, List(dynamic.DecodeError)) { + use result <- result.try(dynamic.field("result", dynamic.bool)(data)) + case result { + True -> dynamic.decode1(AuthData, dynamic.field("user", user_decoder))(data) + False -> Ok(Failed) + } +} + +fn user_decoder( + data: dynamic.Dynamic, +) -> Result(User, List(dynamic.DecodeError)) { + dynamic.decode3( + User, + dynamic.field("id", dynamic.string), + dynamic.field("token", dynamic.string), + dynamic.field("session", session_decoder), + )(data) +} + +fn session_decoder( + data: dynamic.Dynamic, +) -> Result(Session, List(dynamic.DecodeError)) { + dynamic.decode2( + Session, + dynamic.field("user_id", dynamic.int), + dynamic.field("session_uuid", dynamic.string), + )(data) +} diff --git a/src/ibroadcast/auth/logout.gleam b/src/ibroadcast/auth/logout.gleam new file mode 100644 index 0000000..8a33891 --- /dev/null +++ b/src/ibroadcast/auth/logout.gleam @@ -0,0 +1,6 @@ +import gleam/json +import ibroadcast/request_params.{RequestParams} + +pub fn request_params() -> RequestParams { + [#("mode", json.string("logout"))] +} diff --git a/src/ibroadcast/authed_request.gleam b/src/ibroadcast/authed_request.gleam new file mode 100644 index 0000000..b0da1c5 --- /dev/null +++ b/src/ibroadcast/authed_request.gleam @@ -0,0 +1,35 @@ +import gleam/uri +import gleam/json +import ibroadcast/http.{Requestor} +import ibroadcast/request_params.{RequestParams} +import ibroadcast/request +import ibroadcast/utils + +pub type AuthInfo { + AuthInfo(user_id: Int, token: String) +} + +pub type RequestConfig { + RequestConfig(base_config: request.RequestConfig, auth_info: AuthInfo) +} + +pub fn authed_params(auth_info: AuthInfo) -> RequestParams { + [ + #("user_id", json.int(auth_info.user_id)), + #("token", json.string(auth_info.token)), + ] +} + +pub fn authed_request( + url: uri.Uri, + params: RequestParams, + config: RequestConfig, + requestor: Requestor(err_type), +) { + request.raw_request( + url, + utils.combine_params([authed_params(config.auth_info), params]), + config.base_config, + requestor, + ) +} diff --git a/src/ibroadcast/device_info.gleam b/src/ibroadcast/device_info.gleam new file mode 100644 index 0000000..4818df1 --- /dev/null +++ b/src/ibroadcast/device_info.gleam @@ -0,0 +1,3 @@ +pub type DeviceInfo { + DeviceInfo(name: String, user_agent: String) +} diff --git a/src/ibroadcast/http.gleam b/src/ibroadcast/http.gleam new file mode 100644 index 0000000..f00199b --- /dev/null +++ b/src/ibroadcast/http.gleam @@ -0,0 +1,8 @@ +import gleam/http/request +import gleam/http/response +import gleam/javascript/promise + +/// The HTTP client to use in API calls. +pub type Requestor(err_type) = + fn(request.Request(String)) -> + promise.Promise(Result(response.Response(String), err_type)) diff --git a/src/ibroadcast/library/library.gleam b/src/ibroadcast/library/library.gleam new file mode 100644 index 0000000..1bdd93e --- /dev/null +++ b/src/ibroadcast/library/library.gleam @@ -0,0 +1,194 @@ +import gleam/javascript/promise +import gleam/json +import gleam/map.{Map} +import gleam/dynamic +import gleam/result +import ibroadcast/servers +import ibroadcast/request.{DecodeFailed} +import ibroadcast/authed_request.{RequestConfig} +import ibroadcast/request_params.{RequestParams} +import ibroadcast/http.{Requestor} +import ibroadcast/map_format + +pub type Album { + Album( + name: String, + tracks: List(Int), + artist_id: Int, + trashed: Bool, + rating: Int, + disc: Int, + year: Int, + ) +} + +pub type Artist { + Artist(name: String, tracks: List(Int), trashed: Bool, rating: Int) +} + +pub type Track { + Track( + number: Int, + year: Int, + title: String, + genre: String, + length: Int, + album_id: Int, + artwork_id: Int, + artist_id: Int, + enid: Int, + uploaded_on: String, + trashed: Bool, + size: Int, + path: String, + uid: String, + rating: Int, + plays: Int, + file: String, + type_: String, + replay_gain: String, + uploaded_time: String, + ) +} + +pub type Library { + Library( + albums: Map(Int, Album), + artists: Map(Int, Artist), + tracks: Map(Int, Track), + ) +} + +pub type Settings { + Settings(artwork_server: String, streaming_server: String) +} + +pub type ResponseData { + ResponseData(library: Library, settings: Settings) +} + +pub fn get_library(config: RequestConfig, requestor: Requestor(err_type)) { + use resp <- promise.try_await(authed_request.authed_request( + servers.library, + request_params(), + config, + requestor, + )) + + promise.resolve( + json.decode(resp.body, payload_decoder()) + |> result.map_error(DecodeFailed), + ) +} + +fn request_params() -> RequestParams { + [#("mode", json.string("library"))] +} + +fn payload_decoder() { + dynamic.decode2( + ResponseData, + dynamic.field("library", library_decoder()), + dynamic.field("settings", settings_decoder()), + ) +} + +fn library_decoder() { + dynamic.decode3( + Library, + dynamic.field("albums", albums_decoder()), + dynamic.field("artists", artists_decoder()), + dynamic.field("tracks", tracks_decoder()), + ) +} + +fn settings_decoder() { + dynamic.decode2( + Settings, + dynamic.field("artwork_server", dynamic.string), + dynamic.field("streaming_server", dynamic.string), + ) +} + +fn albums_decoder() { + map_format.decoder(map_format.string_int_decoder(), album_decoder()) +} + +fn album_decoder() { + dynamic.decode7( + Album, + dynamic.element(0, dynamic.string), + dynamic.element(1, dynamic.list(dynamic.int)), + dynamic.element(2, dynamic.int), + dynamic.element(3, dynamic.bool), + dynamic.element(4, dynamic.int), + dynamic.element(5, dynamic.int), + dynamic.element(6, dynamic.int), + ) +} + +fn artists_decoder() { + map_format.decoder(map_format.string_int_decoder(), artist_decoder()) +} + +fn artist_decoder() { + dynamic.decode4( + Artist, + dynamic.element(0, dynamic.string), + dynamic.element(1, dynamic.list(dynamic.int)), + dynamic.element(2, dynamic.bool), + dynamic.element(3, dynamic.int), + ) +} + +fn tracks_decoder() { + map_format.decoder(map_format.string_int_decoder(), track_decoder()) +} + +fn track_decoder() { + fn(data: dynamic.Dynamic) { + use number <- result.try(dynamic.element(0, dynamic.int)(data)) + use year <- result.try(dynamic.element(1, dynamic.int)(data)) + use title <- result.try(dynamic.element(2, dynamic.string)(data)) + use genre <- result.try(dynamic.element(3, dynamic.string)(data)) + use length <- result.try(dynamic.element(4, dynamic.int)(data)) + use album_id <- result.try(dynamic.element(5, dynamic.int)(data)) + use artwork_id <- result.try(dynamic.element(6, dynamic.int)(data)) + use artist_id <- result.try(dynamic.element(7, dynamic.int)(data)) + use enid <- result.try(dynamic.element(8, dynamic.int)(data)) + use uploaded_on <- result.try(dynamic.element(9, dynamic.string)(data)) + use trashed <- result.try(dynamic.element(10, dynamic.bool)(data)) + use size <- result.try(dynamic.element(11, dynamic.int)(data)) + use path <- result.try(dynamic.element(12, dynamic.string)(data)) + use uid <- result.try(dynamic.element(13, dynamic.string)(data)) + use rating <- result.try(dynamic.element(14, dynamic.int)(data)) + use plays <- result.try(dynamic.element(15, dynamic.int)(data)) + use file <- result.try(dynamic.element(16, dynamic.string)(data)) + use type_ <- result.try(dynamic.element(17, dynamic.string)(data)) + use replay_gain <- result.try(dynamic.element(18, dynamic.string)(data)) + use uploaded_time <- result.try(dynamic.element(19, dynamic.string)(data)) + + Ok(Track( + number: number, + year: year, + title: title, + genre: genre, + length: length, + album_id: album_id, + artwork_id: artwork_id, + artist_id: artist_id, + enid: enid, + uploaded_on: uploaded_on, + trashed: trashed, + size: size, + path: path, + uid: uid, + rating: rating, + plays: plays, + file: file, + type_: type_, + replay_gain: replay_gain, + uploaded_time: uploaded_time, + )) + } +} diff --git a/src/ibroadcast/map_format.gleam b/src/ibroadcast/map_format.gleam new file mode 100644 index 0000000..aee9b07 --- /dev/null +++ b/src/ibroadcast/map_format.gleam @@ -0,0 +1,43 @@ +import gleam/result +import gleam/map.{Map} +import gleam/int +import gleam/dynamic.{Decoder} + +/// This decoder will attempt to decode a gleam `Map` using the provided decoders. +/// If either a key or a value fails to decode, that entry is just ignored. +pub fn decoder( + key_decoder: Decoder(k), + val_decoder: Decoder(v), +) -> Decoder(Map(k, v)) { + fn(data) { + // First decode into a `Map(Dynamic, Dynamic)` + use dynamic_map <- result.map(dynamic.map(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()) + + // 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) + // Otherwise just ignore it and carry on + _, _ -> map + } + } +} + +/// A decoder that accepts strings that contain integers. +pub fn string_int_decoder() -> Decoder(Int) { + fn(data) { + use strval <- result.try(dynamic.string(data)) + int.parse(strval) + |> result.replace_error([ + dynamic.DecodeError( + expected: "A string representing an int", + found: strval, + path: [], + ), + ]) + } +} diff --git a/src/ibroadcast/request.gleam b/src/ibroadcast/request.gleam new file mode 100644 index 0000000..ce7cb3a --- /dev/null +++ b/src/ibroadcast/request.gleam @@ -0,0 +1,45 @@ +import gleam/uri +import gleam/result +import gleam/http as gleam_http +import gleam/http/request +import gleam/javascript/promise +import gleam/json +import ibroadcast/utils +import ibroadcast/http.{Requestor} +import ibroadcast/app_info.{AppInfo} +import ibroadcast/device_info.{DeviceInfo} +import ibroadcast/request_params.{RequestParams} + +pub type RequestConfig { + RequestConfig(app_info: AppInfo, device_info: DeviceInfo) +} + +pub type ResponseError(requestor_err_type) { + RequestFailed(requestor_err_type) + DecodeFailed(json.DecodeError) +} + +pub fn base_params(config: RequestConfig) -> RequestParams { + [ + #("client", json.string(config.app_info.client)), + #("version", json.string(config.app_info.version)), + #("device_name", json.string(config.device_info.name)), + #("user_agent", json.string(config.device_info.user_agent)), + ] +} + +pub fn raw_request( + url: uri.Uri, + params: RequestParams, + config: RequestConfig, + requestor: Requestor(err_type), +) { + let assert Ok(req) = request.from_uri(url) + let params = utils.combine_params([base_params(config), params]) + let req = + req + |> request.set_body(json.to_string(json.object(params))) + |> request.set_method(gleam_http.Post) + requestor(req) + |> promise.map(fn(r) { result.map_error(r, RequestFailed) }) +} diff --git a/src/ibroadcast/request_params.gleam b/src/ibroadcast/request_params.gleam new file mode 100644 index 0000000..f3f1de1 --- /dev/null +++ b/src/ibroadcast/request_params.gleam @@ -0,0 +1,4 @@ +import gleam/json + +pub type RequestParams = + List(#(String, json.Json)) diff --git a/src/ibroadcast/servers.gleam b/src/ibroadcast/servers.gleam new file mode 100644 index 0000000..b97c7b2 --- /dev/null +++ b/src/ibroadcast/servers.gleam @@ -0,0 +1,22 @@ +import gleam/option.{None, Some} +import gleam/uri.{Uri} + +pub const api = Uri( + scheme: Some("https"), + userinfo: None, + host: Some("api.ibroadcast.com"), + port: Some(443), + path: "/", + query: None, + fragment: None, +) + +pub const library = Uri( + scheme: Some("https"), + userinfo: None, + host: Some("library.ibroadcast.com"), + port: Some(443), + path: "/", + query: None, + fragment: None, +) diff --git a/src/ibroadcast/streaming.gleam b/src/ibroadcast/streaming.gleam new file mode 100644 index 0000000..13b9f95 --- /dev/null +++ b/src/ibroadcast/streaming.gleam @@ -0,0 +1,32 @@ +import gleam/string +import gleam/int +import gleam/uri +import gleam/option +import ibroadcast/authed_request.{RequestConfig} + +pub fn form_url( + track_file: String, + track_id: Int, + config: RequestConfig, + server: String, + bitrate: Int, + expires: Int, +) { + let assert Ok(url) = uri.parse(server) + uri.Uri( + ..url, + path: string.replace( + track_file, + "/128/", + "/" <> int.to_string(bitrate) <> "/", + ), + query: option.Some(uri.query_to_string([ + #("Expires", int.to_string(expires)), + #("Signature", config.auth_info.token), + #("file_id", int.to_string(track_id)), + #("user_id", int.to_string(config.auth_info.user_id)), + #("platform", config.base_config.app_info.client), + #("version", config.base_config.app_info.version), + ])), + ) +} diff --git a/src/ibroadcast/utils.gleam b/src/ibroadcast/utils.gleam new file mode 100644 index 0000000..4642988 --- /dev/null +++ b/src/ibroadcast/utils.gleam @@ -0,0 +1,17 @@ +import gleam/map +import gleam/list +import ibroadcast/request_params.{RequestParams} + +/// Combine two request parameter lists into one, later values override former. +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() +} diff --git a/src/navigator_ffi.mjs b/src/navigator_ffi.mjs new file mode 100644 index 0000000..1da91e8 --- /dev/null +++ b/src/navigator_ffi.mjs @@ -0,0 +1,3 @@ +export function userAgent() { + return globalThis.navigator.userAgent; +} diff --git a/src/player_ffi.mjs b/src/player_ffi.mjs new file mode 100644 index 0000000..2e6ac26 --- /dev/null +++ b/src/player_ffi.mjs @@ -0,0 +1,5 @@ +const player_id = "player-elem"; + +export function registerEndedCallback(callback) { + document.getElementById(player_id).addEventListener("ended", callback); +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..2879cf3 --- /dev/null +++ b/style.css @@ -0,0 +1,61 @@ +:root { + --background-color: #abcdef; + --text-color: #123456; +} + +body { + background-color: var(--background-color); + color: var(--text-color); + overflow: hidden; +} + +main { + margin: 10px; + overflow: hidden; +} + +#authed-view-content { + height: calc(100vh - 2 * 10px); + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: stretch; + overflow: hidden; + gap: 10px; +} + +#player { +} + +#search-positioner { + position: relative; +} + +#search-bar { + position: absolute; + transform: translateY(-200%); + width: 100%; + + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: stretch; + gap: 10px; +} + +#search-bar button { + justify-self: flex-end; +} + +#search-bar-input-wrapper { + flex: 1 1; +} + +#search-bar-input-wrapper input { + width: 100%; +} + +.track-list { + flex: 1 1; + overflow-y: auto; +} diff --git a/test/elektrofoni_test.gleam b/test/elektrofoni_test.gleam new file mode 100644 index 0000000..3831e7a --- /dev/null +++ b/test/elektrofoni_test.gleam @@ -0,0 +1,12 @@ +import gleeunit +import gleeunit/should + +pub fn main() { + gleeunit.main() +} + +// gleeunit test functions end in `_test` +pub fn hello_world_test() { + 1 + |> should.equal(1) +}