Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,21 @@
},
"permissions": {
"allow": [
"Bash(mix compile:*)",
"Bash(mix test *)",
"Bash(fly status *)",
"Bash(fly volumes *)",
"Bash(MIX_ENV=test mix test *)",
"Bash(fly machine *)",
"Bash(fly machines *)",
"Bash(fly secrets *)",
"Bash(fly ssh *)",
"Bash(fly machine *)",
"WebFetch(domain:developers.soundcloud.com)",
"Bash(fly status *)",
"Bash(fly volumes *)",
"Bash(gh run *)",
"Bash(git stash *)",
"Bash(gh run *)"
"Bash(mix compile:*)",
"Bash(mix run -e ' *)",
"Bash(mix sass *)",
"Bash(mix test *)",
"WebFetch(domain:developers.soundcloud.com)",
"WebFetch(domain:raw.githubusercontent.com)"
]
}
}
12 changes: 12 additions & 0 deletions assets/css/layout.scss
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,18 @@ body {
gap: 10px 0;
}

.provider-indicator {
margin-right: auto;
display: flex;
align-items: center;

.provider-icon {
width: 18px;
height: 18px;
margin-right: 0;
}
}

.profile-image {
width: 45px;
height: auto;
Expand Down
98 changes: 83 additions & 15 deletions docs/soundcloud-plan.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,40 @@
# Plan: Mixed-provider queue (Spotify + SoundCloud)

Status: proposal. Not started.
Status: in progress (updated 2026-06-25).

## Progress

Legend: [deployed] in prod, [merged] on master, [wip] open PR / branch, [todo] not started.

- [deployed] Data model (`provider` + `external_id`) + provider abstraction (#112).
- [deployed] SoundCloud OAuth 2.1 with PKCE (#113). Token exchange verified end to end.
- [deployed] Read path: `Provider.SoundCloud` search + get_track, provider registry,
token refresh, `SearchTrack` mapping.
- [deployed] Provider toggle in the search UI, behind `FF_PROVIDER_SWITCHER` and
shown to trusted users.
- [wip] Playlist editing + Sonos favourite trigger + metadata matching (PR #115).
Verified on prod during the spike (see gates below).
- [wip] Run-slicing: `Queue.current_run/0`, run-aware `sync_playlist`,
provider-aware `find_playlist` (`soundcloud-runs` branch). Pending staging verify.

### Section 0 gates - RESOLVED

- Paid API access + playlist editing: yes. `PUT /playlists/{id}` works (verified,
track added to a real playlist).
- Sonos metadata id shape: confirmed `track->soundcloud:tracks:{id}` (Sonos wraps
the SoundCloud urn in its `universalMusicObjectId` format). `parse_object_id`
handles it; the feedback loop resolves SoundCloud tracks.

### SoundCloud API quirks discovered (already handled in code)

- authorization_code token exchange: `client_secret` must be in the request
BODY; SoundCloud ignores the HTTP Basic header the oauth2 lib sends.
- Token refresh: same - `client_id` + `client_secret` go in the body.
- PUT requests need `Content-Type: application/json` or SoundCloud returns 422.
- Playlist track items must be `{"urn": "soundcloud:tracks:<id>"}`, not `{"id": ...}`.
- API auth header: both `OAuth <token>` and `Bearer <token>` work (we send Bearer).
- `replace_playlist` currently targets a hardcoded `SOUNDCLOUD_PLAYLIST_ID` env -
temporary until the setup work below.

Goal: let users queue both Spotify and SoundCloud tracks into the same live
session, played out of the same Sonos speakers. The DB queue stays the single
Expand All @@ -17,6 +51,10 @@ Sonos webhooks.

## 0. Open question / TODO (gating, not yet decided)

> RESOLVED (2026-06-25): both gates passed - see the Progress section above. API
> playlist editing works and the Sonos metadata id is `track->soundcloud:tracks:{id}`.
> Original notes kept below for context.

**SoundCloud API access requires a paid account.** Whether this feature is worth
building at all depends on:

Expand Down Expand Up @@ -276,21 +314,51 @@ and Sonos (`how-it-works.md` section 6). Reuse `PR.Apis.TokenHelper` and the

## 11. Suggested order of work

1. (Gate) Research SoundCloud paid API access + playlist edit support. Go/no-go.
2. (Spike) Save a SoundCloud playlist as a Sonos favourite, play it, capture the
metadata webhook. Confirm a matchable id. Go/no-go.
3. Data model: `provider` + `external_id` migration, constraint, novelty views,
structs.
4. Provider behaviour + extract `Provider.Spotify` from current code. No behaviour
change yet (Spotify-only, still works end to end).
5. Run slicing: `Queue.current_run/0`, run-aware `sync_playlist`, provider-aware
`find_playlist`. Still Spotify-only - verify the boundary loop works with an
all-Spotify queue split into artificial runs.
6. `Provider.SoundCloud`: auth, search, get track, replace playlist, object-id.
7. Metadata: generalise `SonosItem` / `cast_metadata`.
8. UI: provider toggle in search; setup screen for SoundCloud auth + playlist.
9. Integration testing focused on boundary transitions and skips across providers.
1. [done] (Gate) SoundCloud paid API access + playlist edit support. Verified.
2. [done] (Spike) Save a SoundCloud playlist as a Sonos favourite, play it,
capture the metadata webhook. Confirmed `track->soundcloud:tracks:{id}`.
3. [done] Data model: `provider` + `external_id` migration, constraint, novelty
views, structs.
4. [done] Provider behaviour + extract `Provider.Spotify`.
5. [wip] Run slicing: `Queue.current_run/0`, run-aware `sync_playlist`,
provider-aware `find_playlist` (`soundcloud-runs` branch). Tested with unit
tests; pending staging verification of the boundary loop.
6. [done] `Provider.SoundCloud`: auth, search, get track, replace playlist,
object-id. (replace_playlist still uses the hardcoded playlist env - see 10.)
7. [done] Metadata: `SonosItem` / `cast_metadata` are provider-generic via
`Provider.match_object_id`.
8. [partial] UI: provider toggle in search is done. Setup screen for SoundCloud
playlist selection is step 10.
9. [todo] Integration testing focused on boundary transitions and skips across
providers.
10. [todo] Setup parity (see section 12).

Steps 4 and 5 are valuable on their own: they make the system provider-agnostic
and exercise the run-boundary loop using only Spotify, so the riskiest mechanics
are proven before SoundCloud is added.

---

## 12. Setup parity: SoundCloud playlist selection

Goal: make SoundCloud setup mirror Spotify and keep both as simple as possible.
Today SoundCloud's target playlist is a hardcoded `SOUNDCLOUD_PLAYLIST_ID` env var
(spike shortcut); Spotify creates its playlist from the setup screen
(`SpotifyAPI.create_playlist`, stored in `spotify_playlists`).

Bring SoundCloud to the same shape, and improve on it:

- Replace the env var with a stored SoundCloud playlist id (mirror
`SpotifyData.Playlist` / `spotify_playlists` with a `soundcloud_playlists`
table, or a generic playlists store).
- On the setup screen, instead of (or in addition to) "create playlist", **fetch
the user's existing playlists from the API** (`GET /me/playlists`) and let the
operator **pick which one** PlayRequest should write to. Selecting it stores the
id. This avoids creating yet another playlist and lets them point at an existing
one (e.g. the `sonosnow` playlist already favourited in Sonos).
- Consider applying the same picker to Spotify so both setups are identical.
- `Provider.SoundCloud.replace_playlist` then reads the stored id instead of the
env var; drop `SOUNDCLOUD_PLAYLIST_ID`.

Keep the setup flow minimal: authorise -> pick (or create) playlist -> favourite
it in Sonos. Same three steps for both providers.
35 changes: 22 additions & 13 deletions lib/pr/music/music.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,22 @@ defmodule PR.Music do
end
end

@doc """
Load the current run (the head same-provider block of the unplayed queue) into
that provider's playlist. Returns `{:ok, provider}` so the trigger knows which
favourite to play, or `{:ok, nil}` when the queue is empty.
"""
def sync_playlist do
Logger.info("Syncing tracks to provider playlist")

Queue.list_track_uris()
|> Enum.map(fn {id} -> id end)
|> default_provider().replace_playlist()

{:ok}
case Queue.current_run() do
{nil, _} ->
Logger.info("Sync playlist: queue empty, nothing to sync")
{:ok, nil}

{provider, ids} ->
Logger.info("Syncing #{length(ids)} #{provider} track(s) to provider playlist")
Provider.for(provider).replace_playlist(ids)
{:ok, provider}
end
end

def clear_playlist do
Expand All @@ -71,14 +79,18 @@ defmodule PR.Music do
# This take a little while to run, so there can be race conditions if it gets called
# a few times, before it's had a chance to affect the play state
def trigger_playlist(force \\ :dont_force) do
with {:ok} <- sync_playlist(),
with {:ok, provider} <- sync_playlist(),
{:ok} <- check_unplayed(),
%Group{group_id: group_id} <- SonosHouseholds.get_active_group!(),
{:ok, %{items: sonos_favorites}, _} <- SonosAPI.get_favorites(),
{:ok, fav_id} <- find_playlist(sonos_favorites),
{:ok, fav_id} <- find_playlist(sonos_favorites, Provider.for(provider)),
{:ok} <- check_current_playstate(PlayState.get(:play_state), force),
%{} <- SonosAPI.set_favorite(fav_id, group_id) do
Logger.info("Trigger playlist: OK")
Logger.info(
"Trigger playlist OK: provider=#{provider}, " <>
"favourite=#{Provider.for(provider).favourite_name()} (#{fav_id})"
)

{:ok}
else
{:error, :nothing_in_local_queue} ->
Expand Down Expand Up @@ -210,9 +222,6 @@ defmodule PR.Music do
Phoenix.PubSub.broadcast(PR.PubSub, @topic, {__MODULE__, data, key})
end

@spec find_playlist([map()]) :: {:ok, String.t()} | {:error, atom()}
defp find_playlist(sonos_favorites), do: find_playlist(sonos_favorites, default_provider())

@spec find_playlist([map()], module()) :: {:ok, String.t()} | {:error, atom()}
defp find_playlist(sonos_favorites, provider_mod) do
case Enum.find(sonos_favorites, &(&1.name == provider_mod.favourite_name())) do
Expand Down
49 changes: 47 additions & 2 deletions lib/pr/play_state.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ defmodule PR.PlayState do
@topic inspect(__MODULE__)

def start_link(_) do
Agent.start_link(fn -> %{play_state: %{}, metadata: %{}, progress: nil, error_mode: nil} end,
Agent.start_link(
fn ->
%{play_state: %{}, metadata: %{}, progress: nil, error_mode: nil, current_provider: nil}
end,
name: __MODULE__
)
end
Expand Down Expand Up @@ -159,8 +162,27 @@ defmodule PR.PlayState do
# TODO might be worth checking metadata here
case Queue.has_unplayed() do
num when num > 0 ->
Logger.info("Player IDLE. But, queue has more tracks. Loading them in 1000ms")
{finishing_provider, _} = Queue.current_run()

Logger.info(
"Player IDLE with #{num} unplayed. Finishing #{finishing_provider} run, " <>
"bumping last track and re-triggering in 1000ms"
)

Process.sleep(1000)
# The run that just finished left its last track marked playing, not
# played. Mark it played before re-triggering, otherwise current_run
# returns the same run again and the provider never switches. (Same as
# what skip does.)
Queue.bump()

{next_provider, next_ids} = Queue.current_run()

Logger.info(
"Re-triggering: next run is #{next_provider} (#{length(next_ids)} tracks)" <>
if(next_provider != finishing_provider, do: " - PROVIDER SWITCH", else: "")
)

trigger_on_sonos_system()
state

Expand Down Expand Up @@ -241,6 +263,7 @@ defmodule PR.PlayState do

defp update_playing(%{current_item: %{name: name} = current} = state) do
Logger.metadata(playback_state: Map.get(get(:play_state), :state))
track_provider_change(current)

if get(:error_mode) do
Logger.warn("Update playing: Cancelled, cos error mode")
Expand Down Expand Up @@ -273,6 +296,28 @@ defmodule PR.PlayState do
end
end

# Detect when the actually-playing provider changes, log it loudly and push
# it to the header indicator. Only fires on a real change, so it's quiet
# while a single provider's run plays out.
defp track_provider_change(%{provider: provider, name: name, artist: artist})
when is_binary(provider) do
case get(:current_provider) do
^provider ->
:ok

previous ->
Logger.info(
"🔀 Now playing from #{provider}: #{name} - #{artist}" <>
if(previous, do: " (switched from #{previous})", else: "")
)

update_state(provider, :current_provider)
broadcast(provider, :provider)
end
end

defp track_provider_change(_), do: :ok

# Called on an interval by supervisor
def tick do
with %{
Expand Down
30 changes: 30 additions & 0 deletions lib/pr/queue/queue.ex
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,36 @@ defmodule PR.Queue do
|> Repo.all()
end

@doc """
The current "run": the contiguous block of same-provider tracks at the head of
the unplayed queue (in play order). Returns `{provider, [external_id]}`, or
`{nil, []}` when the queue is empty. Only this block is loaded into a provider
playlist at a time, so Sonos goes idle at each provider boundary.
"""
@spec current_run() :: {String.t() | nil, [String.t()]}
def current_run do
rows =
Track
|> query_unplayed()
|> order()
|> limit(100)
|> select([t], {t.provider, t.external_id})
|> Repo.all()

case rows do
[] ->
{nil, []}

[{provider, _} | _] ->
ids =
rows
|> Enum.take_while(fn {p, _} -> p == provider end)
|> Enum.map(fn {_, id} -> id end)

{provider, ids}
end
end

@spec list_track_uris(String.t()) :: [String.t()]
def list_track_uris(provider) do
Track
Expand Down
2 changes: 1 addition & 1 deletion lib/pr_web/controllers/service/service_setup_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ defmodule PRWeb.Service.ServiceSetupController do

def sync_playlist(conn, _) do
case Music.sync_playlist() do
{:ok} ->
{:ok, _} ->
conn
|> put_flash(:info, "Playlist synced")
|> redirect(to: ~p"/setup")
Expand Down
17 changes: 17 additions & 0 deletions lib/pr_web/live/user_header_live/user_header_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ defmodule PRWeb.UserHeaderLive do
show_super_like={@show_super_like}
/>
<div class="playback-controls">
<div
:if={@current_provider && (@show_provider_switcher or @current_user.is_trusted == true)}
class={"provider-indicator provider-indicator--#{@current_provider}"}
title={"Playing from #{@current_provider}"}
>
<img src={provider_img(@current_provider)} class="provider-icon" />
</div>
<%= if (@show_toggle_playback or @current_user.is_trusted == true) and @num_unplayed > 0 do %>
<.play_pause play_state={@play_state} show_skip={@show_skip && @num_unplayed > 1} />
<% end %>
Expand Down Expand Up @@ -136,6 +143,7 @@ defmodule PRWeb.UserHeaderLive do
points: likes,
super_likes: super_likes,
play_state: play_state,
current_provider: current_provider(),
num_unplayed: Queue.num_unplayed(),
participated: Queue.has_participated?(%User{id: user_id}),
max_vol: max_vol()
Expand All @@ -160,6 +168,11 @@ defmodule PRWeb.UserHeaderLive do
{:ok, socket}
end

defp current_provider, do: PlayState.get(:current_provider)

defp provider_img("soundcloud"), do: ~p"/images/soundcloud.svg"
defp provider_img(_), do: ~p"/images/spotify.svg"

def max_vol() do
case Application.get_env(:pr, :max_vol) do
nil -> default_max_vol()
Expand Down Expand Up @@ -203,6 +216,10 @@ defmodule PRWeb.UserHeaderLive do
{:noreply, assign(socket, play_state: play_state)}
end

def handle_info({PlayState, provider, :provider}, socket) when is_binary(provider) do
{:noreply, assign(socket, current_provider: provider)}
end

def handle_info(
{Music, num_unplayed, :queue_updated},
%{assigns: %{current_user: %User{id: user_id}}} = socket
Expand Down
Loading