diff --git a/lib/music_library/record_sets.ex b/lib/music_library/record_sets.ex
new file mode 100644
index 00000000..2ce987de
--- /dev/null
+++ b/lib/music_library/record_sets.ex
@@ -0,0 +1,144 @@
+defmodule MusicLibrary.RecordSets do
+ import Ecto.Query, warn: false
+
+ alias MusicLibrary.RecordSets.{RecordSet, RecordSetItem}
+ alias MusicLibrary.Repo
+
+ def list_record_sets(opts \\ []) do
+ offset = Keyword.get(opts, :offset, 0)
+ limit = Keyword.get(opts, :limit, 20)
+
+ from(rs in RecordSet,
+ order_by: [desc: rs.updated_at],
+ offset: ^offset,
+ limit: ^limit,
+ preload: [items: :record]
+ )
+ |> Repo.all()
+ end
+
+ def count_record_sets do
+ Repo.aggregate(RecordSet, :count)
+ end
+
+ def get_record_set!(id) do
+ RecordSet
+ |> Repo.get!(id)
+ |> Repo.preload(items: :record)
+ end
+
+ def create_record_set(attrs) do
+ %RecordSet{}
+ |> RecordSet.changeset(attrs)
+ |> Repo.insert()
+ |> case do
+ {:ok, record_set} -> {:ok, Repo.preload(record_set, items: :record)}
+ error -> error
+ end
+ end
+
+ def update_record_set(%RecordSet{} = record_set, attrs) do
+ record_set
+ |> RecordSet.changeset(attrs)
+ |> Repo.update()
+ |> case do
+ {:ok, record_set} -> {:ok, Repo.preload(record_set, [items: :record], force: true)}
+ error -> error
+ end
+ end
+
+ def delete_record_set(%RecordSet{} = record_set) do
+ Repo.delete(record_set)
+ end
+
+ def change_record_set(%RecordSet{} = record_set, attrs \\ %{}) do
+ RecordSet.changeset(record_set, attrs)
+ end
+
+ def add_record_to_set(%RecordSet{} = record_set, record_id) do
+ next_position =
+ from(i in RecordSetItem,
+ where: i.record_set_id == ^record_set.id,
+ select: coalesce(max(i.position), -1)
+ )
+ |> Repo.one!()
+ |> Kernel.+(1)
+
+ %RecordSetItem{}
+ |> RecordSetItem.changeset(%{position: next_position})
+ |> Ecto.Changeset.put_change(:record_set_id, record_set.id)
+ |> Ecto.Changeset.put_change(:record_id, record_id)
+ |> Ecto.Changeset.unique_constraint([:record_set_id, :record_id])
+ |> Repo.insert()
+ |> case do
+ {:ok, _item} -> {:ok, get_record_set!(record_set.id)}
+ error -> error
+ end
+ end
+
+ def remove_record_from_set(%RecordSet{} = record_set, record_id) do
+ item =
+ from(i in RecordSetItem,
+ where: i.record_set_id == ^record_set.id and i.record_id == ^record_id
+ )
+ |> Repo.one!()
+
+ Repo.delete(item)
+
+ recompact_positions(record_set.id)
+
+ {:ok, get_record_set!(record_set.id)}
+ end
+
+ def move_record_in_set(%RecordSet{} = record_set, record_id, direction)
+ when direction in [:up, :down] do
+ items =
+ from(i in RecordSetItem,
+ where: i.record_set_id == ^record_set.id,
+ order_by: [asc: i.position]
+ )
+ |> Repo.all()
+
+ index = Enum.find_index(items, fn i -> i.record_id == record_id end)
+
+ swap_index =
+ case direction do
+ :up -> index - 1
+ :down -> index + 1
+ end
+
+ if index && swap_index >= 0 && swap_index < length(items) do
+ item_a = Enum.at(items, index)
+ item_b = Enum.at(items, swap_index)
+
+ Repo.transaction(fn ->
+ item_a
+ |> Ecto.Changeset.change(position: item_b.position)
+ |> Repo.update!()
+
+ item_b
+ |> Ecto.Changeset.change(position: item_a.position)
+ |> Repo.update!()
+ end)
+ end
+
+ {:ok, get_record_set!(record_set.id)}
+ end
+
+ defp recompact_positions(record_set_id) do
+ items =
+ from(i in RecordSetItem,
+ where: i.record_set_id == ^record_set_id,
+ order_by: [asc: i.position]
+ )
+ |> Repo.all()
+
+ Enum.with_index(items, fn item, index ->
+ if item.position != index do
+ item
+ |> Ecto.Changeset.change(position: index)
+ |> Repo.update!()
+ end
+ end)
+ end
+end
diff --git a/lib/music_library/record_sets/record_set.ex b/lib/music_library/record_sets/record_set.ex
new file mode 100644
index 00000000..9f9d41f7
--- /dev/null
+++ b/lib/music_library/record_sets/record_set.ex
@@ -0,0 +1,24 @@
+defmodule MusicLibrary.RecordSets.RecordSet do
+ use Ecto.Schema
+
+ import Ecto.Changeset
+
+ alias MusicLibrary.RecordSets.RecordSetItem
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+ schema "record_sets" do
+ field :name, :string
+ field :description, :string
+
+ has_many :items, RecordSetItem, preload_order: [asc: :position]
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(record_set, attrs) do
+ record_set
+ |> cast(attrs, [:name, :description])
+ |> validate_required([:name])
+ end
+end
diff --git a/lib/music_library/record_sets/record_set_item.ex b/lib/music_library/record_sets/record_set_item.ex
new file mode 100644
index 00000000..9dbb3ebf
--- /dev/null
+++ b/lib/music_library/record_sets/record_set_item.ex
@@ -0,0 +1,25 @@
+defmodule MusicLibrary.RecordSets.RecordSetItem do
+ use Ecto.Schema
+
+ import Ecto.Changeset
+
+ alias MusicLibrary.Records.Record
+ alias MusicLibrary.RecordSets.RecordSet
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+ schema "record_set_items" do
+ field :position, :integer
+
+ belongs_to :record_set, RecordSet
+ belongs_to :record, Record
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(record_set_item, attrs) do
+ record_set_item
+ |> cast(attrs, [:position])
+ |> validate_required([:position])
+ end
+end
diff --git a/lib/music_library_web/components/layouts/app.html.heex b/lib/music_library_web/components/layouts/app.html.heex
index 8ddebdb1..3837e669 100644
--- a/lib/music_library_web/components/layouts/app.html.heex
+++ b/lib/music_library_web/components/layouts/app.html.heex
@@ -35,6 +35,19 @@
/>
{gettext("Wishlist")}
+ <.nav_link
+ route={~p"/record-sets"}
+ section={:record_sets}
+ current_section={@current_section}
+ >
+ <.icon
+ name="hero-rectangle-stack"
+ class="max-sm:hidden h-4 w-4 mr-2"
+ aria-hidden="true"
+ data-slot="icon"
+ />
+ {gettext("Sets")}
+
diff --git a/lib/music_library_web/live/record_set_live/form.ex b/lib/music_library_web/live/record_set_live/form.ex
new file mode 100644
index 00000000..2dc38940
--- /dev/null
+++ b/lib/music_library_web/live/record_set_live/form.ex
@@ -0,0 +1,95 @@
+defmodule MusicLibraryWeb.RecordSetLive.Form do
+ use MusicLibraryWeb, :live_component
+
+ alias MusicLibrary.RecordSets
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+ <.simple_form
+ for={@form}
+ id="record-set-form"
+ phx-target={@myself}
+ phx-change="validate"
+ phx-submit="save"
+ >
+ <.input
+ field={@form[:name]}
+ type="text"
+ label={gettext("Name")}
+ placeholder={gettext("e.g. Favorites, Road Trip, Sunday Morning")}
+ />
+
+ <.textarea
+ field={@form[:description]}
+ label={gettext("Description (optional)")}
+ placeholder={gettext("What is this set about?")}
+ />
+
+ <:actions>
+ <.button phx-disable-with={gettext("Saving...")}>
+ {gettext("Save Set")}
+
+
+
+
+ """
+ end
+
+ @impl true
+ def update(%{record_set: record_set} = assigns, socket) do
+ {:ok,
+ socket
+ |> assign(assigns)
+ |> assign_new(:form, fn ->
+ to_form(RecordSets.change_record_set(record_set))
+ end)}
+ end
+
+ @impl true
+ def handle_event("validate", %{"record_set" => record_set_params}, socket) do
+ changeset =
+ RecordSets.change_record_set(socket.assigns.record_set, record_set_params)
+
+ {:noreply, assign(socket, form: to_form(changeset, action: :validate))}
+ end
+
+ def handle_event("save", %{"record_set" => record_set_params}, socket) do
+ save_record_set(socket, socket.assigns.action, record_set_params)
+ end
+
+ defp save_record_set(socket, :edit, record_set_params) do
+ case RecordSets.update_record_set(socket.assigns.record_set, record_set_params) do
+ {:ok, record_set} ->
+ notify_parent({:updated, record_set})
+ put_toast!(:info, gettext("Record set updated successfully"))
+
+ {:noreply, push_patch(socket, to: socket.assigns.patch)}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+ end
+
+ defp save_record_set(socket, :new, record_set_params) do
+ case RecordSets.create_record_set(record_set_params) do
+ {:ok, record_set} ->
+ notify_parent({:created, record_set})
+ put_toast!(:info, gettext("Record set created successfully"))
+
+ {:noreply, push_patch(socket, to: socket.assigns.patch)}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+ end
+
+ defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
+end
diff --git a/lib/music_library_web/live/record_set_live/index.ex b/lib/music_library_web/live/record_set_live/index.ex
new file mode 100644
index 00000000..5733eaa4
--- /dev/null
+++ b/lib/music_library_web/live/record_set_live/index.ex
@@ -0,0 +1,184 @@
+defmodule MusicLibraryWeb.RecordSetLive.Index do
+ use MusicLibraryWeb, :live_view
+
+ import MusicLibraryWeb.Components.Pagination
+
+ alias MusicLibrary.RecordSets
+ alias MusicLibrary.RecordSets.RecordSet
+
+ @default_list_params %{
+ page: 1,
+ page_size: 20,
+ query: "",
+ order: :updated_at
+ }
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok, assign(socket, :current_section, :record_sets)}
+ end
+
+ @impl true
+ def handle_params(params, _url, socket) do
+ {:noreply, apply_action(socket, socket.assigns.live_action, params)}
+ end
+
+ defp apply_action(socket, :edit, %{"id" => id} = params) do
+ socket
+ |> apply_fallback_index(params)
+ |> assign(:page_title, gettext("Edit Set"))
+ |> assign(:record_set, RecordSets.get_record_set!(id))
+ end
+
+ defp apply_action(socket, :new, params) do
+ socket
+ |> apply_fallback_index(params)
+ |> assign(:page_title, gettext("New Set"))
+ |> assign(:record_set, %RecordSet{})
+ end
+
+ defp apply_action(socket, :add_record, %{"id" => id} = params) do
+ socket
+ |> apply_fallback_index(params)
+ |> assign(:page_title, gettext("Add Record"))
+ |> assign(:record_set, RecordSets.get_record_set!(id))
+ end
+
+ defp apply_action(socket, :index, params) do
+ total_sets = RecordSets.count_record_sets()
+
+ list_params =
+ @default_list_params
+ |> merge_pagination(params, total_sets)
+
+ load_and_assign_sets(socket, list_params)
+ end
+
+ defp apply_fallback_index(socket, params) do
+ if get_in(socket.assigns, [:streams, :record_sets]) == nil do
+ socket
+ |> apply_action(:index, params)
+ else
+ socket
+ end
+ end
+
+ defp merge_pagination(params, url_params, total_records) do
+ page = parse_page(url_params["page"])
+ page_size = parse_page_size(url_params["page_size"])
+
+ params
+ |> Map.put(:page, page)
+ |> Map.put(:page_size, page_size)
+ |> Map.put(:total_records, total_records)
+ end
+
+ defp parse_page(nil), do: 1
+
+ defp parse_page(page) when is_binary(page) do
+ case Integer.parse(page) do
+ {num, ""} when num > 0 -> num
+ _ -> 1
+ end
+ end
+
+ defp parse_page(_), do: 1
+
+ defp parse_page_size(nil), do: 20
+
+ defp parse_page_size(page_size) when is_binary(page_size) do
+ case Integer.parse(page_size) do
+ {num, ""} when num in [20, 50, 100] -> num
+ _ -> 20
+ end
+ end
+
+ defp parse_page_size(_), do: 20
+
+ defp load_and_assign_sets(socket, list_params) do
+ offset = page_to_offset(list_params.page, list_params.page_size)
+
+ sets =
+ RecordSets.list_record_sets(
+ offset: offset,
+ limit: list_params.page_size
+ )
+
+ list_params_with_total =
+ Map.put(list_params, :total_entries, list_params.total_records)
+
+ socket
+ |> assign(:list_params, list_params_with_total)
+ |> assign(:page_title, gettext("Record Sets"))
+ |> assign(:record_set, nil)
+ |> stream(:record_sets, sets, reset: true)
+ end
+
+ def back_path(list_params) do
+ qs =
+ list_params
+ |> Map.take([:page, :page_size])
+
+ ~p"/record-sets?#{qs}"
+ end
+
+ @impl true
+ def handle_info(
+ {MusicLibraryWeb.RecordSetLive.Form, {:created, record_set}},
+ socket
+ ) do
+ {:noreply,
+ socket
+ |> stream_insert(:record_sets, record_set, at: 0)
+ |> load_and_assign_sets(socket.assigns.list_params)}
+ end
+
+ def handle_info(
+ {MusicLibraryWeb.RecordSetLive.Form, {:updated, record_set}},
+ socket
+ ) do
+ {:noreply, stream_insert(socket, :record_sets, record_set)}
+ end
+
+ def handle_info(
+ {MusicLibraryWeb.RecordSetLive.RecordPicker, {:added, record_set}},
+ socket
+ ) do
+ {:noreply,
+ socket
+ |> stream_insert(:record_sets, record_set)
+ |> push_patch(to: back_path(socket.assigns.list_params))}
+ end
+
+ @impl true
+ def handle_event("delete_set", %{"id" => id}, socket) do
+ record_set = RecordSets.get_record_set!(id)
+ {:ok, _} = RecordSets.delete_record_set(record_set)
+
+ {:noreply,
+ socket
+ |> stream_delete(:record_sets, record_set)
+ |> load_and_assign_sets(socket.assigns.list_params)}
+ end
+
+ def handle_event("remove_record", %{"set-id" => set_id, "record-id" => record_id}, socket) do
+ record_set = RecordSets.get_record_set!(set_id)
+ {:ok, updated_set} = RecordSets.remove_record_from_set(record_set, record_id)
+
+ {:noreply, stream_insert(socket, :record_sets, updated_set)}
+ end
+
+ def handle_event("move_up", %{"set-id" => set_id, "record-id" => record_id}, socket) do
+ record_set = RecordSets.get_record_set!(set_id)
+ {:ok, updated_set} = RecordSets.move_record_in_set(record_set, record_id, :up)
+
+ {:noreply, stream_insert(socket, :record_sets, updated_set)}
+ end
+
+ def handle_event("move_down", %{"set-id" => set_id, "record-id" => record_id}, socket) do
+ record_set = RecordSets.get_record_set!(set_id)
+ {:ok, updated_set} = RecordSets.move_record_in_set(record_set, record_id, :down)
+
+ {:noreply, stream_insert(socket, :record_sets, updated_set)}
+ end
+end
diff --git a/lib/music_library_web/live/record_set_live/index.html.heex b/lib/music_library_web/live/record_set_live/index.html.heex
new file mode 100644
index 00000000..bebeb12a
--- /dev/null
+++ b/lib/music_library_web/live/record_set_live/index.html.heex
@@ -0,0 +1,202 @@
+
+
+
+
+
+
+ <.pagination id={:bottom_pagination} pagination_params={@list_params} />
+
+
+ <.structured_modal
+ :if={@live_action in [:new, :edit]}
+ id="record-set-modal"
+ on_close={JS.patch(back_path(@list_params))}
+ >
+ <.live_component
+ module={MusicLibraryWeb.RecordSetLive.Form}
+ id={@record_set.id || :new}
+ title={@page_title}
+ action={@live_action}
+ record_set={@record_set}
+ patch={back_path(@list_params)}
+ />
+
+
+ <.structured_modal
+ :if={@live_action == :add_record}
+ id="record-picker-modal"
+ on_close={JS.patch(back_path(@list_params))}
+ >
+ <.live_component
+ module={MusicLibraryWeb.RecordSetLive.RecordPicker}
+ id={"record-picker-#{@record_set.id}"}
+ title={@page_title}
+ record_set={@record_set}
+ patch={back_path(@list_params)}
+ />
+
+
diff --git a/lib/music_library_web/live/record_set_live/record_picker.ex b/lib/music_library_web/live/record_set_live/record_picker.ex
new file mode 100644
index 00000000..02c95891
--- /dev/null
+++ b/lib/music_library_web/live/record_set_live/record_picker.ex
@@ -0,0 +1,143 @@
+defmodule MusicLibraryWeb.RecordSetLive.RecordPicker do
+ use MusicLibraryWeb, :live_component
+
+ alias MusicLibrary.Collection
+ alias MusicLibrary.Records.Record
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ """
+ end
+
+ @impl true
+ def update(assigns, socket) do
+ existing_record_ids =
+ MapSet.new(assigns.record_set.items, fn item -> item.record.id end)
+
+ {:ok,
+ socket
+ |> assign(assigns)
+ |> assign(:query, "")
+ |> assign(:results, [])
+ |> assign(:existing_record_ids, existing_record_ids)}
+ end
+
+ @impl true
+ def handle_event("search", %{"query" => query}, socket) do
+ results =
+ if String.trim(query) == "" do
+ []
+ else
+ query
+ |> Collection.search_records(limit: 20)
+ |> Enum.reject(fn record ->
+ MapSet.member?(socket.assigns.existing_record_ids, record.id)
+ end)
+ end
+
+ {:noreply,
+ socket
+ |> assign(:query, query)
+ |> assign(:results, results)}
+ end
+
+ def handle_event("add_record", %{"record-id" => record_id}, socket) do
+ record_set = socket.assigns.record_set
+
+ case MusicLibrary.RecordSets.add_record_to_set(record_set, record_id) do
+ {:ok, updated_set} ->
+ existing_record_ids = MapSet.put(socket.assigns.existing_record_ids, record_id)
+
+ results =
+ Enum.reject(socket.assigns.results, fn record -> record.id == record_id end)
+
+ notify_parent({:added, updated_set})
+ put_toast!(:info, gettext("Record added to set"))
+
+ {:noreply,
+ socket
+ |> assign(:record_set, updated_set)
+ |> assign(:existing_record_ids, existing_record_ids)
+ |> assign(:results, results)}
+
+ {:error, _changeset} ->
+ put_toast!(:error, gettext("Could not add record to set"))
+ {:noreply, socket}
+ end
+ end
+
+ defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
+end
diff --git a/lib/music_library_web/router.ex b/lib/music_library_web/router.ex
index b5f6f56a..2915a7d8 100644
--- a/lib/music_library_web/router.ex
+++ b/lib/music_library_web/router.ex
@@ -65,6 +65,11 @@ defmodule MusicLibraryWeb.Router do
live "/artists/:musicbrainz_id/import", ArtistLive.Show, :import
live "/artists/:musicbrainz_id/edit", ArtistLive.Show, :edit
+ live "/record-sets", RecordSetLive.Index, :index
+ live "/record-sets/new", RecordSetLive.Index, :new
+ live "/record-sets/:id/edit", RecordSetLive.Index, :edit
+ live "/record-sets/:id/add-record", RecordSetLive.Index, :add_record
+
live "/scrobble-rules", ScrobbleRulesLive.Index, :index
live "/scrobble-rules/new", ScrobbleRulesLive.Index, :new
live "/scrobble-rules/:id/edit", ScrobbleRulesLive.Index, :edit
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 2e7a7908..37800756 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -14,6 +14,7 @@ msgstr ""
#: lib/music_library_web/components/record_components.ex
#: lib/music_library_web/live/collection_live/show.html.heex
#: lib/music_library_web/live/online_store_template_live/index.html.heex
+#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.html.heex
@@ -38,6 +39,7 @@ msgstr ""
#: lib/music_library_web/components/record_components.ex
#: lib/music_library_web/live/collection_live/show.html.heex
#: lib/music_library_web/live/online_store_template_live/index.html.heex
+#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.html.heex
@@ -52,6 +54,7 @@ msgstr ""
#: lib/music_library_web/live/collection_live/show.ex
#: lib/music_library_web/live/collection_live/show.html.heex
#: lib/music_library_web/live/online_store_template_live/index.html.heex
+#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.ex
@@ -175,6 +178,7 @@ msgstr ""
#: lib/music_library_web/components/record_form.ex
#: lib/music_library_web/live/artist_live/form.ex
#: lib/music_library_web/live/online_store_template_live/form.ex
+#: lib/music_library_web/live/record_set_live/form.ex
#: lib/music_library_web/live/scrobble_rules_live/form.ex
#: lib/music_library_web/live/scrobbled_tracks_live/form.ex
#, elixir-autogen, elixir-format
@@ -182,6 +186,7 @@ msgid "Saving..."
msgstr ""
#: lib/music_library_web/components/core_components.ex
+#: lib/music_library_web/live/record_set_live/record_picker.ex
#, elixir-autogen, elixir-format
msgid "Search"
msgstr ""
@@ -390,6 +395,7 @@ msgid "On Tour"
msgstr ""
#: lib/music_library_web/components/record_components.ex
+#: lib/music_library_web/live/record_set_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "1 record"
msgid_plural "%{count} records"
@@ -899,6 +905,7 @@ msgstr ""
#: lib/music_library_web/live/artist_live/show.html.heex
#: lib/music_library_web/live/collection_live/show.html.heex
#: lib/music_library_web/live/online_store_template_live/index.html.heex
+#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.html.heex
@@ -977,6 +984,7 @@ msgid "Artist Name"
msgstr ""
#: lib/music_library_web/live/online_store_template_live/form.ex
+#: lib/music_library_web/live/record_set_live/form.ex
#: lib/music_library_web/live/scrobble_rules_live/form.ex
#, elixir-autogen, elixir-format
msgid "Description (optional)"
@@ -1687,3 +1695,105 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Regenerate embeddings"
msgstr ""
+
+#: lib/music_library_web/live/record_set_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Add Record"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/record_picker.ex
+#, elixir-autogen, elixir-format
+msgid "Could not add record to set"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Edit Set"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Move left"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Move right"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Name"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/index.ex
+#: lib/music_library_web/live/record_set_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "New Set"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "No record sets yet"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/record_picker.ex
+#, elixir-autogen, elixir-format
+msgid "No records found"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/index.ex
+#: lib/music_library_web/live/record_set_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Record Sets"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/record_picker.ex
+#, elixir-autogen, elixir-format
+msgid "Record added to set"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Record set created successfully"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Record set updated successfully"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Remove"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Remove this record from the set?"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Save Set"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/record_picker.ex
+#, elixir-autogen, elixir-format
+msgid "Search your collection to add a record to this set."
+msgstr ""
+
+#: lib/music_library_web/components/layouts/app.html.heex
+#, elixir-autogen, elixir-format
+msgid "Sets"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "What is this set about?"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "e.g. Favorites, Road Trip, Sunday Morning"
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 3428a541..fc82743e 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -14,6 +14,7 @@ msgstr ""
#: lib/music_library_web/components/record_components.ex
#: lib/music_library_web/live/collection_live/show.html.heex
#: lib/music_library_web/live/online_store_template_live/index.html.heex
+#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.html.heex
@@ -38,6 +39,7 @@ msgstr ""
#: lib/music_library_web/components/record_components.ex
#: lib/music_library_web/live/collection_live/show.html.heex
#: lib/music_library_web/live/online_store_template_live/index.html.heex
+#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.html.heex
@@ -52,6 +54,7 @@ msgstr ""
#: lib/music_library_web/live/collection_live/show.ex
#: lib/music_library_web/live/collection_live/show.html.heex
#: lib/music_library_web/live/online_store_template_live/index.html.heex
+#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.ex
@@ -175,6 +178,7 @@ msgstr ""
#: lib/music_library_web/components/record_form.ex
#: lib/music_library_web/live/artist_live/form.ex
#: lib/music_library_web/live/online_store_template_live/form.ex
+#: lib/music_library_web/live/record_set_live/form.ex
#: lib/music_library_web/live/scrobble_rules_live/form.ex
#: lib/music_library_web/live/scrobbled_tracks_live/form.ex
#, elixir-autogen, elixir-format
@@ -182,6 +186,7 @@ msgid "Saving..."
msgstr ""
#: lib/music_library_web/components/core_components.ex
+#: lib/music_library_web/live/record_set_live/record_picker.ex
#, elixir-autogen, elixir-format
msgid "Search"
msgstr ""
@@ -390,6 +395,7 @@ msgid "On Tour"
msgstr ""
#: lib/music_library_web/components/record_components.ex
+#: lib/music_library_web/live/record_set_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "1 record"
msgid_plural "%{count} records"
@@ -899,6 +905,7 @@ msgstr ""
#: lib/music_library_web/live/artist_live/show.html.heex
#: lib/music_library_web/live/collection_live/show.html.heex
#: lib/music_library_web/live/online_store_template_live/index.html.heex
+#: lib/music_library_web/live/record_set_live/index.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.html.heex
@@ -977,6 +984,7 @@ msgid "Artist Name"
msgstr ""
#: lib/music_library_web/live/online_store_template_live/form.ex
+#: lib/music_library_web/live/record_set_live/form.ex
#: lib/music_library_web/live/scrobble_rules_live/form.ex
#, elixir-autogen, elixir-format
msgid "Description (optional)"
@@ -1687,3 +1695,105 @@ msgstr ""
#, elixir-autogen, elixir-format, fuzzy
msgid "Regenerate embeddings"
msgstr ""
+
+#: lib/music_library_web/live/record_set_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Add Record"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/record_picker.ex
+#, elixir-autogen, elixir-format
+msgid "Could not add record to set"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/index.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Edit Set"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Move left"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Move right"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Name"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/index.ex
+#: lib/music_library_web/live/record_set_live/index.html.heex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "New Set"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "No record sets yet"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/record_picker.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "No records found"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/index.ex
+#: lib/music_library_web/live/record_set_live/index.html.heex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Record Sets"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/record_picker.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Record added to set"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/form.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Record set created successfully"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/form.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Record set updated successfully"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Remove"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Remove this record from the set?"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/form.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Save Set"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/record_picker.ex
+#, elixir-autogen, elixir-format
+msgid "Search your collection to add a record to this set."
+msgstr ""
+
+#: lib/music_library_web/components/layouts/app.html.heex
+#, elixir-autogen, elixir-format
+msgid "Sets"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "What is this set about?"
+msgstr ""
+
+#: lib/music_library_web/live/record_set_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "e.g. Favorites, Road Trip, Sunday Morning"
+msgstr ""
diff --git a/priv/repo/migrations/20260205113052_create_record_sets.exs b/priv/repo/migrations/20260205113052_create_record_sets.exs
new file mode 100644
index 00000000..4276ae40
--- /dev/null
+++ b/priv/repo/migrations/20260205113052_create_record_sets.exs
@@ -0,0 +1,13 @@
+defmodule MusicLibrary.Repo.Migrations.CreateRecordSets do
+ use Ecto.Migration
+
+ def change do
+ create table(:record_sets, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :name, :string, null: false
+ add :description, :text
+
+ timestamps(type: :utc_datetime)
+ end
+ end
+end
diff --git a/priv/repo/migrations/20260205113055_create_record_set_items.exs b/priv/repo/migrations/20260205113055_create_record_set_items.exs
new file mode 100644
index 00000000..8a1a5573
--- /dev/null
+++ b/priv/repo/migrations/20260205113055_create_record_set_items.exs
@@ -0,0 +1,21 @@
+defmodule MusicLibrary.Repo.Migrations.CreateRecordSetItems do
+ use Ecto.Migration
+
+ def change do
+ create table(:record_set_items, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :position, :integer, null: false
+
+ add :record_set_id, references(:record_sets, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :record_id, references(:records, type: :binary_id, on_delete: :delete_all), null: false
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:record_set_items, [:record_set_id])
+ create unique_index(:record_set_items, [:record_set_id, :record_id])
+ create index(:record_set_items, [:record_set_id, :position])
+ end
+end