scroobbler

6ccfeee · gmorell · 2026-06-11 16:05

40 files +1980 -7

Files changed

modified config/config.exs
+1 −0
@@ -29,6 +29,7 @@ config :oh_my_ex_oh_ircd,
29 29 # PRIVMSGs to them route to the corresponding service process.
30 30 config :oh_my_ex_oh_ircd, :services,
31 31 chanserv_nick: "ChanServ",
32 + scrobbleserv_nick: "ScrobbleServ",
32 33 service_host: "services."
33 34
34 35 # WebIRC LiveView toggle. When enabled, logged-in users can open
added lib/oh_my_ex_oh_ircd/irc/scrobble_serv.ex
+640 −0
@@ -0,0 +1,640 @@
1 +defmodule OhMyExOhIrcd.IRC.ScrobbleServ do
2 + @moduledoc """
3 + Pseudo-service that announces "now playing" scrobbles into channels.
4 +
5 + Like `ChanServ`, it lives as a Horde-supervised cluster-wide singleton
6 + registered under `{:user, downcased_nick}`, so PRIVMSGs and INVITEs to
7 + it from anywhere in the cluster route here.
8 +
9 + ScrobbleServ is *invited* into a registered channel (`/invite
10 + ScrobbleServ #chan` by the founder or a channel op), where it joins as a
11 + voiced member — voice is required so a `+m` (moderated) channel doesn't
12 + silently drop its announcements. It is configured per-channel via direct
13 + messages (`/msg ScrobbleServ HELP`) and via the web UI.
14 +
15 + Scrobbles arrive out-of-band over an authenticated HTTP endpoint (see
16 + `OhMyExOhIrcdWeb.ScrobbleController`), which calls `announce/5`. The
17 + service checks the channel config (enabled / allowlist / rate limit),
18 + posts the rendered line to the channel, and records it for the web UI.
19 +
20 + Config and history live in `OhMyExOhIrcd.Scrobbles`. Rate-limit state is
21 + in-memory (per `{channel, who}`), so it resets if the singleton restarts
22 + — acceptable for a "now playing" bot.
23 + """
24 +
25 + use GenServer, restart: :transient
26 + require Logger
27 +
28 + alias OhMyExOhIrcd.Cluster.{HordeRegistry, HordeSupervisor}
29 + alias OhMyExOhIrcd.{Accounts, Channels, Scrobbles}
30 + alias OhMyExOhIrcd.IRC.Channel
31 +
32 + # Voice rank keeps announcements flowing through `+m`.
33 + @join_access :voice
34 + @announce_timeout 5_000
35 + @max_line 400
36 +
37 + ## Public API
38 +
39 + @doc "Configured ScrobbleServ nick (default `ScrobbleServ`)."
40 + def nick do
41 + Application.get_env(:oh_my_ex_oh_ircd, :services, [])
42 + |> Keyword.get(:scrobbleserv_nick, "ScrobbleServ")
43 + end
44 +
45 + def host do
46 + Application.get_env(:oh_my_ex_oh_ircd, :services, [])
47 + |> Keyword.get(:service_host, "services.")
48 + end
49 +
50 + @doc "Ensure ScrobbleServ is running somewhere in the cluster."
51 + def ensure_started do
52 + key = {:user, String.downcase(nick())}
53 +
54 + case HordeRegistry.whereis(key) do
55 + pid when is_pid(pid) ->
56 + {:ok, pid}
57 +
58 + nil ->
59 + spec = %{
60 + id: __MODULE__,
61 + start: {__MODULE__, :start_link, []},
62 + restart: :transient
63 + }
64 +
65 + case Horde.DynamicSupervisor.start_child(HordeSupervisor, spec) do
66 + {:ok, pid} -> {:ok, pid}
67 + {:error, {:already_started, pid}} -> {:ok, pid}
68 + other -> other
69 + end
70 + end
71 + end
72 +
73 + def start_link(_opts \\ []) do
74 + GenServer.start_link(__MODULE__, [],
75 + name: HordeRegistry.via({:user, String.downcase(nick())})
76 + )
77 + end
78 +
79 + @doc """
80 + Announce a scrobble into `channel`. Returns `:ok` when posted,
81 + `{:ignored, reason}` when suppressed by config (`:disabled`,
82 + `:not_allowed`, `:rate_limited`), `{:error, :not_joined}` when the
83 + service isn't set up in that channel, or `{:error, :unavailable}` if the
84 + singleton can't be reached / times out.
85 + """
86 + def announce(channel, who, artist, track, album \\ nil) do
87 + case HordeRegistry.whereis({:user, String.downcase(nick())}) do
88 + pid when is_pid(pid) ->
89 + try do
90 + GenServer.call(pid, {:announce, channel, who, artist, track, album}, @announce_timeout)
91 + catch
92 + _, _ -> {:error, :unavailable}
93 + end
94 +
95 + nil ->
96 + {:error, :unavailable}
97 + end
98 + end
99 +
100 + ## Server
101 +
102 + @impl true
103 + def init(_) do
104 + Logger.info("ScrobbleServ online as #{nick()}")
105 + {:ok, %{rate: %{}}, {:continue, :rejoin}}
106 + end
107 +
108 + @impl true
109 + def handle_continue(:rejoin, state) do
110 + for channel <- safe_joined_channels() do
111 + join_channel(channel)
112 + end
113 +
114 + {:noreply, state}
115 + end
116 +
117 + # WHOIS support.
118 + @impl true
119 + def handle_call(:whois_info, _from, state) do
120 + info = %{
121 + nick: nick(),
122 + user: "service",
123 + host: host(),
124 + realname: "Scrobble Services",
125 + channels: [],
126 + idle: 0,
127 + signon: 0,
128 + away: nil,
129 + account: nil
130 + }
131 +
132 + {:reply, info, state}
133 + end
134 +
135 + def handle_call({:announce, channel, who, artist, track, album}, _from, state) do
136 + case Scrobbles.get_config(channel) do
137 + nil ->
138 + {:reply, {:error, :not_joined}, state}
139 +
140 + %{joined: false} ->
141 + {:reply, {:error, :not_joined}, state}
142 +
143 + %{enabled: false} ->
144 + {:reply, {:ignored, :disabled}, state}
145 +
146 + cfg ->
147 + do_announce(state, channel, cfg, who, artist, track, album)
148 + end
149 + end
150 +
151 + ## Invites / kicks
152 +
153 + @impl true
154 + def handle_info({:irc_invite, inviter, channel}, state) do
155 + case require_op(channel, inviter) do
156 + {:ok, _user} ->
157 + case Scrobbles.ensure_config(channel) do
158 + {:ok, _cfg} ->
159 + Scrobbles.set_joined(channel, true)
160 + join_channel(channel)
161 +
162 + reply(inviter, [
163 + "Joined #{channel}. Announcements are ON.",
164 + "Mint an API token with: TOKEN NEW #{channel}",
165 + "See all commands with: HELP"
166 + ])
167 +
168 + {:error, :not_registered} ->
169 + reply(inviter, "#{channel} must be registered with ChanServ before I can join.")
170 +
171 + {:error, _cs} ->
172 + reply(inviter, "Could not set up scrobbling on #{channel}.")
173 + end
174 +
175 + :stop ->
176 + :ok
177 + end
178 +
179 + {:noreply, state}
180 + end
181 +
182 + def handle_info({:irc_kicked, channel}, state) do
183 + Scrobbles.set_joined(channel, false)
184 + {:noreply, %{state | rate: drop_channel_rate(state.rate, channel)}}
185 + end
186 +
187 + def handle_info({:irc_privmsg, %{sender: sender, text: text}}, state) do
188 + sender_nick = extract_nick(sender)
189 + Logger.debug("ScrobbleServ <- #{sender_nick}: #{text}")
190 + state = handle_command(text, sender_nick, state)
191 + {:noreply, state}
192 + end
193 +
194 + def handle_info(_, state), do: {:noreply, state}
195 +
196 + ## Announce internals
197 +
198 + defp do_announce(state, channel, cfg, who, artist, track, album) do
199 + who_norm = normalize_who(who)
200 +
201 + cond do
202 + cfg.restrict_to_allowlist and who_norm not in cfg.allowlist ->
203 + {:reply, {:ignored, :not_allowed}, state}
204 +
205 + rate_limited?(state.rate, channel, who_norm, cfg.rate_limit_seconds) ->
206 + {:reply, {:ignored, :rate_limited}, state}
207 +
208 + true ->
209 + ensure_member(channel)
210 + text = render(cfg.format, who, artist, track, album)
211 + Channel.privmsg(channel, service_prefix(), text)
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 + {:reply, :ok, %{state | rate: mark_rate(state.rate, channel, who_norm)}}
221 + end
222 + end
223 +
224 + defp render(format, who, artist, track, album) do
225 + format
226 + |> String.replace("%who%", strip(who))
227 + |> String.replace("%artist%", strip(artist))
228 + |> String.replace("%track%", strip(track))
229 + |> String.replace("%album%", strip(album || ""))
230 + |> clip(@max_line)
231 + end
232 +
233 + # Re-join if the Channel singleton restarted out from under us.
234 + defp ensure_member(channel) do
235 + case Channel.member_info(channel, nick()) do
236 + %{access: _} -> :ok
237 + _ -> join_channel(channel)
238 + end
239 + catch
240 + _, _ -> :ok
241 + end
242 +
243 + defp join_channel(channel) do
244 + Channel.join(channel, nick(), self(), nil, service_prefix())
245 + Channel.service_set_access(channel, nick(), @join_access, nick())
246 + :ok
247 + catch
248 + _, _ -> :ok
249 + end
250 +
251 + ## Rate limiting (in-memory, per {downcased channel, downcased who})
252 +
253 + defp rate_limited?(_rate, _channel, _who, seconds) when seconds <= 0, do: false
254 +
255 + defp rate_limited?(rate, channel, who, seconds) do
256 + case Map.get(rate, {downcase(channel), who}) do
257 + nil -> false
258 + last -> now() - last < seconds
259 + end
260 + end
261 +
262 + defp mark_rate(rate, channel, who), do: Map.put(rate, {downcase(channel), who}, now())
263 +
264 + defp drop_channel_rate(rate, channel) do
265 + down = downcase(channel)
266 + rate |> Enum.reject(fn {{c, _w}, _} -> c == down end) |> Map.new()
267 + end
268 +
269 + ## Command dispatch
270 +
271 + defp handle_command(text, who, state) do
272 + case String.split(text, ~r/\s+/, trim: true) do
273 + [] -> state
274 + [cmd | args] -> do_command(String.upcase(cmd), args, who, state)
275 + end
276 + end
277 +
278 + defp do_command("HELP", _args, who, state) do
279 + reply(who, [
280 + "ScrobbleServ announces \"now playing\" scrobbles into channels.",
281 + "Invite me with /invite ScrobbleServ #channel (founder/op of a registered channel).",
282 + "Commands (all but HELP require you to be founder/op of the channel):",
283 + " INFO #chan Show current configuration.",
284 + " ENABLE / DISABLE #chan Turn announcements on/off.",
285 + " FORMAT #chan <template> Set the line. Tokens: %who% %artist% %track% %album%.",
286 + " RATELIMIT #chan <seconds> Min seconds between a who's announcements (0 = off).",
287 + " RESTRICT #chan ON|OFF Only announce allowlisted who-names.",
288 + " ALLOW / DENY #chan <who> Add/remove a who-name from the allowlist.",
289 + " ALLOWLIST #chan Show the allowlist.",
290 + " TOKEN NEW #chan <who> [label] Mint an API token bound to you (shown once).",
291 + " TOKEN LIST #chan List tokens (who + label).",
292 + " TOKEN REVOKE #chan <id> Revoke a token by id.",
293 + " PART #chan Leave the channel and stop announcing."
294 + ])
295 +
296 + state
297 + end
298 +
299 + defp do_command("INFO", [channel], who, state) do
300 + with_op(channel, who, state, fn ->
301 + case Scrobbles.get_config(channel) do
302 + nil ->
303 + reply(who, "#{channel} has no scrobble config. Invite me first.")
304 +
305 + cfg ->
306 + reply(who, [
307 + "#{channel} scrobble config:",
308 + " Joined: #{cfg.joined}",
309 + " Enabled: #{cfg.enabled}",
310 + " Format: #{cfg.format}",
311 + " Ratelimit: #{cfg.rate_limit_seconds}s",
312 + " Restrict: #{cfg.restrict_to_allowlist} (#{length(cfg.allowlist)} allowed)"
313 + ])
314 + end
315 + end)
316 + end
317 +
318 + defp do_command("ENABLE", [channel], who, state) do
319 + with_op(channel, who, state, fn ->
320 + Scrobbles.set_enabled(channel, true)
321 + reply(who, "Announcements ENABLED on #{channel}.")
322 + end)
323 + end
324 +
325 + defp do_command("DISABLE", [channel], who, state) do
326 + with_op(channel, who, state, fn ->
327 + Scrobbles.set_enabled(channel, false)
328 + reply(who, "Announcements DISABLED on #{channel}.")
329 + end)
330 + end
331 +
332 + defp do_command("FORMAT", [channel | rest], who, state) when rest != [] do
333 + with_op(channel, who, state, fn ->
334 + template = Enum.join(rest, " ")
335 +
336 + case Scrobbles.set_format(channel, template) do
337 + {:ok, cfg} -> reply(who, "Format for #{channel} set to: #{cfg.format}")
338 + _ -> reply(who, "Could not set format.")
339 + end
340 + end)
341 + end
342 +
343 + defp do_command("RATELIMIT", [channel, seconds], who, state) do
344 + with_op(channel, who, state, fn ->
345 + case Integer.parse(seconds) do
346 + {n, ""} when n >= 0 ->
347 + case Scrobbles.set_rate_limit(channel, n) do
348 + {:ok, _} -> reply(who, "Rate limit for #{channel} set to #{n}s.")
349 + _ -> reply(who, "Could not set rate limit.")
350 + end
351 +
352 + _ ->
353 + reply(who, "Usage: RATELIMIT #{channel} <seconds>")
354 + end
355 + end)
356 + end
357 +
358 + defp do_command("RESTRICT", [channel, toggle], who, state) do
359 + with_op(channel, who, state, fn ->
360 + case parse_toggle(toggle) do
361 + {:ok, value} ->
362 + Scrobbles.set_restrict(channel, value)
363 + reply(who, "Allowlist enforcement on #{channel}: #{if value, do: "ON", else: "OFF"}.")
364 +
365 + :error ->
366 + reply(who, "Usage: RESTRICT #{channel} ON|OFF")
367 + end
368 + end)
369 + end
370 +
371 + defp do_command("ALLOW", [channel, target], who, state) do
372 + with_op(channel, who, state, fn ->
373 + Scrobbles.add_allow(channel, target)
374 + reply(who, "Added #{target} to the #{channel} allowlist.")
375 + end)
376 + end
377 +
378 + defp do_command("DENY", [channel, target], who, state) do
379 + with_op(channel, who, state, fn ->
380 + Scrobbles.remove_allow(channel, target)
381 + reply(who, "Removed #{target} from the #{channel} allowlist.")
382 + end)
383 + end
384 +
385 + defp do_command("ALLOWLIST", [channel], who, state) do
386 + with_op(channel, who, state, fn ->
387 + case Scrobbles.get_config(channel) do
388 + %{allowlist: [_ | _] = list} ->
389 + reply(who, "#{channel} allowlist: " <> Enum.join(list, ", "))
390 +
391 + _ ->
392 + reply(who, "#{channel} allowlist is empty.")
393 + end
394 + end)
395 + end
396 +
397 + defp do_command("TOKEN", ["NEW", channel, who_name | rest], who, state) do
398 + with_op(channel, who, state, fn ->
399 + label = if rest == [], do: nil, else: Enum.join(rest, " ")
400 +
401 + case op_user(channel, who) do
402 + nil ->
403 + reply(who, "Could not mint a token.")
404 +
405 + user ->
406 + case Scrobbles.mint_token(channel, user, who_name, label) do
407 + {:ok, display, _token} ->
408 + reply(who, [
409 + "New scrobble token for #{channel} as \"#{who_name}\" (shown once — store it now):",
410 + " #{display}",
411 + "POST it as `Authorization: Bearer <token>` to /api/scrobble."
412 + ])
413 +
414 + _ ->
415 + reply(who, "Could not mint a token.")
416 + end
417 + end
418 + end)
419 + end
420 +
421 + defp do_command("TOKEN", ["NEW" | _], who, state) do
422 + reply(who, "Usage: TOKEN NEW #channel <who> [label]")
423 + state
424 + end
425 +
426 + defp do_command("TOKEN", ["LIST", channel], who, state) do
427 + with_op(channel, who, state, fn ->
428 + case Scrobbles.list_tokens(channel) do
429 + [] ->
430 + reply(who, "#{channel} has no active tokens.")
431 +
432 + tokens ->
433 + reply(who, "Active tokens for #{channel}:")
434 +
435 + Enum.each(tokens, fn t ->
436 + used = if t.last_used_at, do: DateTime.to_iso8601(t.last_used_at), else: "never"
437 + label = if t.label, do: " (#{t.label})", else: ""
438 + reply(who, " ##{t.id} as #{t.who}#{label} — last used: #{used}")
439 + end)
440 + end
441 + end)
442 + end
443 +
444 + defp do_command("TOKEN", ["REVOKE", channel, id], who, state) do
445 + with_op(channel, who, state, fn ->
446 + case Integer.parse(id) do
447 + {token_id, ""} ->
448 + Scrobbles.revoke_token(channel, token_id)
449 + reply(who, "Token ##{token_id} on #{channel} revoked (if it existed).")
450 +
451 + _ ->
452 + reply(who, "Usage: TOKEN REVOKE #{channel} <id>")
453 + end
454 + end)
455 + end
456 +
457 + defp do_command("TOKEN", _args, who, state) do
458 + reply(who, "Usage: TOKEN NEW|LIST|REVOKE #channel [args]")
459 + state
460 + end
461 +
462 + defp do_command("PART", [channel], who, state) do
463 + case require_op(channel, who) do
464 + {:ok, _user} ->
465 + Scrobbles.set_joined(channel, false)
466 + Channel.part(channel, nick(), "Scrobbling disabled")
467 + reply(who, "Left #{channel}. I'll stop announcing there.")
468 + %{state | rate: drop_channel_rate(state.rate, channel)}
469 +
470 + :stop ->
471 + state
472 + end
473 + end
474 +
475 + defp do_command(other, _args, who, state) do
476 + reply(who, "Unknown command: #{other}. Try HELP.")
477 + state
478 + end
479 +
480 + ## Authorization
481 +
482 + # Run `fun` only if `who` is founder/op of the registered `channel`.
483 + # Always returns `state` (commands don't mutate it except PART/rate).
484 + defp with_op(channel, who, state, fun) do
485 + case require_op(channel, who) do
486 + {:ok, _user} -> fun.()
487 + :stop -> :ok
488 + end
489 +
490 + state
491 + end
492 +
493 + defp require_op(channel, who) do
494 + case Channels.get(channel) do
495 + nil ->
496 + reply(who, "#{channel} is not registered. Register it with ChanServ first.")
497 + :stop
498 +
499 + rec ->
500 + with {:ok, user} <- require_account(who) do
501 + if op?(user, who, channel, rec) do
502 + {:ok, user}
503 + else
504 + reply(who, "Only the channel founder or an op may do that.")
505 + :stop
506 + end
507 + end
508 + end
509 + end
510 +
511 + defp require_account(who) do
512 + case account_for(who) do
513 + {:ok, user} ->
514 + {:ok, user}
515 +
516 + :error ->
517 + reply(who, "You must be logged in. Connect with SASL or PASS using your account.")
518 + :stop
519 + end
520 + end
521 +
522 + defp op?(user, who, channel, rec) do
523 + cond do
524 + rec.founder_user_id == user.id -> true
525 + persisted_op?(user, channel) -> true
526 + live_op?(who, channel) -> true
527 + true -> false
528 + end
529 + end
530 +
531 + defp persisted_op?(user, channel) do
532 + down = downcase(channel)
533 +
534 + Channels.list_for_user(user)
535 + |> Enum.any?(fn %{record: r, role: role} ->
536 + r.name == down and role in [:founder, :admin, :op]
537 + end)
538 + end
539 +
540 + defp live_op?(who, channel) do
541 + case Channel.member_info(channel, who) do
542 + %{access: a} -> Channel.rank(a) >= Channel.rank(:op)
543 + _ -> false
544 + end
545 + catch
546 + _, _ -> false
547 + end
548 +
549 + defp op_user(channel, who) do
550 + case Channels.get(channel) do
551 + nil ->
552 + nil
553 +
554 + _ ->
555 + case account_for(who) do
556 + {:ok, user} -> user
557 + _ -> nil
558 + end
559 + end
560 + end
561 +
562 + defp account_for(who) do
563 + case HordeRegistry.whereis({:user, String.downcase(who)}) do
564 + pid when is_pid(pid) ->
565 + try do
566 + info = GenServer.call(pid, :whois_info, 2_000)
567 +
568 + case info[:account] && Accounts.get_user_by_email(info.account) do
569 + %_{} = user -> {:ok, user}
570 + _ -> :error
571 + end
572 + catch
573 + _, _ -> :error
574 + end
575 +
576 + nil ->
577 + :error
578 + end
579 + end
580 +
581 + ## Reply helpers
582 +
583 + defp reply(target_nick, lines) when is_list(lines),
584 + do: Enum.each(lines, &reply(target_nick, &1))
585 +
586 + defp reply(target_nick, line) when is_binary(line) do
587 + case HordeRegistry.whereis({:user, String.downcase(target_nick)}) do
588 + pid when is_pid(pid) ->
589 + send(
590 + pid,
591 + {:irc_privmsg,
592 + %{
593 + sender: nick(),
594 + target: target_nick,
595 + text: line,
596 + kind: :notice
597 + }}
598 + )
599 +
600 + nil ->
601 + :ok
602 + end
603 + end
604 +
605 + ## Misc
606 +
607 + defp safe_joined_channels do
608 + Scrobbles.list_joined_channels()
609 + rescue
610 + _ -> []
611 + end
612 +
613 + defp parse_toggle(t) do
614 + case String.upcase(t) do
615 + v when v in ["ON", "TRUE", "1", "YES"] -> {:ok, true}
616 + v when v in ["OFF", "FALSE", "0", "NO"] -> {:ok, false}
617 + _ -> :error
618 + end
619 + end
620 +
621 + defp extract_nick(prefix) when is_binary(prefix) do
622 + case String.split(prefix, "!", parts: 2) do
623 + [n | _] -> n
624 + end
625 + end
626 +
627 + defp normalize_who(who), do: who |> String.trim() |> String.downcase()
628 +
629 + defp strip(s) when is_binary(s), do: String.replace(s, ~r/[\x00-\x1f]/, "")
630 + defp strip(_), do: ""
631 +
632 + defp clip(nil, _), do: nil
633 + defp clip(s, max) when is_binary(s), do: String.slice(s, 0, max)
634 +
635 + defp downcase(s) when is_binary(s), do: String.downcase(s)
636 +
637 + defp now, do: System.system_time(:second)
638 +
639 + defp service_prefix, do: "#{nick()}!service@#{host()}"
640 +end
modified lib/oh_my_ex_oh_ircd/irc/services.ex
+7 −2
@@ -1,14 +1,14 @@
1 1 defmodule OhMyExOhIrcd.IRC.Services do
2 2 @moduledoc """
3 3 Boot-time starter that ensures the cluster-wide pseudo-services
4 − (currently just ChanServ) are running. Sits in the supervision tree
4 + (ChanServ, ScrobbleServ) are running. Sits in the supervision tree
5 5 *after* Horde so it can talk to the cluster registry.
6 6 """
7 7
8 8 use GenServer
9 9 require Logger
10 10
11 − alias OhMyExOhIrcd.IRC.ChanServ
11 + alias OhMyExOhIrcd.IRC.{ChanServ, ScrobbleServ}
12 12
13 13 def start_link(_), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)
14 14
@@ -24,6 +24,11 @@ defmodule OhMyExOhIrcd.IRC.Services do
24 24 other -> Logger.warning("Services: ChanServ failed to start: #{inspect(other)}")
25 25 end
26 26
27 + case ScrobbleServ.ensure_started() do
28 + {:ok, _pid} -> Logger.debug("Services: ScrobbleServ ready")
29 + other -> Logger.warning("Services: ScrobbleServ failed to start: #{inspect(other)}")
30 + end
31 +
27 32 {:noreply, state}
28 33 end
29 34 end
modified lib/oh_my_ex_oh_ircd/irc/stats.ex
+5 −1
@@ -38,7 +38,11 @@ defmodule OhMyExOhIrcd.IRC.Stats do
38 38 """
39 39 @spec users() :: [map()]
40 40 def users do
41 − service_nicks = MapSet.new([String.downcase(OhMyExOhIrcd.IRC.ChanServ.nick())])
41 + service_nicks =
42 + MapSet.new([
43 + String.downcase(OhMyExOhIrcd.IRC.ChanServ.nick()),
44 + String.downcase(OhMyExOhIrcd.IRC.ScrobbleServ.nick())
45 + ])
42 46
43 47 Horde.Registry.select(HordeRegistry, [
44 48 {{{:user, :"$1"}, :"$2", :_}, [], [{{:"$1", :"$2"}}]}
added lib/oh_my_ex_oh_ircd/scrobbles.ex
+215 −0
@@ -0,0 +1,215 @@
1 +defmodule OhMyExOhIrcd.Scrobbles do
2 + @moduledoc """
3 + Context for ScrobbleServ persistence: per-channel config, per-channel
4 + API tokens, and the append-only scrobble history shown in the web UI.
5 +
6 + All channel lookups go through `OhMyExOhIrcd.Channels.get/1`, so only
7 + ChanServ-registered channels can be configured (the FK to `channels`
8 + enforces this at the DB layer, and `on_delete: :delete_all` means
9 + dropping a registration cascades away its config, tokens and history).
10 + """
11 +
12 + import Ecto.Query, warn: false
13 +
14 + alias OhMyExOhIrcd.Repo
15 + alias OhMyExOhIrcd.Accounts.User
16 + alias OhMyExOhIrcd.Channels
17 + alias OhMyExOhIrcd.Channels.ChannelRecord
18 + alias OhMyExOhIrcd.Scrobbles.{ChannelConfig, Scrobble, Token}
19 +
20 + ## Config
21 +
22 + @doc "Fetch the config for a channel by name. Returns nil if absent."
23 + def get_config(channel_name) when is_binary(channel_name) do
24 + case Channels.get(channel_name) do
25 + nil -> nil
26 + %ChannelRecord{id: id} -> Repo.get_by(ChannelConfig, channel_id: id)
27 + end
28 + end
29 +
30 + @doc """
31 + Fetch the config for a registered channel, creating it (enabled) if it
32 + doesn't exist yet. Returns `{:ok, config}` or `{:error, :not_registered}`.
33 + """
34 + def ensure_config(channel_name, attrs \\ %{}) when is_binary(channel_name) do
35 + case Channels.get(channel_name) do
36 + nil ->
37 + {:error, :not_registered}
38 +
39 + %ChannelRecord{id: id} ->
40 + case Repo.get_by(ChannelConfig, channel_id: id) do
41 + nil ->
42 + %ChannelConfig{}
43 + |> ChannelConfig.changeset(Map.merge(%{channel_id: id}, attrs))
44 + |> Repo.insert()
45 +
46 + %ChannelConfig{} = cfg ->
47 + {:ok, cfg}
48 + end
49 + end
50 + end
51 +
52 + @doc """
53 + Update config fields for a registered channel, creating the row first if
54 + needed. Returns `{:ok, config}`, `{:error, changeset}`, or
55 + `{:error, :not_registered}`.
56 + """
57 + def update_config(channel_name, attrs) when is_binary(channel_name) do
58 + with {:ok, cfg} <- ensure_config(channel_name) do
59 + cfg
60 + |> ChannelConfig.changeset(attrs)
61 + |> Repo.update()
62 + end
63 + end
64 +
65 + def set_enabled(channel_name, value), do: update_config(channel_name, %{enabled: !!value})
66 + def set_joined(channel_name, value), do: update_config(channel_name, %{joined: !!value})
67 + def set_format(channel_name, format), do: update_config(channel_name, %{format: format})
68 +
69 + def set_restrict(channel_name, value),
70 + do: update_config(channel_name, %{restrict_to_allowlist: !!value})
71 +
72 + def set_rate_limit(channel_name, seconds),
73 + do: update_config(channel_name, %{rate_limit_seconds: seconds})
74 +
75 + @doc "Add a `who` display name to the allowlist (stored downcased, deduped)."
76 + def add_allow(channel_name, who) when is_binary(who) do
77 + who = normalize_who(who)
78 +
79 + with {:ok, cfg} <- ensure_config(channel_name) do
80 + list = Enum.uniq([who | cfg.allowlist])
81 + update_config(channel_name, %{allowlist: list})
82 + end
83 + end
84 +
85 + @doc "Remove a `who` display name from the allowlist."
86 + def remove_allow(channel_name, who) when is_binary(who) do
87 + who = normalize_who(who)
88 +
89 + with {:ok, cfg} <- ensure_config(channel_name) do
90 + list = Enum.reject(cfg.allowlist, &(&1 == who))
91 + update_config(channel_name, %{allowlist: list})
92 + end
93 + end
94 +
95 + @doc "Channel names where ScrobbleServ should currently be present."
96 + def list_joined_channels do
97 + Repo.all(
98 + from c in ChannelConfig,
99 + where: c.joined == true,
100 + join: ch in ChannelRecord,
101 + on: ch.id == c.channel_id,
102 + select: ch.name
103 + )
104 + end
105 +
106 + ## Tokens
107 +
108 + @doc """
109 + Mint a token for a registered channel, bound to `user` and announcing as
110 + `who`. Returns `{:ok, display_token, token}` where `display_token` is
111 + shown to the user once.
112 + """
113 + def mint_token(channel_name, %User{} = user, who, label \\ nil)
114 + when is_binary(channel_name) and is_binary(who) do
115 + who = String.trim(who)
116 +
117 + case Channels.get(channel_name) do
118 + _ when who == "" ->
119 + {:error, :blank_who}
120 +
121 + nil ->
122 + {:error, :not_registered}
123 +
124 + %ChannelRecord{id: id} ->
125 + {display, struct} = Token.build(id, user.id, who, label)
126 +
127 + case struct |> Token.changeset(%{}) |> Repo.insert() do
128 + {:ok, token} -> {:ok, display, token}
129 + {:error, cs} -> {:error, cs}
130 + end
131 + end
132 + end
133 +
134 + @doc "List (non-revoked) tokens for a channel. Never exposes plaintext."
135 + def list_tokens(channel_name) when is_binary(channel_name) do
136 + case Channels.get(channel_name) do
137 + nil ->
138 + []
139 +
140 + %ChannelRecord{id: id} ->
141 + Repo.all(
142 + from t in Token,
143 + where: t.channel_id == ^id and is_nil(t.revoked_at),
144 + order_by: [desc: t.inserted_at]
145 + )
146 + end
147 + end
148 +
149 + @doc "Revoke a token by id, scoped to the channel. Idempotent."
150 + def revoke_token(channel_name, token_id) when is_binary(channel_name) do
151 + case Channels.get(channel_name) do
152 + nil ->
153 + :ok
154 +
155 + %ChannelRecord{id: id} ->
156 + from(t in Token,
157 + where: t.id == ^token_id and t.channel_id == ^id and is_nil(t.revoked_at)
158 + )
159 + |> Repo.update_all(set: [revoked_at: DateTime.utc_now(:second)])
160 +
161 + :ok
162 + end
163 + end
164 +
165 + @doc """
166 + Verify a presented bearer token. Returns `{:ok, %{channel:, who:,
167 + user_id:}}` for a live (non-revoked) token — `who` is the bound display
168 + name, NOT anything the client supplied — else `:error`. Bumps
169 + `last_used_at` as a side effect on success.
170 + """
171 + def verify_token(presented) when is_binary(presented) do
172 + with {:ok, hash} <- Token.hash_presented(presented),
173 + %Token{} = token <-
174 + Repo.one(from t in Token, where: t.token_hash == ^hash and is_nil(t.revoked_at)),
175 + %ChannelRecord{} = channel <- Repo.get(ChannelRecord, token.channel_id) do
176 + Repo.update_all(
177 + from(t in Token, where: t.id == ^token.id),
178 + set: [last_used_at: DateTime.utc_now(:second)]
179 + )
180 +
181 + {:ok, %{channel: channel, who: token.who, user_id: token.user_id}}
182 + else
183 + _ -> :error
184 + end
185 + end
186 +
187 + def verify_token(_), do: :error
188 +
189 + ## History
190 +
191 + @doc "Persist an announced scrobble. `attrs` has who/artist/track/album."
192 + def record_scrobble(channel_id, attrs) when is_integer(channel_id) do
193 + %Scrobble{}
194 + |> Scrobble.changeset(Map.put(attrs, :channel_id, channel_id))
195 + |> Repo.insert()
196 + end
197 +
198 + @doc "Most recent `n` scrobbles for a channel, newest first."
199 + def recent(channel_name, n \\ 25) when is_binary(channel_name) do
200 + case Channels.get(channel_name) do
201 + nil ->
202 + []
203 +
204 + %ChannelRecord{id: id} ->
205 + Repo.all(
206 + from s in Scrobble,
207 + where: s.channel_id == ^id,
208 + order_by: [desc: s.inserted_at, desc: s.id],
209 + limit: ^n
210 + )
211 + end
212 + end
213 +
214 + defp normalize_who(who), do: who |> String.trim() |> String.downcase()
215 +end
added lib/oh_my_ex_oh_ircd/scrobbles/channel_config.ex
+63 −0

Click to load diff…

added lib/oh_my_ex_oh_ircd/scrobbles/scrobble.ex
+33 −0

Click to load diff…

added lib/oh_my_ex_oh_ircd/scrobbles/token.ex
+80 −0

Click to load diff…

added lib/oh_my_ex_oh_ircd_web/controllers/scrobble_controller.ex
+94 −0

Click to load diff…

modified lib/oh_my_ex_oh_ircd_web/live/user_live/channels.ex
+9 −0

Click to load diff…

added lib/oh_my_ex_oh_ircd_web/live/user_live/scrobble.ex
+316 −0

Click to load diff…

added lib/oh_my_ex_oh_ircd_web/plugs/scrobble_auth.ex
+31 −0

Click to load diff…

modified lib/oh_my_ex_oh_ircd_web/router.ex
+12 −4

Click to load diff…

added priv/repo/migrations/20260526000000_create_scrobble_channel_configs.exs
+22 −0

Click to load diff…

added priv/repo/migrations/20260527000000_create_scrobble_tokens.exs
+24 −0

Click to load diff…

added priv/repo/migrations/20260528000000_create_scrobbles.exs
+17 −0

Click to load diff…

added test/oh_my_ex_oh_ircd/irc/scrobble_serv_test.exs
+138 −0

Click to load diff…

added test/oh_my_ex_oh_ircd/scrobbles_test.exs
+120 −0

Click to load diff…

added test/oh_my_ex_oh_ircd_web/controllers/scrobble_controller_test.exs
+96 −0

Click to load diff…

added test/oh_my_ex_oh_ircd_web/live/user_live/scrobble_test.exs
+57 −0

Click to load diff…

Parents: 3c243c4