diff --git a/.iex.exs b/.iex.exs index 1ec54101..ac713985 100644 --- a/.iex.exs +++ b/.iex.exs @@ -38,6 +38,8 @@ alias Radiator.Directory.{Audio, Episode, Podcast, Network, Editor} alias Radiator.Media alias Radiator.Media.AudioFile +alias Radiator.AudioMeta.Chapter + alias Radiator.Feed.Builder alias Radiator.Auth diff --git a/guides/rest_api.md b/guides/rest_api.md index 2e6c43eb..065db480 100644 --- a/guides/rest_api.md +++ b/guides/rest_api.md @@ -47,6 +47,12 @@ - [Parameters for Create](#Parameters-for-Create) - [Create](#Create-6) - [Read](#Read-6) +- [Audio Chapters](#Audio-Chapters) + - [Parameters for Create & Update](#Parameters-for-Create--Update-6) + - [Create](#Create-7) + - [Read](#Read-7) + - [Update](#Update-6) + - [Delete](#Delete-6) ## API Usage @@ -354,3 +360,41 @@ POST /api/rest/v1/audio_file ``` GET /api/rest/v1/audio_file/:id ``` + +## Audio Chapters + +> ⚠️ A chapter is uniquely identified by its start time and the associated audio. There can only be exactly one chapter per audio with a given start time. + +### Parameters for Create & Update + +| Name | Type | Description | +| ------------------- | --------- | -------------------------------------------------------------- | +| `chapter[audio_id]` | `integer` | **Required.** Chapter is attached to Audio object of given ID. | +| `chapter[start]` | `integer` | **Required.** chapter start time in milliseconds | +| `chapter[title]` | `string` | chapter title | +| `chapter[link]` | `string` | chapter link | +| `chapter[file]` | `image` | chapter image | + +### Create + +``` +POST /api/rest/v1/audios/:audio_id/chapters +``` + +### Read + +``` +GET /api/rest/v1/audios/:audio_id/chapters/:start +``` + +### Update + +``` +PATCH /api/rest/v1/audios/:audio_id/chapters/:start +``` + +### Delete + +``` +DELETE /api/rest/v1/audios/:audio_id/chapters/:start +``` diff --git a/lib/radiator/audio_meta/audio_meta.ex b/lib/radiator/audio_meta/audio_meta.ex index f5be41da..8e672d8d 100644 --- a/lib/radiator/audio_meta/audio_meta.ex +++ b/lib/radiator/audio_meta/audio_meta.ex @@ -39,6 +39,11 @@ defmodule Radiator.AudioMeta do |> Repo.all() end + def delete_chapter(chapter = %Chapter{}) do + delete_chapter_image(chapter) + Repo.delete(chapter) + end + def delete_chapters(%Audio{} = audio) do audio = Repo.preload(audio, :chapters) diff --git a/lib/radiator/audio_meta/chapter.ex b/lib/radiator/audio_meta/chapter.ex index 3223b70d..9f16f6c9 100644 --- a/lib/radiator/audio_meta/chapter.ex +++ b/lib/radiator/audio_meta/chapter.ex @@ -8,13 +8,14 @@ defmodule Radiator.AudioMeta.Chapter do alias Radiator.Directory.Audio alias Radiator.Media + @primary_key false schema "chapters" do - field :start, :integer + field :start, :integer, primary_key: true field :title, :string field :link, :string field :image, Media.ChapterImage.Type - belongs_to :audio, Audio + belongs_to :audio, Audio, primary_key: true end @doc false @@ -25,9 +26,18 @@ defmodule Radiator.AudioMeta.Chapter do :title, :link ]) + |> validate_required([:start]) + |> maybe_delete_existing_attachments(attrs) |> cast_attachments(attrs, [:image], allow_paths: true, allow_urls: true) end + def maybe_delete_existing_attachments(changeset, %{image: _image}) do + Radiator.AudioMeta.delete_chapter_image(changeset.data) + changeset + end + + def maybe_delete_existing_attachments(changeset, _attrs), do: changeset + @doc """ Convenience accessor for image URL. """ diff --git a/lib/radiator/directory/editor.ex b/lib/radiator/directory/editor.ex index 4b12ab2c..312814a1 100644 --- a/lib/radiator/directory/editor.ex +++ b/lib/radiator/directory/editor.ex @@ -13,6 +13,8 @@ defmodule Radiator.Directory.Editor do alias Radiator.Support alias Radiator.Repo + alias Radiator.AudioMeta + alias Radiator.AudioMeta.Chapter alias Radiator.Directory alias Radiator.Directory.{Network, Podcast, Episode, Editor, Audio, Collaborator} @@ -442,6 +444,44 @@ defmodule Radiator.Directory.Editor do episode end + def get_chapter(actor = %Auth.User{}, audio = %Audio{}, start) do + case Repo.get_by(Chapter, audio_id: audio.id, start: start) do + nil -> + @not_found_match + + chapter = %Chapter{} -> + if has_permission(actor, audio, :readonly) do + {:ok, chapter} + else + @not_authorized_match + end + end + end + + def create_chapter(actor = %Auth.User{}, audio, attrs) do + if has_permission(actor, audio, :edit) do + AudioMeta.create_chapter(audio, attrs) + else + @not_authorized_match + end + end + + def update_chapter(actor = %Auth.User{}, chapter = %Chapter{}, attrs) do + if has_permission(actor, %Audio{id: chapter.audio_id}, :edit) do + AudioMeta.update_chapter(chapter, attrs) + else + @not_authorized_match + end + end + + def delete_chapter(actor = %Auth.User{}, chapter = %Chapter{}) do + if has_permission(actor, %Audio{id: chapter.audio_id}, :own) do + AudioMeta.delete_chapter(chapter) + else + @not_authorized_match + end + end + @spec get_audio(Auth.User.t(), pos_integer()) :: {:ok, Audio.t()} | {:error, :not_authorized | :not_found} def get_audio(actor = %Auth.User{}, id) do diff --git a/lib/radiator/directory/editor/permission.ex b/lib/radiator/directory/editor/permission.ex index b423ce9b..b5e24cc9 100644 --- a/lib/radiator/directory/editor/permission.ex +++ b/lib/radiator/directory/editor/permission.ex @@ -4,6 +4,7 @@ defmodule Radiator.Directory.Editor.Permission do use Radiator.Constants alias Radiator.Repo + alias Radiator.AudioMeta.Chapter alias Radiator.Auth alias Radiator.Perm.Permission alias Radiator.Directory.{Network, Podcast, Episode, Audio} diff --git a/lib/radiator/media/audio_file.ex b/lib/radiator/media/audio_file.ex index 637624d4..13c7c87d 100644 --- a/lib/radiator/media/audio_file.ex +++ b/lib/radiator/media/audio_file.ex @@ -31,6 +31,7 @@ defmodule Radiator.Media.AudioFile do # todo: determine byte length and mime type on file change, don't take them as attrs end + # fixme: auidofile.upload geht bei dom nicht mehr # not sure if this is the best way # via https://elixirforum.com/t/ecto-validating-belongs-to-association-is-not-nil/2665/13 defp cast_or_constraint_assoc(changeset, name) do diff --git a/lib/radiator/media/chapter_image.ex b/lib/radiator/media/chapter_image.ex index 05f3f0c3..f77827a6 100644 --- a/lib/radiator/media/chapter_image.ex +++ b/lib/radiator/media/chapter_image.ex @@ -9,6 +9,6 @@ defmodule Radiator.Media.ChapterImage do end def storage_dir(_version, {_file, chapter}) do - "chapter/#{chapter.id}" + "chapter/#{chapter.audio_id}" end end diff --git a/lib/radiator_web/controllers/api/chapters_controller.ex b/lib/radiator_web/controllers/api/chapters_controller.ex new file mode 100644 index 00000000..6bf957f0 --- /dev/null +++ b/lib/radiator_web/controllers/api/chapters_controller.ex @@ -0,0 +1,75 @@ +defmodule RadiatorWeb.Api.ChaptersController do + use RadiatorWeb, :rest_controller + + alias Radiator.Directory.Editor + + plug :assign_audio when action in [:show, :create, :update, :delete] + plug :assign_chapter when action in [:show, :update, :delete] + + def show(conn, _params) do + render(conn, "show.json") + end + + def create(conn, %{"chapter" => chapter_params}) do + with user <- current_user(conn), + {:ok, chapter} <- Editor.create_chapter(user, conn.assigns[:audio], chapter_params) do + conn + |> put_status(:created) + |> put_resp_header( + "location", + Routes.api_audio_chapters_path(conn, :show, conn.assigns[:audio].id, chapter.start) + ) + |> assign(:chapter, chapter) + |> render("show.json") + end + end + + def update(conn, %{"chapter" => chapter_params}) do + with user <- current_user(conn), + {:ok, chapter} <- Editor.update_chapter(user, conn.assigns[:chapter], chapter_params) do + conn + |> assign(:chapter, chapter) + |> render("show.json") + end + end + + def delete(conn, _) do + with user <- current_user(conn), + {:ok, _} <- Editor.delete_chapter(user, conn.assigns[:chapter]) do + send_delete_resp(conn) + else + @not_found_match -> send_delete_resp(conn) + error -> error + end + end + + defp assign_audio(conn, _) do + with {:ok, audio} <- + conn + |> current_user() + |> Editor.get_audio(conn.params["audio_id"]) do + conn + |> assign(:audio, audio) + else + response -> apply_action_fallback(conn, response) + end + end + + defp assign_chapter(conn, _) do + with {:ok, chapter} <- + conn + |> current_user() + |> Editor.get_chapter(conn.assigns[:audio], conn.params["start"]) do + conn + |> assign(:chapter, chapter) + else + response -> apply_action_fallback(conn, response) + end + end + + defp apply_action_fallback(conn, response) do + case @phoenix_fallback do + {:module, module} -> apply(module, :call, [conn, response]) |> halt() + end + end +end diff --git a/lib/radiator_web/graphql/admin/resolvers/editor.ex b/lib/radiator_web/graphql/admin/resolvers/editor.ex index 0d69ed2d..94a0b368 100644 --- a/lib/radiator_web/graphql/admin/resolvers/editor.ex +++ b/lib/radiator_web/graphql/admin/resolvers/editor.ex @@ -284,16 +284,6 @@ defmodule RadiatorWeb.GraphQL.Admin.Resolvers.Editor do {:ok, AudioMeta.list_chapters(audio)} end - def set_episode_chapters(_parent, %{id: id, chapters: chapters, type: type}, %{ - context: %{current_user: user} - }) do - with_audio user, id do - fn audio -> - AudioMeta.set_chapters(audio, chapters, String.to_existing_atom(type)) - end - end - end - def get_audio_files(audio = %Audio{}, _args, %{context: %{current_user: user}}) do {:ok, Editor.list_audio_files(user, audio)} end @@ -316,13 +306,6 @@ defmodule RadiatorWeb.GraphQL.Admin.Resolvers.Editor do end end - def get_chapters(%Audio{} = audio, _, _) do - chapter_query = Radiator.AudioMeta.Chapter.ordered_query() - audio = Radiator.Repo.preload(audio, chapters: chapter_query) - - {:ok, audio.chapters} - end - def get_duration(%Episode{audio: audio}, _, _) do {:ok, audio.duration} end diff --git a/lib/radiator_web/graphql/admin/types.ex b/lib/radiator_web/graphql/admin/types.ex index 169896f0..20fb0dab 100644 --- a/lib/radiator_web/graphql/admin/types.ex +++ b/lib/radiator_web/graphql/admin/types.ex @@ -173,7 +173,7 @@ defmodule RadiatorWeb.GraphQL.Admin.Types do arg :order, type: :sort_order, default_value: :asc # resolve dataloader(Radiator.AudioMeta, :chapters) - resolve &Resolvers.Editor.get_chapters/3 + resolve &Resolvers.Editor.list_chapters/3 end field :episodes, list_of(:episode) do diff --git a/lib/radiator_web/graphql/schema.ex b/lib/radiator_web/graphql/schema.ex index d6f919e0..e9e1485c 100644 --- a/lib/radiator_web/graphql/schema.ex +++ b/lib/radiator_web/graphql/schema.ex @@ -311,15 +311,5 @@ defmodule RadiatorWeb.GraphQL.Schema do middleware Middleware.RequireAuthentication resolve &Admin.Resolvers.Editor.delete_episode/3 end - - @desc "Set chapters for an episode" - field :set_chapters, type: :audio do - arg :id, non_null(:id) - arg :chapters, non_null(:string) - arg :type, non_null(:string) - - middleware Middleware.RequireAuthentication - resolve &Admin.Resolvers.Editor.set_episode_chapters/3 - end end end diff --git a/lib/radiator_web/router.ex b/lib/radiator_web/router.ex index 85117c46..76f5c94c 100644 --- a/lib/radiator_web/router.ex +++ b/lib/radiator_web/router.ex @@ -101,7 +101,12 @@ defmodule RadiatorWeb.Router do end resources "/episodes", EpisodeController, only: [:show, :create, :update, :delete] - resources "/audios", AudioController, only: [:show, :create, :update, :delete] + + resources "/audios", AudioController, only: [:show, :create, :update, :delete] do + resources "/chapters", ChaptersController, + param: "start", + only: [:show, :create, :update, :delete] + end # todo: pluralize resources "/audio_file", AudioFileController, only: [:show, :create] diff --git a/lib/radiator_web/views/api/chapters_view.ex b/lib/radiator_web/views/api/chapters_view.ex new file mode 100644 index 00000000..b55baf19 --- /dev/null +++ b/lib/radiator_web/views/api/chapters_view.ex @@ -0,0 +1,25 @@ +defmodule RadiatorWeb.Api.ChaptersView do + use RadiatorWeb, :view + + alias HAL.{Document, Link} + alias Radiator.AudioMeta.Chapter + + def render("show.json", assigns) do + render(__MODULE__, "chapter.json", assigns) + end + + def render("chapter.json", %{conn: conn, chapter: chapter, audio: audio}) do + %Document{} + |> Document.add_link(%Link{ + rel: "self", + href: Routes.api_audio_chapters_path(conn, :show, audio.id, chapter.start) + }) + |> Document.add_properties(%{ + audio_id: chapter.audio_id, + start: chapter.start, + title: chapter.title, + link: chapter.link, + image: Chapter.image_url(chapter) + }) + end +end diff --git a/priv/repo/migrations/0600_create_chapters.exs b/priv/repo/migrations/0600_create_chapters.exs index 8014a882..dd7a2244 100644 --- a/priv/repo/migrations/0600_create_chapters.exs +++ b/priv/repo/migrations/0600_create_chapters.exs @@ -2,13 +2,13 @@ defmodule Radiator.Repo.Migrations.CreateChapters do use Ecto.Migration def change do - create table(:chapters) do - add :start, :integer + create table(:chapters, primary_key: false) do + add :audio_id, references(:audios, on_delete: :delete_all), primary_key: true + + add :start, :integer, primary_key: true add :title, :text add :link, :text add :image, :string - - add :audio_id, references(:audios, on_delete: :delete_all) end end end diff --git a/test/radiator_web/controllers/api/chapters_controller_test.exs b/test/radiator_web/controllers/api/chapters_controller_test.exs new file mode 100644 index 00000000..f4521bd3 --- /dev/null +++ b/test/radiator_web/controllers/api/chapters_controller_test.exs @@ -0,0 +1,64 @@ +defmodule RadiatorWeb.Api.ChaptersControllerTest do + use RadiatorWeb.ConnCase + + import Radiator.Factory + + def setup_user_and_conn(%{conn: conn}) do + user = Radiator.TestEntries.user() + + [ + conn: Radiator.TestEntries.put_current_user(conn, user), + user: user + ] + end + + setup :setup_user_and_conn + + describe "create chapter" do + test "renders chapter when data is valid", %{conn: conn, user: user} do + audio = insert(:audio) |> owned_by(user) + + conn = + post(conn, Routes.api_audio_chapters_path(conn, :create, audio.id), + chapter: %{title: "example", start: 123_000, link: "http://example.com"} + ) + + assert %{"title" => "example", "start" => 123_000, "link" => "http://example.com"} = + json_response(conn, 201) + end + + test "renders error when permissions are invalid", %{conn: conn} do + audio = insert(:audio) + + conn = + post(conn, Routes.api_audio_chapters_path(conn, :create, audio.id), + chapter: %{title: "example"} + ) + + assert json_response(conn, 401) + end + end + + describe "update chapter" do + test "updates and renders chapter", %{conn: conn, user: user} do + audio = insert(:audio) |> owned_by(user) + chapter = insert(:chapter, audio: audio) + + conn = + put(conn, Routes.api_audio_chapters_path(conn, :update, audio.id, chapter.start), %{ + chapter: %{title: "new"} + }) + + assert %{"title" => "new"} = json_response(conn, 200) + end + end + + test "delete chapter", %{conn: conn, user: user} do + audio = insert(:audio) |> owned_by(user) + chapter = insert(:chapter, audio: audio) + + conn = delete(conn, Routes.api_audio_chapters_path(conn, :delete, audio.id, chapter.start)) + + assert response(conn, 204) + end +end diff --git a/test/radiator_web/graphql/admin/schema/mutation/episodes_test.exs b/test/radiator_web/graphql/admin/schema/mutation/episodes_test.exs index 01ef0c5e..7daa0634 100644 --- a/test/radiator_web/graphql/admin/schema/mutation/episodes_test.exs +++ b/test/radiator_web/graphql/admin/schema/mutation/episodes_test.exs @@ -340,55 +340,4 @@ defmodule RadiatorWeb.GraphQL.Schema.Mutation.EpisodesTest do assert %{"errors" => [%{"message" => "Entity not found"}]} = json_response(conn, 200) end - - @set_chapters_query """ - mutation ($id: ID!, $chapters: String!, $type: String!) { - setChapters(id: $id, chapters: $chapters, type: $type) { - chapters { - start - title - link - } - } - } - """ - - test "setChapters sets chapters for audio", %{conn: conn, user: user} do - audio = insert(:audio) |> owned_by(user) - - chapters = ~S""" - 00:00:01.234 Intro - 00:12:34.000 About us - 01:02:03.000 Later - """ - - conn = - post conn, "/api/graphql", - query: @set_chapters_query, - variables: %{"chapters" => chapters, "id" => audio.id, "type" => "mp4chaps"} - - assert %{ - "data" => %{ - "setChapters" => %{ - "chapters" => [ - %{ - "start" => 1234, - "title" => "Intro", - "link" => "http://example.com" - }, - %{ - "start" => 754_000, - "title" => "About us", - "link" => nil - }, - %{ - "start" => 3_723_000, - "title" => "Later", - "link" => nil - } - ] - } - } - } = json_response(conn, 200) - end end diff --git a/test/support/factory.ex b/test/support/factory.ex index a284d667..65757473 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -146,6 +146,15 @@ defmodule Radiator.Factory do } end + def chapter_factory do + %Radiator.AudioMeta.Chapter{ + start: sequence(:start, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]), + title: sequence(:title, &"chapter #{&1}"), + link: sequence(:link, &"http://example.com/#{&1}"), + audio: build(:audio) + } + end + # @deprecated, use audio_file_factory def enclosure_factory do %Radiator.Media.AudioFile{