loggs

ca5f84e · gmorell · 2026-06-11 19:20

15 files +139 -72

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 &lt;token&gt;</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 &lt;token&gt;</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