neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
loggs
ca5f84e · gmorell · 2026-06-11 19:20
Files changed
modified
lib/oh_my_ex_oh_ircd/irc/scrobble_serv.ex
+0
−7
@@ -210,13 +210,6 @@ defmodule OhMyExOhIrcd.IRC.ScrobbleServ do
| 210 | 210 | text = render(cfg.format, who, artist, track, album) |
| 211 | 211 | Channel.privmsg(channel, service_prefix(), text) |
| 212 | 212 | |
| 213 | − Scrobbles.record_scrobble(cfg.channel_id, %{ | |
| 214 | − who: clip(who, 64), | |
| 215 | − artist: clip(artist, 256), | |
| 216 | − track: clip(track, 256), | |
| 217 | − album: album && clip(album, 256) | |
| 218 | − }) | |
| 219 | − | |
| 220 | 213 | {:reply, :ok, %{state | rate: mark_rate(state.rate, channel, who_norm)}} |
| 221 | 214 | end |
| 222 | 215 | end |
modified
lib/oh_my_ex_oh_ircd_web/controllers/scrobble_controller.ex
+59
−17
@@ -1,9 +1,24 @@
| 1 | 1 | defmodule OhMyExOhIrcdWeb.ScrobbleController do |
| 2 | 2 | @moduledoc """ |
| 3 | − HTTP endpoint for posting scrobbles. Authenticated by a channel+user | |
| 4 | − bearer token (see `OhMyExOhIrcdWeb.Plugs.ScrobbleAuth`). The token fixes | |
| 5 | − BOTH the target channel and the announced identity (`who`), so the | |
| 6 | − request body carries only track metadata — it cannot set `who`. | |
| 3 | + HTTP endpoints for the Scropple-compatible scrobble API. Authenticated by | |
| 4 | + a channel+user bearer token (see `OhMyExOhIrcdWeb.Plugs.ScrobbleAuth`). | |
| 5 | + The token fixes BOTH the target channel and the announced identity | |
| 6 | + (`who`), so request bodies carry only track metadata. | |
| 7 | + | |
| 8 | + Two endpoints, matching the Scropple Android client / reference server: | |
| 9 | + | |
| 10 | + * `POST /api/v1/now-playing` — a single track object. Announced to the | |
| 11 | + channel by ScrobbleServ ("now playing …"). Ephemeral; not persisted. | |
| 12 | + Returns `204`. | |
| 13 | + | |
| 14 | + * `POST /api/v1/scrobble` — a single object, a bare array, or | |
| 15 | + `{"scrobbles": [...]}`. Each confirmed play is persisted to the | |
| 16 | + channel's history (shown on the web page). Returns | |
| 17 | + `200 {"accepted": n}`. | |
| 18 | + | |
| 19 | + Track fields: `artist`, `track`, `album?` (plus `albumArtist`, | |
| 20 | + `durationSec`, `timestamp` which we currently ignore). Unknown fields, | |
| 21 | + including a client-supplied `who`, are ignored. | |
| 7 | 22 | """ |
| 8 | 23 | |
| 9 | 24 | use OhMyExOhIrcdWeb, :controller |
@@ -17,7 +32,8 @@ defmodule OhMyExOhIrcdWeb.ScrobbleController do
| 17 | 32 | |
| 18 | 33 | @limits %{"artist" => 256, "track" => 256, "album" => 256} |
| 19 | 34 | |
| 20 | − def create(conn, params) do | |
| 35 | + # POST /api/v1/now-playing — single object, announced to the channel. | |
| 36 | + def now_playing(conn, params) do | |
| 21 | 37 | channel = conn.assigns.scrobble_channel |
| 22 | 38 | who = conn.assigns.scrobble_who |
| 23 | 39 |
@@ -33,7 +49,7 @@ defmodule OhMyExOhIrcdWeb.ScrobbleController do
| 33 | 49 | ) |
| 34 | 50 | |
| 35 | 51 | log(channel, who, result) |
| 36 | − respond(conn, result) | |
| 52 | + send_resp(conn, :no_content, "") | |
| 37 | 53 | |
| 38 | 54 | {:error, detail} -> |
| 39 | 55 | log(channel, who, {:invalid_payload, detail}) |
@@ -44,6 +60,42 @@ defmodule OhMyExOhIrcdWeb.ScrobbleController do
| 44 | 60 | end |
| 45 | 61 | end |
| 46 | 62 | |
| 63 | + # POST /api/v1/scrobble — one or many confirmed plays, persisted to history. | |
| 64 | + def create(conn, params) do | |
| 65 | + channel = conn.assigns.scrobble_channel | |
| 66 | + who = conn.assigns.scrobble_who | |
| 67 | + | |
| 68 | + items = extract_list(params) | |
| 69 | + | |
| 70 | + accepted = | |
| 71 | + Enum.reduce(items, 0, fn item, acc -> | |
| 72 | + case validate(item) do | |
| 73 | + {:ok, fields} -> | |
| 74 | + Scrobbles.record_scrobble(channel.id, %{ | |
| 75 | + who: who, | |
| 76 | + artist: fields["artist"], | |
| 77 | + track: fields["track"], | |
| 78 | + album: fields["album"] | |
| 79 | + }) | |
| 80 | + | |
| 81 | + acc + 1 | |
| 82 | + | |
| 83 | + {:error, _detail} -> | |
| 84 | + acc | |
| 85 | + end | |
| 86 | + end) | |
| 87 | + | |
| 88 | + log(channel, who, {:scrobbled, accepted, length(items)}) | |
| 89 | + json(conn, %{accepted: accepted}) | |
| 90 | + end | |
| 91 | + | |
| 92 | + # Accept a single object, a bare JSON array (Plug nests it under "_json"), | |
| 93 | + # or `{"scrobbles": [...]}`. | |
| 94 | + defp extract_list(%{"_json" => list}) when is_list(list), do: list | |
| 95 | + defp extract_list(%{"scrobbles" => list}) when is_list(list), do: list | |
| 96 | + defp extract_list(params) when is_map(params), do: [params] | |
| 97 | + defp extract_list(_), do: [] | |
| 98 | + | |
| 47 | 99 | # Best-effort debug log of each authenticated request's outcome. |
| 48 | 100 | defp log(channel, who, outcome) do |
| 49 | 101 | {status, detail} = log_fields(outcome) |
@@ -55,17 +107,7 @@ defmodule OhMyExOhIrcdWeb.ScrobbleController do
| 55 | 107 | defp log_fields({:error, :not_joined}), do: {"not_joined", nil} |
| 56 | 108 | defp log_fields({:error, reason}), do: {"error", to_string(reason)} |
| 57 | 109 | defp log_fields({:invalid_payload, detail}), do: {"invalid_payload", detail} |
| 58 | − | |
| 59 | − defp respond(conn, :ok), do: json(conn, %{status: "ok"}) | |
| 60 | − | |
| 61 | − defp respond(conn, {:ignored, reason}), | |
| 62 | − do: json(conn, %{status: "ignored", reason: reason}) | |
| 63 | − | |
| 64 | − defp respond(conn, {:error, :not_joined}), | |
| 65 | − do: conn |> put_status(:not_found) |> json(%{error: "not_joined"}) | |
| 66 | − | |
| 67 | − defp respond(conn, {:error, _}), | |
| 68 | − do: conn |> put_status(:service_unavailable) |> json(%{error: "service_unavailable"}) | |
| 110 | + defp log_fields({:scrobbled, accepted, total}), do: {"scrobbled", "#{accepted}/#{total}"} | |
| 69 | 111 | |
| 70 | 112 | ## Validation — pick out artist/track/album (non-blank strings, capped, |
| 71 | 113 | ## with control chars stripped so nothing can forge an IRC line). Any |
modified
lib/oh_my_ex_oh_ircd_web/live/user_live/scrobble.ex
+7
−6
@@ -274,12 +274,12 @@ defmodule OhMyExOhIrcdWeb.UserLive.Scrobble do
| 274 | 274 | <button class="btn btn-sm btn-primary">Mint token</button> |
| 275 | 275 | </form> |
| 276 | 276 | <p class="text-xs text-base-content/60"> |
| 277 | − Each token is bound to you and a fixed display name. POST scrobbles to | |
| 278 | − <code>/api/v1/scrobble</code> | |
| 279 | − with header <code>Authorization: Bearer <token></code> | |
| 280 | − and a JSON body containing <code>artist</code> | |
| 281 | − and <code>track</code> | |
| 282 | − (optional <code>album</code>). | |
| 277 | + Each token is bound to you and a fixed display name. Point a Scropple-compatible | |
| 278 | + client at this server with header <code>Authorization: Bearer <token></code>: | |
| 279 | + <code>POST /api/v1/now-playing</code> | |
| 280 | + (a single track) is announced to the channel, and <code>POST /api/v1/scrobble</code> | |
| 281 | + (one or a batch) is saved to the history below. Track | |
| 282 | + fields: <code>artist</code>, <code>track</code>, optional <code>album</code>. | |
| 283 | 283 | </p> |
| 284 | 284 | </section> |
| 285 | 285 |
@@ -351,6 +351,7 @@ defmodule OhMyExOhIrcdWeb.UserLive.Scrobble do
| 351 | 351 | end |
| 352 | 352 | |
| 353 | 353 | defp log_badge("ok"), do: "badge-success" |
| 354 | + defp log_badge("scrobbled"), do: "badge-success" | |
| 354 | 355 | defp log_badge("ignored"), do: "badge-warning" |
| 355 | 356 | defp log_badge(_), do: "badge-error" |
| 356 | 357 |
modified
lib/oh_my_ex_oh_ircd_web/router.ex
+1
−0
@@ -35,6 +35,7 @@ defmodule OhMyExOhIrcdWeb.Router do
| 35 | 35 | scope "/api/v1", OhMyExOhIrcdWeb do |
| 36 | 36 | pipe_through :scrobble_api |
| 37 | 37 | |
| 38 | + post "/now-playing", ScrobbleController, :now_playing | |
| 38 | 39 | post "/scrobble", ScrobbleController, :create |
| 39 | 40 | end |
| 40 | 41 |
modified
test/oh_my_ex_oh_ircd_web/controllers/scrobble_controller_test.exs
+72
−42
@@ -1,6 +1,6 @@
| 1 | 1 | defmodule OhMyExOhIrcdWeb.ScrobbleControllerTest do |
| 2 | 2 | @moduledoc """ |
| 3 | − These exercise the HTTP scrobble endpoint. `async: false` so the SQL | |
| 3 | + Exercises the Scropple-compatible HTTP API. `async: false` so the SQL | |
| 4 | 4 | sandbox runs in shared mode — `ScrobbleServ` (a singleton in the app |
| 5 | 5 | tree) needs to see the channel/config/token rows the test inserts. |
| 6 | 6 | """ |
@@ -24,26 +24,24 @@ defmodule OhMyExOhIrcdWeb.ScrobbleControllerTest do
| 24 | 24 | end |
| 25 | 25 | |
| 26 | 26 | defp auth(conn, token), do: put_req_header(conn, "authorization", "Bearer #{token}") |
| 27 | + defp post_json(conn, path, data), do: post(conn, path, Jason.encode!(data)) | |
| 28 | + defp track, do: %{"artist" => "Boards of Canada", "track" => "Roygbiv"} | |
| 27 | 29 | |
| 28 | − defp payload, do: %{"artist" => "Boards of Canada", "track" => "Roygbiv"} | |
| 30 | + ## Auth | |
| 29 | 31 | |
| 30 | 32 | test "rejects a missing token with 401", %{conn: conn} do |
| 31 | − conn = post(conn, ~p"/api/v1/scrobble", payload()) | |
| 33 | + conn = post_json(conn, ~p"/api/v1/scrobble", track()) | |
| 32 | 34 | assert json_response(conn, 401) == %{"error" => "unauthorized"} |
| 33 | 35 | end |
| 34 | 36 | |
| 35 | 37 | test "rejects a bad token with 401", %{conn: conn} do |
| 36 | − conn = conn |> auth("garbage") |> post(~p"/api/v1/scrobble", payload()) | |
| 38 | + conn = conn |> auth("garbage") |> post_json(~p"/api/v1/scrobble", track()) | |
| 37 | 39 | assert json_response(conn, 401) == %{"error" => "unauthorized"} |
| 38 | 40 | end |
| 39 | 41 | |
| 40 | − test "rejects a missing required field with 422", %{conn: conn, token: token} do | |
| 41 | − conn = conn |> auth(token) |> post(~p"/api/v1/scrobble", %{"artist" => "y"}) | |
| 42 | − body = json_response(conn, 422) | |
| 43 | − assert body["error"] == "invalid_payload" | |
| 44 | − end | |
| 42 | + ## now-playing — announced, ephemeral (not persisted) | |
| 45 | 43 | |
| 46 | − test "ignores a client-supplied who (who is token-bound)", %{ | |
| 44 | + test "now-playing on a joined channel returns 204 and is logged ok", %{ | |
| 47 | 45 | conn: conn, |
| 48 | 46 | token: token, |
| 49 | 47 | chan: chan |
@@ -51,62 +49,94 @@ defmodule OhMyExOhIrcdWeb.ScrobbleControllerTest do
| 51 | 49 | {:ok, _} = Scrobbles.ensure_config(chan) |
| 52 | 50 | {:ok, _} = Scrobbles.set_joined(chan, true) |
| 53 | 51 | |
| 54 | − conn = | |
| 55 | − conn |> auth(token) |> post(~p"/api/v1/scrobble", Map.put(payload(), "who", "someone-else")) | |
| 52 | + conn = conn |> auth(token) |> post_json(~p"/api/v1/now-playing", track()) | |
| 53 | + assert response(conn, 204) == "" | |
| 54 | + | |
| 55 | + # Ephemeral: not written to history. | |
| 56 | + assert Scrobbles.recent(chan, 5) == [] | |
| 57 | + assert [log] = Scrobbles.recent_logs(chan, 5) | |
| 58 | + assert log.status == "ok" | |
| 59 | + assert log.who == "gmp" | |
| 60 | + end | |
| 61 | + | |
| 62 | + test "now-playing with a missing field returns 422", %{conn: conn, token: token} do | |
| 63 | + conn = conn |> auth(token) |> post_json(~p"/api/v1/now-playing", %{"artist" => "y"}) | |
| 64 | + assert json_response(conn, 422)["error"] == "invalid_payload" | |
| 65 | + end | |
| 56 | 66 | |
| 57 | − assert json_response(conn, 200) == %{"status" => "ok"} | |
| 67 | + test "now-playing when not joined still 204s but logs not_joined", %{ | |
| 68 | + conn: conn, | |
| 69 | + token: token, | |
| 70 | + chan: chan | |
| 71 | + } do | |
| 72 | + conn = conn |> auth(token) |> post_json(~p"/api/v1/now-playing", track()) | |
| 73 | + assert response(conn, 204) == "" | |
| 58 | 74 | |
| 59 | − assert [scrobble] = Scrobbles.recent(chan, 5) | |
| 60 | − assert scrobble.who == "gmp" | |
| 75 | + assert [log] = Scrobbles.recent_logs(chan, 5) | |
| 76 | + assert log.status == "not_joined" | |
| 61 | 77 | end |
| 62 | 78 | |
| 63 | − test "ignores unexpected fields", %{conn: conn, token: token, chan: chan} do | |
| 79 | + test "now-playing ignores a client-supplied who", %{conn: conn, token: token, chan: chan} do | |
| 64 | 80 | {:ok, _} = Scrobbles.ensure_config(chan) |
| 65 | 81 | {:ok, _} = Scrobbles.set_joined(chan, true) |
| 66 | 82 | |
| 67 | 83 | conn = |
| 68 | 84 | conn |
| 69 | 85 | |> auth(token) |
| 70 | − |> post(~p"/api/v1/scrobble", Map.put(payload(), "evil", "\r\nPRIVMSG #x :hax")) | |
| 86 | + |> post_json(~p"/api/v1/now-playing", Map.put(track(), "who", "someone")) | |
| 71 | 87 | |
| 72 | − assert json_response(conn, 200) == %{"status" => "ok"} | |
| 73 | − assert [_scrobble] = Scrobbles.recent(chan, 5) | |
| 88 | + assert response(conn, 204) == "" | |
| 89 | + assert [log] = Scrobbles.recent_logs(chan, 5) | |
| 90 | + assert log.who == "gmp" | |
| 74 | 91 | end |
| 75 | 92 | |
| 76 | − test "valid token but ScrobbleServ not joined returns 404 and is logged", %{ | |
| 93 | + ## scrobble — persisted to history (one, array, or wrapped) | |
| 94 | + | |
| 95 | + test "a single scrobble is persisted with the token's who", %{ | |
| 77 | 96 | conn: conn, |
| 78 | 97 | token: token, |
| 79 | 98 | chan: chan |
| 80 | 99 | } do |
| 81 | − # No invite happened → config (if any) is joined: false. | |
| 82 | − conn = conn |> auth(token) |> post(~p"/api/v1/scrobble", payload()) | |
| 83 | − assert json_response(conn, 404) == %{"error" => "not_joined"} | |
| 100 | + conn = conn |> auth(token) |> post_json(~p"/api/v1/scrobble", track()) | |
| 101 | + assert json_response(conn, 200) == %{"accepted" => 1} | |
| 84 | 102 | |
| 85 | − assert [log] = Scrobbles.recent_logs(chan, 5) | |
| 86 | − assert log.status == "not_joined" | |
| 87 | − assert log.who == "gmp" | |
| 103 | + assert [s] = Scrobbles.recent(chan, 5) | |
| 104 | + assert s.who == "gmp" | |
| 105 | + assert s.artist == "Boards of Canada" | |
| 106 | + assert s.track == "Roygbiv" | |
| 88 | 107 | end |
| 89 | 108 | |
| 90 | − test "joined + enabled channel announces and records", %{conn: conn, token: token, chan: chan} do | |
| 91 | − {:ok, _} = Scrobbles.ensure_config(chan) | |
| 92 | − {:ok, _} = Scrobbles.set_joined(chan, true) | |
| 109 | + test "a batch array persists every item", %{conn: conn, token: token, chan: chan} do | |
| 110 | + batch = [track(), %{"artist" => "Aphex Twin", "track" => "Xtal"}] | |
| 111 | + conn = conn |> auth(token) |> post_json(~p"/api/v1/scrobble", batch) | |
| 112 | + assert json_response(conn, 200) == %{"accepted" => 2} | |
| 113 | + assert length(Scrobbles.recent(chan, 5)) == 2 | |
| 114 | + end | |
| 93 | 115 | |
| 94 | − conn = conn |> auth(token) |> post(~p"/api/v1/scrobble", payload()) | |
| 95 | − assert json_response(conn, 200) == %{"status" => "ok"} | |
| 116 | + test "a wrapped {scrobbles: [...]} batch is accepted", %{conn: conn, token: token, chan: chan} do | |
| 117 | + conn = conn |> auth(token) |> post_json(~p"/api/v1/scrobble", %{"scrobbles" => [track()]}) | |
| 118 | + assert json_response(conn, 200) == %{"accepted" => 1} | |
| 119 | + assert length(Scrobbles.recent(chan, 5)) == 1 | |
| 120 | + end | |
| 96 | 121 | |
| 97 | − assert [scrobble] = Scrobbles.recent(chan, 5) | |
| 98 | − assert scrobble.who == "gmp" | |
| 99 | − assert scrobble.artist == "Boards of Canada" | |
| 100 | − assert scrobble.track == "Roygbiv" | |
| 122 | + test "invalid items in a batch are skipped, not fatal", %{conn: conn, token: token, chan: chan} do | |
| 123 | + batch = [track(), %{"artist" => "no track here"}] | |
| 124 | + conn = conn |> auth(token) |> post_json(~p"/api/v1/scrobble", batch) | |
| 125 | + assert json_response(conn, 200) == %{"accepted" => 1} | |
| 126 | + assert length(Scrobbles.recent(chan, 5)) == 1 | |
| 101 | 127 | end |
| 102 | 128 | |
| 103 | − test "disabled channel is ignored, not announced", %{conn: conn, token: token, chan: chan} do | |
| 104 | − {:ok, _} = Scrobbles.ensure_config(chan) | |
| 105 | − {:ok, _} = Scrobbles.set_joined(chan, true) | |
| 106 | − {:ok, _} = Scrobbles.set_enabled(chan, false) | |
| 129 | + test "scrobble ignores client who and unexpected fields", %{ | |
| 130 | + conn: conn, | |
| 131 | + token: token, | |
| 132 | + chan: chan | |
| 133 | + } do | |
| 134 | + body = track() |> Map.put("who", "someone-else") |> Map.put("evil", "\r\nPRIVMSG #x :hax") | |
| 135 | + conn = conn |> auth(token) |> post_json(~p"/api/v1/scrobble", body) | |
| 136 | + assert json_response(conn, 200) == %{"accepted" => 1} | |
| 107 | 137 | |
| 108 | − body = conn |> auth(token) |> post(~p"/api/v1/scrobble", payload()) |> json_response(200) | |
| 109 | − assert body == %{"status" => "ignored", "reason" => "disabled"} | |
| 110 | − assert Scrobbles.recent(chan, 5) == [] | |
| 138 | + assert [s] = Scrobbles.recent(chan, 5) | |
| 139 | + assert s.who == "gmp" | |
| 140 | + assert s.track == "Roygbiv" | |
| 111 | 141 | end |
| 112 | 142 | end |
Parents: 5474077