Skip to content

Commit fa9756c

Browse files
committed
deep link custom playlist
1 parent e29f919 commit fa9756c

File tree

20 files changed

+590
-431
lines changed

20 files changed

+590
-431
lines changed
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
defmodule Pling.Playlists.MusicLibrary do
2+
@moduledoc """
3+
Manages music playlists and track selection from the database.
4+
Playlists are organized by decades, with tracks associated to each playlist.
5+
"""
6+
import Ecto.Query
7+
require Logger
8+
alias Pling.Repo
9+
alias Pling.Playlists.{Playlist, Track}
10+
11+
@doc """
12+
Loads all playlists from the database and returns them as a map with spotify_id keys.
13+
"""
14+
def load_playlists do
15+
Playlist
16+
|> preload(:tracks)
17+
|> Repo.all()
18+
|> Enum.map(fn playlist -> {playlist.spotify_id, playlist} end)
19+
|> Enum.into(%{})
20+
end
21+
22+
@doc """
23+
Gets all tracks from all playlists when playlist is "mix",
24+
otherwise gets tracks for the specified playlist.
25+
"""
26+
def get_tracks(_playlists, "mix") do
27+
Repo.all(Track)
28+
end
29+
30+
def get_tracks(_playlists, playlist_id) when is_binary(playlist_id) do
31+
Track
32+
|> where([t], t.playlist_spotify_id == ^playlist_id)
33+
|> Repo.all()
34+
end
35+
36+
@doc """
37+
Selects a random track from the given list of tracks.
38+
"""
39+
def random_track([]), do: nil
40+
def random_track(tracks), do: Enum.random(tracks)
41+
42+
@doc """
43+
Selects a random track from the specified playlist.
44+
Returns nil if no tracks are found.
45+
"""
46+
def select_track(playlists, nil) when is_map(playlists) and map_size(playlists) > 0 do
47+
# If no playlist specified, pick a random one
48+
{playlist_id, _} = Enum.random(playlists)
49+
select_track(playlists, playlist_id)
50+
end
51+
52+
def select_track(_playlists, nil), do: nil
53+
54+
def select_track(playlists, playlist_id) do
55+
playlists
56+
|> get_tracks(playlist_id)
57+
|> random_track()
58+
end
59+
60+
@doc """
61+
Gets a playlist by Spotify ID, fetching it from Spotify if it doesn't exist locally.
62+
Returns the playlist as soon as it has basic info and at least one track.
63+
"""
64+
def get_or_fetch_playlist(spotify_id, timeout \\ 5000) do
65+
case Repo.get(Playlist, spotify_id) do
66+
nil ->
67+
ref = make_ref()
68+
parent = self()
69+
70+
callback = fn
71+
%{"playlist_info" => info} ->
72+
playlist = %Playlist{
73+
spotify_id: spotify_id,
74+
name: info["name"],
75+
owner: info["owner"],
76+
image_url: get_first_image_url(info["images"]),
77+
official: false
78+
}
79+
80+
{:ok, saved_playlist} = Repo.insert(playlist)
81+
saved_playlist
82+
83+
%{"track" => track} ->
84+
track_entry = %Track{
85+
spotify_id: track["id"],
86+
title: track["name"],
87+
artists: Enum.map(track["artists"], & &1["name"]),
88+
uri: track["uri"],
89+
popularity: track["popularity"],
90+
album: track["album"]["name"],
91+
playlist_spotify_id: spotify_id
92+
}
93+
94+
{:ok, saved_track} = Repo.insert(track_entry, on_conflict: :nothing)
95+
96+
# Notify parent process of first track
97+
if not Process.get({:notified_first_track, ref}) do
98+
Process.put({:notified_first_track, ref}, true)
99+
send(parent, {:first_track_saved, ref, spotify_id})
100+
end
101+
102+
saved_track
103+
end
104+
105+
Task.start(fn ->
106+
case Pling.Services.Spotify.stream_playlist(spotify_id, callback) do
107+
:ok ->
108+
:ok
109+
110+
{:error, reason} ->
111+
Logger.error("Failed to fetch complete playlist: #{inspect(reason)}")
112+
# Notify parent in case of immediate error
113+
send(parent, {:playlist_error, ref, reason})
114+
end
115+
end)
116+
117+
receive do
118+
{:first_track_saved, ^ref, playlist_id} ->
119+
case Repo.get(Playlist, playlist_id) do
120+
nil -> {:error, :playlist_fetch_failed}
121+
playlist -> {:ok, :first_track_saved, playlist}
122+
end
123+
124+
{:playlist_error, ^ref, reason} ->
125+
{:error, reason}
126+
after
127+
timeout ->
128+
case Repo.get(Playlist, spotify_id) do
129+
nil -> {:error, :playlist_fetch_timeout}
130+
playlist -> {:ok, :timeout, playlist}
131+
end
132+
end
133+
134+
playlist ->
135+
{:ok, :exists, playlist}
136+
end
137+
end
138+
139+
@doc """
140+
Returns a callback that just outputs the streaming data.
141+
Useful for debugging or monitoring the stream.
142+
"""
143+
def debug_callback do
144+
fn
145+
%{"playlist_info" => info} ->
146+
IO.puts("Playlist Info:")
147+
IO.inspect(info, pretty: true)
148+
:ok
149+
150+
%{"track" => track} ->
151+
IO.puts("\nTrack:")
152+
IO.inspect(track, pretty: true)
153+
:ok
154+
end
155+
end
156+
157+
defp get_first_image_url([%{"url" => url} | _]), do: url
158+
defp get_first_image_url(_), do: nil
159+
end

