diff --git a/lib/pinchflat_web/controllers/api/media_controller.ex b/lib/pinchflat_web/controllers/api/media_controller.ex new file mode 100644 index 0000000..e2aec65 --- /dev/null +++ b/lib/pinchflat_web/controllers/api/media_controller.ex @@ -0,0 +1,62 @@ +defmodule PinchflatWeb.Api.MediaController do + use PinchflatWeb, :controller + + import Ecto.Query, warn: false + + alias Pinchflat.Repo + alias Pinchflat.Media.MediaItem + + @default_limit 50 + @min_limit 1 + @max_limit 500 + + def recent_downloads(conn, params) do + limit = + params + |> Map.get("limit", "#{@default_limit}") + |> parse_int(@default_limit) + |> clamp(@min_limit, @max_limit) + + media_items = + from(mi in MediaItem, + where: not is_nil(mi.media_downloaded_at), + order_by: [desc: mi.media_downloaded_at], + limit: ^limit, + select: %{ + id: mi.id, + uuid: mi.uuid, + title: mi.title, + media_id: mi.media_id, + source_id: mi.source_id, + uploaded_at: mi.uploaded_at, + media_downloaded_at: mi.media_downloaded_at, + media_filepath: mi.media_filepath, + thumbnail_filepath: mi.thumbnail_filepath, + metadata_filepath: mi.metadata_filepath, + nfo_filepath: mi.nfo_filepath, + subtitle_filepaths: mi.subtitle_filepaths + } + ) + |> Repo.all() + + conn + |> put_status(:ok) + |> json(%{data: media_items}) + end + + defp parse_int(value, default) when is_binary(value) do + case Integer.parse(value) do + {int, _} -> int + :error -> default + end + end + + defp parse_int(value, _default) when is_integer(value), do: value + defp parse_int(_, default), do: default + + defp clamp(value, min_val, max_val) do + value + |> Kernel.max(min_val) + |> Kernel.min(max_val) + end +end diff --git a/lib/pinchflat_web/router.ex b/lib/pinchflat_web/router.ex index 9586f13..877d704 100644 --- a/lib/pinchflat_web/router.ex +++ b/lib/pinchflat_web/router.ex @@ -71,6 +71,13 @@ defmodule PinchflatWeb.Router do get "/healthcheck", HealthController, :check, log: false end + # No auth or CSRF protection for internal API endpoints + scope "/api", PinchflatWeb do + pipe_through :api + + get "/media/recent_downloads", Api.MediaController, :recent_downloads + end + scope "/dev" do pipe_through :browser diff --git a/test/pinchflat_web/controllers/api/media_controller_test.exs b/test/pinchflat_web/controllers/api/media_controller_test.exs new file mode 100644 index 0000000..8b13af1 --- /dev/null +++ b/test/pinchflat_web/controllers/api/media_controller_test.exs @@ -0,0 +1,103 @@ +defmodule PinchflatWeb.Api.MediaControllerTest do + use PinchflatWeb.ConnCase + + import Pinchflat.MediaFixtures + + describe "GET /api/media/recent_downloads" do + test "returns only downloaded media items", %{conn: conn} do + downloaded = media_item_fixture(%{media_downloaded_at: DateTime.utc_now()}) + _not_downloaded = media_item_fixture(%{media_downloaded_at: nil}) + + conn = get(conn, "/api/media/recent_downloads") + response = json_response(conn, 200) + + ids = Enum.map(response["data"], & &1["id"]) + assert downloaded.id in ids + assert length(response["data"]) == 1 + end + + test "returns items ordered by media_downloaded_at descending", %{conn: conn} do + older = media_item_fixture(%{media_downloaded_at: ~U[2024-01-01 00:00:00Z]}) + newer = media_item_fixture(%{media_downloaded_at: ~U[2024-06-01 00:00:00Z]}) + + conn = get(conn, "/api/media/recent_downloads") + response = json_response(conn, 200) + + ids = Enum.map(response["data"], & &1["id"]) + assert ids == [newer.id, older.id] + end + + test "default limit is 50", %{conn: conn} do + for _ <- 1..55 do + media_item_fixture(%{media_downloaded_at: DateTime.utc_now()}) + end + + conn = get(conn, "/api/media/recent_downloads") + response = json_response(conn, 200) + + assert length(response["data"]) == 50 + end + + test "respects custom limit param", %{conn: conn} do + for _ <- 1..10 do + media_item_fixture(%{media_downloaded_at: DateTime.utc_now()}) + end + + conn = get(conn, "/api/media/recent_downloads?limit=5") + response = json_response(conn, 200) + + assert length(response["data"]) == 5 + end + + test "clamps limit to minimum of 1", %{conn: conn} do + media_item_fixture(%{media_downloaded_at: DateTime.utc_now()}) + media_item_fixture(%{media_downloaded_at: DateTime.utc_now()}) + + conn = get(conn, "/api/media/recent_downloads?limit=0") + response = json_response(conn, 200) + + assert length(response["data"]) == 1 + end + + test "clamps limit to maximum of 500", %{conn: conn} do + for _ <- 1..5 do + media_item_fixture(%{media_downloaded_at: DateTime.utc_now()}) + end + + conn = get(conn, "/api/media/recent_downloads?limit=9999") + response = json_response(conn, 200) + + assert length(response["data"]) == 5 + end + + test "returns expected fields for each item", %{conn: conn} do + media_item_fixture(%{media_downloaded_at: DateTime.utc_now()}) + + conn = get(conn, "/api/media/recent_downloads") + response = json_response(conn, 200) + + item = hd(response["data"]) + assert Map.has_key?(item, "id") + assert Map.has_key?(item, "uuid") + assert Map.has_key?(item, "title") + assert Map.has_key?(item, "media_id") + assert Map.has_key?(item, "source_id") + assert Map.has_key?(item, "uploaded_at") + assert Map.has_key?(item, "media_downloaded_at") + assert Map.has_key?(item, "media_filepath") + assert Map.has_key?(item, "thumbnail_filepath") + assert Map.has_key?(item, "metadata_filepath") + assert Map.has_key?(item, "nfo_filepath") + assert Map.has_key?(item, "subtitle_filepaths") + end + + test "returns empty data when no downloaded items exist", %{conn: conn} do + _not_downloaded = media_item_fixture(%{media_downloaded_at: nil}) + + conn = get(conn, "/api/media/recent_downloads") + response = json_response(conn, 200) + + assert response["data"] == [] + end + end +end