lib/pling/playlists/playlist.ex

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
defmodule Pling.Playlists.Playlist do
2+
use Ecto.Schema
3+
import Ecto.Changeset
4+
5+
@derive {Jason.Encoder, only: [:spotify_id, :name, :image_url, :owner, :official]}
6+
@primary_key {:spotify_id, :string, autogenerate: false}
7+
schema "playlists" do
8+
field :name, :string
9+
field :image_url, :string
10+
field :owner, :string
11+
field :official, :boolean, default: false
12+
has_many :tracks, Pling.Playlists.Track, foreign_key: :playlist_spotify_id
13+
14+
timestamps()
15+
end
16+
17+
def changeset(playlist, attrs) do
18+
playlist
19+
|> cast(attrs, [:spotify_id, :name, :official, :image_url, :owner])
20+
|> validate_required([:spotify_id, :name, :owner])
21+
|> unique_constraint(:spotify_id, name: :playlists_pkey)
22+
end
23+
end

lib/pling/playlists/track.ex

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
defmodule Pling.Playlists.Track do
2+
use Ecto.Schema
3+
import Ecto.Changeset
4+
5+
@derive {Jason.Encoder, only: [:spotify_id, :title, :artists, :uri, :popularity, :album]}
6+
@primary_key {:spotify_id, :string, autogenerate: false}
7+
schema "tracks" do
8+
field :title, :string
9+
field :artists, {:array, :string}
10+
field :uri, :string
11+
field :popularity, :integer
12+
field :album, :string
13+
14+
belongs_to :playlist, Pling.Playlists.Playlist,
15+
foreign_key: :playlist_spotify_id,
16+
references: :spotify_id,
17+
type: :string
18+
19+
timestamps()
20+
end
21+
22+
def changeset(track, attrs) do
23+
track
24+
|> cast(attrs, [
25+
:spotify_id,
26+
:title,
27+
:artists,
28+
:uri,
29+
:playlist_spotify_id,
30+
:popularity,
31+
:album
32+
])
33+
|> validate_required([:spotify_id, :uri, :playlist_spotify_id, :title, :artists])
34+
|> foreign_key_constraint(:playlist_spotify_id)
35+
end
36+
end

lib/pling/rooms.ex

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ defmodule Pling.Rooms do
44
"""
55

66
alias Pling.Rooms.Room.Server
7+
require Logger
78

89
def get_state(room_code) do
910
GenServer.call(via_tuple(room_code), :get_state)
@@ -33,25 +34,53 @@ defmodule Pling.Rooms do
3334
GenServer.cast(via_tuple(room_code), :process_tick)
3435
end
3536

36-
def join_room(room_code, user_id, pid, game_mode \\ "vs") do
37-
if get_room_pid(room_code) == :error do
38-
DynamicSupervisor.start_child(
39-
Pling.Rooms.RoomSupervisor,
40-
{Server, {room_code, game_mode, user_id}}
41-
)
37+
def join_room(room_code, user_id, pid, game_mode, playlist \\ nil) do
38+
Logger.metadata(room_code: room_code, user_id: user_id)
39+
Logger.info("Joining room", event: :room_join, game_mode: game_mode)
40+
41+
case get_or_create_room(room_code, game_mode, user_id, playlist) do
42+
{:ok, room_pid} ->
43+
case GenServer.call(room_pid, {:monitor_liveview, pid}) do
44+
:ok ->
45+
Logger.info("Room joined successfully", event: :room_join_success)
46+
{:ok, get_state(room_code)}
47+
48+
error ->
49+
Logger.error("Failed to join room", event: :room_join_error, error: inspect(error))
50+
error
51+
end
52+
53+
error ->
54+
Logger.error("Failed to create room", event: :room_create_error, error: inspect(error))
55+
error
4256
end
57+
end
4358

44-
GenServer.call(via_tuple(room_code), {:monitor_liveview, pid})
45-
{users, leader?} = Pling.Rooms.Presence.initialize_presence(room_code, user_id)
46-
current_state = get_state(room_code)
47-
{:ok, Map.merge(current_state, %{users: users, leader?: leader?})}
59+
defp get_or_create_room(room_code, game_mode, leader_id, playlist) do
60+
case get_room_pid(room_code) do
61+
{:ok, pid} ->
62+
Logger.debug("Using existing room", event: :room_found)
63+
{:ok, pid}
64+
65+
:error ->
66+
Logger.info("Creating new room", event: :room_create)
67+
68+
DynamicSupervisor.start_child(
69+
Pling.Rooms.RoomSupervisor,
70+
{Server, {room_code, game_mode, leader_id, playlist}}
71+
)
72+
end
4873
end
4974

5075
defp via_tuple(room_code) do
5176
{:via, Registry, {Pling.Rooms.ServerRegistry, room_code}}
5277
end
5378

54-
defp get_room_pid(room_code) do
79+
@doc """
80+
Gets the PID of a room by its room code.
81+
Returns {:ok, pid} if the room exists, :error otherwise.
82+
"""
83+
def get_room_pid(room_code) do
5584
case Registry.lookup(Pling.Rooms.ServerRegistry, room_code) do
5685
[{pid, _}] -> {:ok, pid}
5786
[] -> :error

lib/pling/rooms/music_library.ex

Lines changed: 0 additions & 50 deletions
This file was deleted.

lib/pling/rooms/playlist.ex

Lines changed: 0 additions & 19 deletions
This file was deleted.

0 commit comments

Comments
 (0)