From 172c1ff264aba237d36917c40bbcaa6fefd738b9 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 3 Jul 2026 23:22:24 +0000 Subject: [PATCH 1/3] Add JSON REST API for MCP integration - Add /api/v1 namespace with Bearer token auth (PINCHFLAT_API_TOKEN env var) - API controllers for sources, media items, media profiles, settings, tasks - Support for all source actions: create, update, delete, force_download_pending, force_redownload, force_index, force_metadata_refresh, sync_files_on_disk - Media item endpoints: list, show, search, force_download, update, delete - Media profile CRUD endpoints - Settings show/update and app_info endpoints - Task listing endpoints - Add Jason.Encoder for Task schema - Comprehensive test suite for all API endpoints - Gitea Actions CI workflow (runs existing checks + API-specific tests) - API documentation in docs/API.md --- .gitea/workflows/ci.yml | 57 ++++++ config/config.exs | 1 + config/runtime.exs | 3 +- config/test.exs | 4 + docs/API.md | 108 ++++++++++ lib/pinchflat/tasks/task.ex | 19 ++ lib/pinchflat_web/api_auth_plug.ex | 50 +++++ .../api/v1/api_media_item_controller.ex | 94 +++++++++ .../api/v1/api_media_profile_controller.ex | 68 +++++++ .../api/v1/api_setting_controller.ex | 56 ++++++ .../api/v1/api_source_controller.ex | 118 +++++++++++ .../controllers/api/v1/api_task_controller.ex | 31 +++ lib/pinchflat_web/router.ex | 46 +++++ test/pinchflat_web/api_auth_plug_test.exs | 65 ++++++ .../api/v1/api_media_item_controller_test.exs | 80 ++++++++ .../v1/api_media_profile_controller_test.exs | 43 ++++ .../api/v1/api_setting_controller_test.exs | 45 +++++ .../api/v1/api_source_controller_test.exs | 187 ++++++++++++++++++ .../api/v1/api_task_controller_test.exs | 48 +++++ 19 files changed, 1122 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 docs/API.md create mode 100644 lib/pinchflat_web/api_auth_plug.ex create mode 100644 lib/pinchflat_web/controllers/api/v1/api_media_item_controller.ex create mode 100644 lib/pinchflat_web/controllers/api/v1/api_media_profile_controller.ex create mode 100644 lib/pinchflat_web/controllers/api/v1/api_setting_controller.ex create mode 100644 lib/pinchflat_web/controllers/api/v1/api_source_controller.ex create mode 100644 lib/pinchflat_web/controllers/api/v1/api_task_controller.ex create mode 100644 test/pinchflat_web/api_auth_plug_test.exs create mode 100644 test/pinchflat_web/controllers/api/v1/api_media_item_controller_test.exs create mode 100644 test/pinchflat_web/controllers/api/v1/api_media_profile_controller_test.exs create mode 100644 test/pinchflat_web/controllers/api/v1/api_setting_controller_test.exs create mode 100644 test/pinchflat_web/controllers/api/v1/api_source_controller_test.exs create mode 100644 test/pinchflat_web/controllers/api/v1/api_task_controller_test.exs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..67663cd --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,57 @@ +name: Build, Lint, and Test + +on: + push: + branches: + - main + - feature/* + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + build-and-test: + name: Build, Lint, and Test + runs-on: ubuntu-latest + if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')" + env: + COMPOSE_FILE: ./docker-compose.ci.yml + MIX_ENV: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Pull prebuilt images + run: docker compose pull + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/dev.Dockerfile + load: true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run Docker image + run: docker compose up --detach + + - name: Install Elixir and JS deps + run: | + docker compose exec -T phx mix deps.get && yarn install && cd assets && yarn install && cd .. + + - name: Create and Migrate database + run: | + docker compose exec -T phx mix ecto.create + docker compose exec -T phx mix ecto.migrate + + - name: Run code checks + run: docker compose exec -T phx mix check --no-fix --no-retry + + - name: Run API tests + run: docker compose exec -T phx mix test test/pinchflat_web/controllers/api test/pinchflat_web/api_auth_plug_test.exs --trace \ No newline at end of file diff --git a/config/config.exs b/config/config.exs index f57e0cc..74b2303 100644 --- a/config/config.exs +++ b/config/config.exs @@ -26,6 +26,7 @@ config :pinchflat, basic_auth_username: "", basic_auth_password: "", expose_feed_endpoints: false, + api_token: "", file_watcher_poll_interval: 1000, timezone: "UTC", base_route_path: "/" diff --git a/config/runtime.exs b/config/runtime.exs index 5624bfe..e82faa9 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -23,7 +23,8 @@ end config :pinchflat, basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"), - basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD") + basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD"), + api_token: System.get_env("PINCHFLAT_API_TOKEN") arch_string = to_string(:erlang.system_info(:system_architecture)) diff --git a/config/test.exs b/config/test.exs index ce61044..4021e3f 100644 --- a/config/test.exs +++ b/config/test.exs @@ -11,6 +11,10 @@ config :pinchflat, config :pinchflat, Oban, testing: :manual +# API token is not set in tests by default — allows tests to run without auth. +# Individual tests that need auth can set it via Application.put_env. +config :pinchflat, :api_token, nil + # Configure your database # # The MIX_TEST_PARTITION environment variable can be used diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..abf3561 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,108 @@ +# Pinchflat API + +This fork adds a JSON REST API to Pinchflat for programmatic access, enabling +MCP server integration and other automation use cases. + +## API Authentication + +The API is protected by a Bearer token. Set the `PINCHFLAT_API_TOKEN` +environment variable to a secure value: + +```bash +docker run \ + -e PINCHFLAT_API_TOKEN=your-secret-token \ + ... +``` + +If the token is not set, API authentication is disabled (useful for +development, but **not recommended for production**). + +Clients must send the token in the `Authorization` header: + +``` +Authorization: Bearer your-secret-token +``` + +## API Endpoints + +All endpoints are under `/api/v1`. + +### Sources + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/sources` | List all sources | +| GET | `/api/v1/sources/:id` | Get a source with its pending tasks | +| POST | `/api/v1/sources` | Create a new source | +| PUT | `/api/v1/sources/:id` | Update a source | +| DELETE | `/api/v1/sources/:id` | Delete a source (marks for deletion) | +| POST | `/api/v1/sources/:source_id/force_download_pending` | Force download pending media | +| POST | `/api/v1/sources/:source_id/force_redownload` | Force re-download existing media | +| POST | `/api/v1/sources/:source_id/force_index` | Force re-index | +| POST | `/api/v1/sources/:source_id/force_metadata_refresh` | Force metadata refresh | +| POST | `/api/v1/sources/:source_id/sync_files_on_disk` | Sync files on disk | + +### Media Items + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/media` | List all media items (optional `?source_id=X` filter) | +| GET | `/api/v1/media/search?q=query` | Search media items | +| GET | `/api/v1/media/:id` | Get a media item | +| PUT | `/api/v1/media/:id` | Update a media item | +| DELETE | `/api/v1/media/:id` | Delete media files | +| POST | `/api/v1/media/:media_item_id/force_download` | Force download a media item | + +### Media Profiles + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/media_profiles` | List all media profiles | +| GET | `/api/v1/media_profiles/:id` | Get a media profile | +| POST | `/api/v1/media_profiles` | Create a media profile | +| PUT | `/api/v1/media_profiles/:id` | Update a media profile | +| DELETE | `/api/v1/media_profiles/:id` | Delete a media profile | + +### Settings + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/settings` | Get settings (route_token excluded) | +| PUT | `/api/v1/settings` | Update settings | +| GET | `/api/v1/app_info` | Get app info (version, environment, etc.) | + +### Tasks + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/tasks` | List all tasks (optional `?source_id=X` filter) | +| GET | `/api/v1/tasks/:id` | Get a task | + +## Response Format + +All responses are JSON. Successful responses use: + +```json +{"data": ...} +``` + +Error responses use: + +```json +{"errors": {"field": "error message"}} +``` + +## Example: Create a Source + +```bash +curl -X POST http://localhost:8945/api/v1/sources \ + -H "Authorization: Bearer your-token" \ + -H "Content-Type: application/json" \ + -d '{ + "source": { + "media_profile_id": 1, + "collection_type": "channel", + "original_url": "https://www.youtube.com/@SomeChannel" + } + }' +``` \ No newline at end of file diff --git a/lib/pinchflat/tasks/task.ex b/lib/pinchflat/tasks/task.ex index 1a4f529..d498651 100644 --- a/lib/pinchflat/tasks/task.ex +++ b/lib/pinchflat/tasks/task.ex @@ -6,6 +6,7 @@ defmodule Pinchflat.Tasks.Task do use Ecto.Schema import Ecto.Changeset + alias Pinchflat.Repo alias Pinchflat.Media.MediaItem alias Pinchflat.Sources.Source @@ -23,4 +24,22 @@ defmodule Pinchflat.Tasks.Task do |> cast(attrs, [:job_id, :source_id, :media_item_id]) |> validate_required([:job_id]) end + + @doc false + def json_excluded_fields do + [:__meta__, :__struct__] + end +end + +defimpl Jason.Encoder, for: Pinchflat.Tasks.Task do + def encode(value, opts) do + value + |> Repo.preload(:job) + |> Map.drop(Pinchflat.Tasks.Task.json_excluded_fields()) + |> Map.update!(:job, fn + nil -> nil + job -> Map.drop(job, [:__meta__, :__struct__, :args, :meta, :worker, :queue]) + end) + |> Jason.Encode.map(opts) + end end diff --git a/lib/pinchflat_web/api_auth_plug.ex b/lib/pinchflat_web/api_auth_plug.ex new file mode 100644 index 0000000..6635a08 --- /dev/null +++ b/lib/pinchflat_web/api_auth_plug.ex @@ -0,0 +1,50 @@ +defmodule PinchflatWeb.ApiAuthPlug do + @moduledoc """ + Authenticates API requests using a Bearer token. + + The token is read from the `PINCHFLAT_API_TOKEN` environment variable. + If the environment variable is not set, API authentication is disabled + (useful for development and testing). In production, you should always + set this to a secure value. + + Clients should send the token in the Authorization header: + Authorization: Bearer + """ + + import Plug.Conn + alias Pinchflat.Settings + + def init(opts), do: opts + + def call(conn, _opts) do + expected_token = Application.get_env(:pinchflat, :api_token) + + if credential_set?(expected_token) do + case get_req_header(conn, "authorization") do + ["Bearer " <> token] -> + if String.trim(token) == expected_token do + conn + else + send_unauthorized(conn) + end + + _ -> + send_unauthorized(conn) + end + else + # No API token configured — allow access (dev/test mode) + conn + end + end + + defp credential_set?(credential) do + credential && credential != "" + end + + defp send_unauthorized(conn) do + conn + |> put_resp_content_type("application/json") + |> send_resp(401, ~s({"errors":{"detail":"Unauthorized"}})) + |> halt() + end +end \ No newline at end of file diff --git a/lib/pinchflat_web/controllers/api/v1/api_media_item_controller.ex b/lib/pinchflat_web/controllers/api/v1/api_media_item_controller.ex new file mode 100644 index 0000000..11e5caa --- /dev/null +++ b/lib/pinchflat_web/controllers/api/v1/api_media_item_controller.ex @@ -0,0 +1,94 @@ +defmodule PinchflatWeb.Api.V1.ApiMediaItemController do + @moduledoc """ + JSON API controller for MediaItems. + """ + + use PinchflatWeb, :controller + + alias Pinchflat.Repo + alias Pinchflat.Media + alias Pinchflat.Media.MediaItem + alias Pinchflat.Downloading.MediaDownloadWorker + + def index(conn, params) do + media_items = + case params do + %{"source_id" => source_id} -> + source = Pinchflat.Sources.get_source!(source_id) + Media.list_pending_media_items_for(source) + + _ -> + Media.list_media_items() + end + + media_items = Repo.preload(media_items, :source) + json(conn, %{data: media_items}) + end + + def show(conn, %{"id" => id}) do + media_item = + id + |> Media.get_media_item!() + |> Repo.preload([:source, tasks: [:job]]) + + json(conn, %{data: media_item}) + end + + def search(conn, %{"q" => query}) do + results = Media.search(query) |> Repo.preload(:source) + json(conn, %{data: results}) + end + + def search(conn, _params) do + json(conn, %{data: []}) + end + + def force_download(conn, %{"media_item_id" => id}) do + media_item = Media.get_media_item!(id) + + case MediaDownloadWorker.kickoff_with_task(media_item, %{force: true}) do + {:ok, _} -> + json(conn, %{data: %{message: "Download task enqueued."}}) + + {:error, :duplicate_job} -> + json(conn, %{data: %{message: "Download task already enqueued."}}) + + {:error, reason} -> + conn + |> put_status(:internal_server_error) + |> json(%{errors: %{detail: "Failed to enqueue download: #{inspect(reason)}"}}) + end + end + + def update(conn, %{"id" => id, "media_item" => params}) do + media_item = Media.get_media_item!(id) + + case Media.update_media_item(media_item, params) do + {:ok, media_item} -> + media_item = Repo.preload(media_item, :source) + json(conn, %{data: media_item}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_changeset_errors(changeset)}) + end + end + + def delete(conn, %{"id" => id} = params) do + prevent_download = Map.get(params, "prevent_download", "false") == "true" + media_item = Media.get_media_item!(id) + + {:ok, _} = Media.delete_media_files(media_item, %{prevent_download: prevent_download}) + + json(conn, %{data: %{message: "Files deleted successfully."}}) + end + + defp format_changeset_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end \ No newline at end of file diff --git a/lib/pinchflat_web/controllers/api/v1/api_media_profile_controller.ex b/lib/pinchflat_web/controllers/api/v1/api_media_profile_controller.ex new file mode 100644 index 0000000..cb8df72 --- /dev/null +++ b/lib/pinchflat_web/controllers/api/v1/api_media_profile_controller.ex @@ -0,0 +1,68 @@ +defmodule PinchflatWeb.Api.V1.ApiMediaProfileController do + @moduledoc """ + JSON API controller for MediaProfiles. + """ + + use PinchflatWeb, :controller + + alias Pinchflat.Profiles + alias Pinchflat.Profiles.MediaProfile + + def index(conn, _params) do + json(conn, %{data: Profiles.list_media_profiles()}) + end + + def show(conn, %{"id" => id}) do + json(conn, %{data: Profiles.get_media_profile!(id)}) + end + + def create(conn, %{"media_profile" => params}) do + case Profiles.create_media_profile(params) do + {:ok, media_profile} -> + conn + |> put_status(:created) + |> json(%{data: media_profile}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_changeset_errors(changeset)}) + end + end + + def update(conn, %{"id" => id, "media_profile" => params}) do + media_profile = Profiles.get_media_profile!(id) + + case Profiles.update_media_profile(media_profile, params) do + {:ok, media_profile} -> + json(conn, %{data: media_profile}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_changeset_errors(changeset)}) + end + end + + def delete(conn, %{"id" => id}) do + media_profile = Profiles.get_media_profile!(id) + + case Profiles.delete_media_profile(media_profile) do + {:ok, _} -> + json(conn, %{data: %{message: "Media profile deleted."}}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_changeset_errors(changeset)}) + end + end + + defp format_changeset_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end \ No newline at end of file diff --git a/lib/pinchflat_web/controllers/api/v1/api_setting_controller.ex b/lib/pinchflat_web/controllers/api/v1/api_setting_controller.ex new file mode 100644 index 0000000..6f433e1 --- /dev/null +++ b/lib/pinchflat_web/controllers/api/v1/api_setting_controller.ex @@ -0,0 +1,56 @@ +defmodule PinchflatWeb.Api.V1.ApiSettingController do + @moduledoc """ + JSON API controller for Settings. + """ + + use PinchflatWeb, :controller + + alias Pinchflat.Settings + + def show(conn, _params) do + setting = Settings.record() + safe_data = setting_to_map(setting) + json(conn, %{data: safe_data}) + end + + def update(conn, %{"setting" => params}) do + setting = Settings.record() + + case Settings.update_setting(setting, params) do + {:ok, setting} -> + safe_data = setting_to_map(setting) + json(conn, %{data: safe_data}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_changeset_errors(changeset)}) + end + end + + def app_info(conn, _params) do + info = %{ + version: Application.spec(:pinchflat, :vsn) |> to_string(), + environment: Application.get_env(:pinchflat, :env), + yt_dlp_version: Settings.get!(:yt_dlp_version), + apprise_version: Settings.get!(:apprise_version), + onboarding: Settings.get!(:onboarding) + } + + json(conn, %{data: info}) + end + + defp setting_to_map(setting) do + setting + |> Map.from_struct() + |> Map.drop([:route_token, :__meta__, :__struct__]) + end + + defp format_changeset_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end \ No newline at end of file diff --git a/lib/pinchflat_web/controllers/api/v1/api_source_controller.ex b/lib/pinchflat_web/controllers/api/v1/api_source_controller.ex new file mode 100644 index 0000000..b55cfeb --- /dev/null +++ b/lib/pinchflat_web/controllers/api/v1/api_source_controller.ex @@ -0,0 +1,118 @@ +defmodule PinchflatWeb.Api.V1.ApiSourceController do + @moduledoc """ + JSON API controller for Sources. + """ + + use PinchflatWeb, :controller + + alias Pinchflat.Repo + alias Pinchflat.Sources + alias Pinchflat.Sources.Source + alias Pinchflat.Tasks + alias Pinchflat.Downloading.DownloadingHelpers + alias Pinchflat.SlowIndexing.SlowIndexingHelpers + alias Pinchflat.Metadata.SourceMetadataStorageWorker + alias Pinchflat.Media.FileSyncingWorker + alias Pinchflat.Sources.SourceDeletionWorker + + def index(conn, _params) do + sources = Sources.list_sources() |> Repo.preload(:media_profile) + json(conn, %{data: sources}) + end + + def show(conn, %{"id" => id}) do + source = Sources.get_source!(id) |> Repo.preload([:media_profile, :media_items]) + + pending_tasks = + source + |> Tasks.list_tasks_for(nil, [:executing, :available, :scheduled, :retryable]) + |> Repo.preload(:job) + + json(conn, %{data: source, pending_tasks: pending_tasks}) + end + + def create(conn, %{"source" => source_params}) do + case Sources.create_source(source_params) do + {:ok, source} -> + source = Repo.preload(source, :media_profile) + conn + |> put_status(:created) + |> json(%{data: source}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_changeset_errors(changeset)}) + end + end + + def update(conn, %{"id" => id, "source" => source_params}) do + source = Sources.get_source!(id) + + case Sources.update_source(source, source_params) do + {:ok, source} -> + source = Repo.preload(source, :media_profile) + json(conn, %{data: source}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_changeset_errors(changeset)}) + end + end + + def delete(conn, %{"id" => id} = params) do + delete_files = Map.get(params, "delete_files", "false") == "true" + source = Sources.get_source!(id) + + {:ok, _} = Sources.update_source(source, %{marked_for_deletion_at: DateTime.utc_now()}) + SourceDeletionWorker.kickoff(source, %{delete_files: delete_files}) + + conn + |> put_status(:ok) + |> json(%{data: %{id: source.id, message: "Source deletion started."}}) + end + + def force_download_pending(conn, %{"source_id" => id}) do + source = Sources.get_source!(id) + DownloadingHelpers.enqueue_pending_download_tasks(source) + + json(conn, %{data: %{message: "Forcing download of pending media items."}}) + end + + def force_redownload(conn, %{"source_id" => id}) do + source = Sources.get_source!(id) + DownloadingHelpers.kickoff_redownload_for_existing_media(source) + + json(conn, %{data: %{message: "Forcing re-download of downloaded media items."}}) + end + + def force_index(conn, %{"source_id" => id}) do + source = Sources.get_source!(id) + SlowIndexingHelpers.kickoff_indexing_task(source, %{force: true}) + + json(conn, %{data: %{message: "Index enqueued."}}) + end + + def force_metadata_refresh(conn, %{"source_id" => id}) do + source = Sources.get_source!(id) + SourceMetadataStorageWorker.kickoff_with_task(source) + + json(conn, %{data: %{message: "Metadata refresh enqueued."}}) + end + + def sync_files_on_disk(conn, %{"source_id" => id}) do + source = Sources.get_source!(id) + FileSyncingWorker.kickoff_with_task(source) + + json(conn, %{data: %{message: "File sync enqueued."}}) + end + + defp format_changeset_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end \ No newline at end of file diff --git a/lib/pinchflat_web/controllers/api/v1/api_task_controller.ex b/lib/pinchflat_web/controllers/api/v1/api_task_controller.ex new file mode 100644 index 0000000..06d1fe3 --- /dev/null +++ b/lib/pinchflat_web/controllers/api/v1/api_task_controller.ex @@ -0,0 +1,31 @@ +defmodule PinchflatWeb.Api.V1.ApiTaskController do + @moduledoc """ + JSON API controller for Tasks. + """ + + use PinchflatWeb, :controller + + alias Pinchflat.Repo + alias Pinchflat.Tasks + + def index(conn, params) do + tasks = + case params do + %{"source_id" => source_id} -> + source = Pinchflat.Sources.get_source!(source_id) + Tasks.list_tasks_for(source) + |> Repo.preload(:job) + + _ -> + Tasks.list_tasks() + |> Repo.preload(:job) + end + + json(conn, %{data: tasks}) + end + + def show(conn, %{"id" => id}) do + task = Tasks.get_task!(id) |> Repo.preload(:job) + json(conn, %{data: task}) + end +end \ No newline at end of file diff --git a/lib/pinchflat_web/router.ex b/lib/pinchflat_web/router.ex index 9586f13..86d8ba8 100644 --- a/lib/pinchflat_web/router.ex +++ b/lib/pinchflat_web/router.ex @@ -20,6 +20,10 @@ defmodule PinchflatWeb.Router do plug :accepts, ["json"] end + pipeline :api_auth do + plug PinchflatWeb.ApiAuthPlug + end + scope "/", PinchflatWeb do pipe_through [:maybe_basic_auth, :token_protected_route] @@ -71,6 +75,48 @@ defmodule PinchflatWeb.Router do get "/healthcheck", HealthController, :check, log: false end + # JSON API for MCP integration and other programmatic clients. + # Protected by Bearer token auth (PINCHFLAT_API_TOKEN env var). + scope "/api/v1", PinchflatWeb.Api.V1 do + pipe_through [:api, :api_auth] + + get "/sources", ApiSourceController, :index + get "/sources/:id", ApiSourceController, :show + post "/sources", ApiSourceController, :create + put "/sources/:id", ApiSourceController, :update + patch "/sources/:id", ApiSourceController, :update + delete "/sources/:id", ApiSourceController, :delete + + post "/sources/:source_id/force_download_pending", ApiSourceController, :force_download_pending + post "/sources/:source_id/force_redownload", ApiSourceController, :force_redownload + post "/sources/:source_id/force_index", ApiSourceController, :force_index + post "/sources/:source_id/force_metadata_refresh", ApiSourceController, :force_metadata_refresh + post "/sources/:source_id/sync_files_on_disk", ApiSourceController, :sync_files_on_disk + + get "/media", ApiMediaItemController, :index + get "/media/search", ApiMediaItemController, :search + get "/media/:id", ApiMediaItemController, :show + put "/media/:id", ApiMediaItemController, :update + patch "/media/:id", ApiMediaItemController, :update + delete "/media/:id", ApiMediaItemController, :delete + post "/media/:media_item_id/force_download", ApiMediaItemController, :force_download + + get "/media_profiles", ApiMediaProfileController, :index + get "/media_profiles/:id", ApiMediaProfileController, :show + post "/media_profiles", ApiMediaProfileController, :create + put "/media_profiles/:id", ApiMediaProfileController, :update + patch "/media_profiles/:id", ApiMediaProfileController, :update + delete "/media_profiles/:id", ApiMediaProfileController, :delete + + get "/settings", ApiSettingController, :show + put "/settings", ApiSettingController, :update + patch "/settings", ApiSettingController, :update + get "/app_info", ApiSettingController, :app_info + + get "/tasks", ApiTaskController, :index + get "/tasks/:id", ApiTaskController, :show + end + scope "/dev" do pipe_through :browser diff --git a/test/pinchflat_web/api_auth_plug_test.exs b/test/pinchflat_web/api_auth_plug_test.exs new file mode 100644 index 0000000..536644c --- /dev/null +++ b/test/pinchflat_web/api_auth_plug_test.exs @@ -0,0 +1,65 @@ +defmodule PinchflatWeb.ApiAuthPlugTest do + use PinchflatWeb.ConnCase + + import Pinchflat.ProfilesFixtures + + setup do + # Ensure api_token is not set by default in tests + Application.put_env(:pinchflat, :api_token, nil) + :ok + end + + describe "when no api_token is configured" do + test "allows access without Authorization header", %{conn: conn} do + conn = get(conn, ~p"/api/v1/media_profiles") + assert %{status: 200} = conn + end + end + + describe "when api_token is configured" do + setup do + Application.put_env(:pinchflat, :api_token, "secret-token-123") + on_exit(fn -> Application.put_env(:pinchflat, :api_token, nil) end) + :ok + end + + test "allows access with correct Bearer token", %{conn: conn} do + conn = + conn + |> put_req_header("authorization", "Bearer secret-token-123") + |> get(~p"/api/v1/media_profiles") + + assert %{status: 200} = conn + end + + test "returns 401 without Authorization header", %{conn: conn} do + conn = get(conn, ~p"/api/v1/media_profiles") + + assert %{status: 401} = conn + assert json_response(conn, 401)["errors"]["detail"] == "Unauthorized" + end + + test "returns 401 with incorrect token", %{conn: conn} do + conn = + conn + |> put_req_header("authorization", "Bearer wrong-token") + |> get(~p"/api/v1/media_profiles") + + assert %{status: 401} = conn + end + + test "returns 401 with malformed Authorization header", %{conn: conn} do + conn = + conn + |> put_req_header("authorization", "secret-token-123") + |> get(~p"/api/v1/media_profiles") + + assert %{status: 401} = conn + end + + test "healthcheck endpoint does not require auth", %{conn: conn} do + conn = get(conn, ~p"/healthcheck") + assert %{status: 200} = conn + end + end +end \ No newline at end of file diff --git a/test/pinchflat_web/controllers/api/v1/api_media_item_controller_test.exs b/test/pinchflat_web/controllers/api/v1/api_media_item_controller_test.exs new file mode 100644 index 0000000..0da1ec5 --- /dev/null +++ b/test/pinchflat_web/controllers/api/v1/api_media_item_controller_test.exs @@ -0,0 +1,80 @@ +defmodule PinchflatWeb.Api.V1.ApiMediaItemControllerTest do + use PinchflatWeb.ConnCase + + import Pinchflat.MediaFixtures + import Pinchflat.SourcesFixtures + import Pinchflat.ProfilesFixtures + + alias Pinchflat.Downloading.MediaDownloadWorker + + describe "GET /api/v1/media" do + test "lists all media items as JSON", %{conn: conn} do + media_item = media_item_fixture() + conn = get(conn, ~p"/api/v1/media") + + assert %{status: 200} = conn + data = json_response(conn, 200)["data"] + assert length(data) == 1 + assert hd(data)["id"] == media_item.id + end + + test "filters by source_id", %{conn: conn} do + source1 = source_fixture() + source2 = source_fixture() + _media1 = media_item_fixture(%{source_id: source1.id}) + _media2 = media_item_fixture(%{source_id: source2.id}) + + conn = get(conn, ~p"/api/v1/media?source_id=#{source1.id}") + + assert %{status: 200} = conn + data = json_response(conn, 200)["data"] + assert length(data) == 1 + end + end + + describe "GET /api/v1/media/:id" do + test "shows a media item as JSON", %{conn: conn} do + media_item = media_item_fixture() + conn = get(conn, ~p"/api/v1/media/#{media_item.id}") + + assert %{status: 200} = conn + assert json_response(conn, 200)["data"]["id"] == media_item.id + end + + test "returns 404 for non-existent media item", %{conn: conn} do + conn = get(conn, ~p"/api/v1/media/999999") + + assert %{status: 404} = conn + end + end + + describe "GET /api/v1/media/search" do + test "returns search results as JSON", %{conn: conn} do + media_item = media_item_fixture(%{title: "Cool Video About Elixir"}) + conn = get(conn, ~p"/api/v1/media/search?q=Elixir") + + assert %{status: 200} = conn + data = json_response(conn, 200)["data"] + assert length(data) >= 1 + end + + test "returns empty for blank query", %{conn: conn} do + conn = get(conn, ~p"/api/v1/media/search?q=") + + assert %{status: 200} = conn + assert json_response(conn, 200)["data"] == [] + end + end + + describe "POST /api/v1/media/:media_item_id/force_download" do + test "enqueues a download task", %{conn: conn} do + media_item = media_item_fixture(%{media_filepath: nil}) + + assert [] = all_enqueued(worker: MediaDownloadWorker) + conn = post(conn, ~p"/api/v1/media/#{media_item.id}/force_download") + + assert %{status: 200} = conn + assert [_] = all_enqueued(worker: MediaDownloadWorker) + end + end +end \ No newline at end of file diff --git a/test/pinchflat_web/controllers/api/v1/api_media_profile_controller_test.exs b/test/pinchflat_web/controllers/api/v1/api_media_profile_controller_test.exs new file mode 100644 index 0000000..e3448a5 --- /dev/null +++ b/test/pinchflat_web/controllers/api/v1/api_media_profile_controller_test.exs @@ -0,0 +1,43 @@ +defmodule PinchflatWeb.Api.V1.ApiMediaProfileControllerTest do + use PinchflatWeb.ConnCase + + import Pinchflat.ProfilesFixtures + + describe "GET /api/v1/media_profiles" do + test "lists all media profiles as JSON", %{conn: conn} do + media_profile = media_profile_fixture() + conn = get(conn, ~p"/api/v1/media_profiles") + + assert %{status: 200} = conn + data = json_response(conn, 200)["data"] + assert length(data) >= 1 + ids = Enum.map(data, & &1["id"]) + assert media_profile.id in ids + end + end + + describe "GET /api/v1/media_profiles/:id" do + test "shows a media profile as JSON", %{conn: conn} do + media_profile = media_profile_fixture() + conn = get(conn, ~p"/api/v1/media_profiles/#{media_profile.id}") + + assert %{status: 200} = conn + assert json_response(conn, 200)["data"]["id"] == media_profile.id + end + end + + describe "POST /api/v1/media_profiles" do + test "creates a media profile and returns 201", %{conn: conn} do + conn = post(conn, ~p"/api/v1/media_profiles", media_profile: %{name: "Test Profile", output_path_template: "{{title}}.{{ext}}"}) + + assert %{status: 201} = conn + assert json_response(conn, 201)["data"]["name"] == "Test Profile" + end + + test "returns 422 when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/v1/media_profiles", media_profile: %{name: nil}) + + assert %{status: 422} = conn + end + end +end \ No newline at end of file diff --git a/test/pinchflat_web/controllers/api/v1/api_setting_controller_test.exs b/test/pinchflat_web/controllers/api/v1/api_setting_controller_test.exs new file mode 100644 index 0000000..851fb52 --- /dev/null +++ b/test/pinchflat_web/controllers/api/v1/api_setting_controller_test.exs @@ -0,0 +1,45 @@ +defmodule PinchflatWeb.Api.V1.ApiSettingControllerTest do + use PinchflatWeb.ConnCase + + import Pinchflat.ProfilesFixtures + + alias Pinchflat.Settings + + describe "GET /api/v1/settings" do + test "returns settings as JSON", %{conn: conn} do + conn = get(conn, ~p"/api/v1/settings") + + assert %{status: 200} = conn + data = json_response(conn, 200)["data"] + assert data["onboarding"] != nil + end + + test "does not expose route_token", %{conn: conn} do + conn = get(conn, ~p"/api/v1/settings") + + assert %{status: 200} = conn + data = json_response(conn, 200)["data"] + refute Map.has_key?(data, "route_token") + end + end + + describe "PUT /api/v1/settings" do + test "updates settings", %{conn: conn} do + conn = put(conn, ~p"/api/v1/settings", setting: %{onboarding: false}) + + assert %{status: 200} = conn + assert json_response(conn, 200)["data"]["onboarding"] == false + end + end + + describe "GET /api/v1/app_info" do + test "returns app info as JSON", %{conn: conn} do + conn = get(conn, ~p"/api/v1/app_info") + + assert %{status: 200} = conn + data = json_response(conn, 200)["data"] + assert data["version"] != nil + assert data["environment"] != nil + end + end +end \ No newline at end of file diff --git a/test/pinchflat_web/controllers/api/v1/api_source_controller_test.exs b/test/pinchflat_web/controllers/api/v1/api_source_controller_test.exs new file mode 100644 index 0000000..11ccab4 --- /dev/null +++ b/test/pinchflat_web/controllers/api/v1/api_source_controller_test.exs @@ -0,0 +1,187 @@ +defmodule PinchflatWeb.Api.V1.ApiSourceControllerTest do + use PinchflatWeb.ConnCase + + import Pinchflat.MediaFixtures + import Pinchflat.SourcesFixtures + import Pinchflat.ProfilesFixtures + + alias Pinchflat.Settings + alias Pinchflat.Downloading.MediaDownloadWorker + alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker + alias Pinchflat.Metadata.SourceMetadataStorageWorker + alias Pinchflat.Media.FileSyncingWorker + + setup do + media_profile = media_profile_fixture() + Settings.set(onboarding: false) + + {:ok, + %{ + media_profile: media_profile, + create_attrs: %{ + media_profile_id: media_profile.id, + collection_type: "channel", + original_url: "https://www.youtube.com/source/abc123" + }, + update_attrs: %{ + original_url: "https://www.youtube.com/source/321xyz" + }, + invalid_attrs: %{original_url: nil, media_profile_id: nil} + }} + end + + describe "GET /api/v1/sources" do + test "lists all sources as JSON", %{conn: conn} do + source = source_fixture() + conn = get(conn, ~p"/api/v1/sources") + + assert %{status: 200} = conn + data = json_response(conn, 200)["data"] + assert length(data) == 1 + assert hd(data)["id"] == source.id + end + end + + describe "GET /api/v1/sources/:id" do + test "shows a source as JSON", %{conn: conn} do + source = source_fixture() + conn = get(conn, ~p"/api/v1/sources/#{source.id}") + + assert %{status: 200} = conn + assert json_response(conn, 200)["data"]["id"] == source.id + end + + test "returns 404 for non-existent source", %{conn: conn} do + conn = get(conn, ~p"/api/v1/sources/999999") + + assert %{status: 404} = conn + end + end + + describe "POST /api/v1/sources" do + test "creates a source and returns 201", %{conn: conn, create_attrs: create_attrs} do + expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/5) + + conn = post(conn, ~p"/api/v1/sources", source: create_attrs) + + assert %{status: 201} = conn + assert json_response(conn, 201)["data"]["id"] != nil + end + + test "returns 422 when data is invalid", %{conn: conn, invalid_attrs: invalid_attrs} do + conn = post(conn, ~p"/api/v1/sources", source: invalid_attrs) + + assert %{status: 422} = conn + assert json_response(conn, 422)["errors"] != nil + end + end + + describe "PUT /api/v1/sources/:id" do + setup [:create_source] + + test "updates the source", %{conn: conn, source: source, update_attrs: update_attrs} do + expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/5) + + conn = put(conn, ~p"/api/v1/sources/#{source.id}", source: update_attrs) + + assert %{status: 200} = conn + assert json_response(conn, 200)["data"]["original_url"] == "https://www.youtube.com/source/321xyz" + end + + test "returns 422 when data is invalid", %{conn: conn, source: source, invalid_attrs: invalid_attrs} do + conn = put(conn, ~p"/api/v1/sources/#{source.id}", source: invalid_attrs) + + assert %{status: 422} = conn + end + end + + describe "DELETE /api/v1/sources/:id" do + setup [:create_source] + + test "marks source for deletion", %{conn: conn, source: source} do + conn = delete(conn, ~p"/api/v1/sources/#{source.id}") + + assert %{status: 200} = conn + assert json_response(conn, 200)["data"]["message"] =~ "deletion" + end + end + + describe "POST /api/v1/sources/:source_id/force_download_pending" do + test "enqueues pending download tasks", %{conn: conn} do + source = source_fixture() + _media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil}) + + assert [] = all_enqueued(worker: MediaDownloadWorker) + conn = post(conn, ~p"/api/v1/sources/#{source.id}/force_download_pending") + + assert %{status: 200} = conn + assert [_] = all_enqueued(worker: MediaDownloadWorker) + end + end + + describe "POST /api/v1/sources/:source_id/force_redownload" do + test "enqueues re-download tasks", %{conn: conn} do + source = source_fixture() + _media_item = media_item_fixture(source_id: source.id, media_downloaded_at: now()) + + assert [] = all_enqueued(worker: MediaDownloadWorker) + conn = post(conn, ~p"/api/v1/sources/#{source.id}/force_redownload") + + assert %{status: 200} = conn + assert [_] = all_enqueued(worker: MediaDownloadWorker) + end + end + + describe "POST /api/v1/sources/:source_id/force_index" do + test "forces an index", %{conn: conn} do + source = source_fixture() + + assert [] = all_enqueued(worker: MediaCollectionIndexingWorker) + conn = post(conn, ~p"/api/v1/sources/#{source.id}/force_index") + + assert %{status: 200} = conn + assert [_] = all_enqueued(worker: MediaCollectionIndexingWorker) + end + end + + describe "POST /api/v1/sources/:source_id/force_metadata_refresh" do + test "forces a metadata refresh", %{conn: conn} do + source = source_fixture() + + assert [] = all_enqueued(worker: SourceMetadataStorageWorker) + conn = post(conn, ~p"/api/v1/sources/#{source.id}/force_metadata_refresh") + + assert %{status: 200} = conn + assert [_] = all_enqueued(worker: SourceMetadataStorageWorker) + end + end + + describe "POST /api/v1/sources/:source_id/sync_files_on_disk" do + test "forces a file sync", %{conn: conn} do + source = source_fixture() + + assert [] = all_enqueued(worker: FileSyncingWorker) + conn = post(conn, ~p"/api/v1/sources/#{source.id}/sync_files_on_disk") + + assert %{status: 200} = conn + assert [_] = all_enqueued(worker: FileSyncingWorker) + end + end + + defp create_source(_) do + source = source_fixture() + media_item = media_item_with_attachments(%{source_id: source.id}) + + %{source: source, media_item: media_item} + end + + defp runner_function_mock(_url, :get_source_details, _opts, _ot, _addl) do + {:ok, + Phoenix.json_library().encode!(%{ + channel: "some channel name", + channel_id: "some_channel_id_#{:rand.uniform(1_000_000)}", + playlist_id: "some_playlist_id_#{:rand.uniform(1_000_000)}", + playlist_title: "some playlist name" + })} + end +end \ No newline at end of file diff --git a/test/pinchflat_web/controllers/api/v1/api_task_controller_test.exs b/test/pinchflat_web/controllers/api/v1/api_task_controller_test.exs new file mode 100644 index 0000000..23d0ba5 --- /dev/null +++ b/test/pinchflat_web/controllers/api/v1/api_task_controller_test.exs @@ -0,0 +1,48 @@ +defmodule PinchflatWeb.Api.V1.ApiTaskControllerTest do + use PinchflatWeb.ConnCase + + import Pinchflat.SourcesFixtures + import Pinchflat.MediaFixtures + + alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker + + describe "GET /api/v1/tasks" do + test "lists all tasks as JSON", %{conn: conn} do + source = source_fixture() + {:ok, task} = MediaCollectionIndexingWorker.kickoff_with_task(source) + + conn = get(conn, ~p"/api/v1/tasks") + + assert %{status: 200} = conn + data = json_response(conn, 200)["data"] + ids = Enum.map(data, & &1["id"]) + assert task.id in ids + end + + test "filters by source_id", %{conn: conn} do + source1 = source_fixture() + source2 = source_fixture() + {:ok, task1} = MediaCollectionIndexingWorker.kickoff_with_task(source1) + {:ok, _task2} = MediaCollectionIndexingWorker.kickoff_with_task(source2) + + conn = get(conn, ~p"/api/v1/tasks?source_id=#{source1.id}") + + assert %{status: 200} = conn + data = json_response(conn, 200)["data"] + assert length(data) == 1 + assert hd(data)["id"] == task1.id + end + end + + describe "GET /api/v1/tasks/:id" do + test "shows a task as JSON", %{conn: conn} do + source = source_fixture() + {:ok, task} = MediaCollectionIndexingWorker.kickoff_with_task(source) + + conn = get(conn, ~p"/api/v1/tasks/#{task.id}") + + assert %{status: 200} = conn + assert json_response(conn, 200)["data"]["id"] == task.id + end + end +end \ No newline at end of file From 51f93c521e700cd105a547d4d79c26ebad9e80e6 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 3 Jul 2026 23:23:23 +0000 Subject: [PATCH 2/3] Fix CI branch names: master instead of main --- .gitea/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 67663cd..ef9eb18 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -3,11 +3,11 @@ name: Build, Lint, and Test on: push: branches: - - main + - master - feature/* pull_request: branches: - - main + - master workflow_dispatch: jobs: From 0cf96f88be9240f55c611644e9bebeb3e9d50e5b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 4 Jul 2026 09:35:17 +0000 Subject: [PATCH 3/3] Fix API controller tests: 404 handling, JSON encoding, media filtering - Add FallbackController for proper 404 JSON responses on missing resources - Add non-bang get_source/1 and get_media_item/1 for safe lookups - Fix Task JSON encoder: remove Repo.preload call (not available in encoder scope), handle NotLoaded associations by returning nil - Fix media item index: use list_media_items_for_source/1 instead of list_pending_media_items_for/1 which had overly restrictive filtering - Add missing extras_directory to test config - All 1007 tests pass --- config/test.exs | 1 + lib/pinchflat/media/media.ex | 11 ++++++++++ lib/pinchflat/sources/sources.ex | 1 + lib/pinchflat/tasks/task.ex | 13 ++++++++++-- .../api/v1/api_media_item_controller.ex | 17 +++++++++------- .../api/v1/api_source_controller.ex | 20 +++++++++++++------ .../controllers/api/v1/api_task_controller.ex | 2 ++ .../controllers/api/v1/fallback_controller.ex | 18 +++++++++++++++++ 8 files changed, 68 insertions(+), 15 deletions(-) create mode 100644 lib/pinchflat_web/controllers/api/v1/fallback_controller.ex diff --git a/config/test.exs b/config/test.exs index 4021e3f..4a42e62 100644 --- a/config/test.exs +++ b/config/test.exs @@ -6,6 +6,7 @@ config :pinchflat, apprise_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]), media_directory: Path.join([System.tmp_dir!(), "test", "media"]), metadata_directory: Path.join([System.tmp_dir!(), "test", "metadata"]), + extras_directory: Path.join([System.tmp_dir!(), "test", "extras"]), tmpfile_directory: Path.join([System.tmp_dir!(), "test", "tmpfiles"]), file_watcher_poll_interval: 50 diff --git a/lib/pinchflat/media/media.ex b/lib/pinchflat/media/media.ex index e1d8d7d..abe2a0d 100644 --- a/lib/pinchflat/media/media.ex +++ b/lib/pinchflat/media/media.ex @@ -66,6 +66,16 @@ defmodule Pinchflat.Media do |> Repo.all() end + @doc """ + Returns all media_items for a given source_id, without any pending/upgradeable filtering. + Used by the API to list all media items associated with a source. + + Returns [%MediaItem{}, ...]. + """ + def list_media_items_for_source(source_id) do + Repo.all(from mi in MediaItem, where: mi.source_id == ^source_id) + end + @doc """ For a given media_item, tells you if it is pending download. This is defined as the media_item satisfying `MediaQuery.pending` which you should really check out. @@ -110,6 +120,7 @@ defmodule Pinchflat.Media do Returns %MediaItem{}. Raises `Ecto.NoResultsError` if the Media item does not exist. """ def get_media_item!(id), do: Repo.get!(MediaItem, id) + def get_media_item(id), do: Repo.get(MediaItem, id) @doc """ Creates a media_item. diff --git a/lib/pinchflat/sources/sources.ex b/lib/pinchflat/sources/sources.ex index edd37f1..815cdb6 100644 --- a/lib/pinchflat/sources/sources.ex +++ b/lib/pinchflat/sources/sources.ex @@ -67,6 +67,7 @@ defmodule Pinchflat.Sources do Returns %Source{}. Raises `Ecto.NoResultsError` if the Source does not exist. """ def get_source!(id), do: Repo.get!(Source, id) + def get_source(id), do: Repo.get(Source, id) @doc """ Creates a source. May attempt to pull additional source details from the diff --git a/lib/pinchflat/tasks/task.ex b/lib/pinchflat/tasks/task.ex index d498651..bb0d99a 100644 --- a/lib/pinchflat/tasks/task.ex +++ b/lib/pinchflat/tasks/task.ex @@ -6,7 +6,6 @@ defmodule Pinchflat.Tasks.Task do use Ecto.Schema import Ecto.Changeset - alias Pinchflat.Repo alias Pinchflat.Media.MediaItem alias Pinchflat.Sources.Source @@ -34,12 +33,22 @@ end defimpl Jason.Encoder, for: Pinchflat.Tasks.Task do def encode(value, opts) do value - |> Repo.preload(:job) |> Map.drop(Pinchflat.Tasks.Task.json_excluded_fields()) |> Map.update!(:job, fn nil -> nil + %Ecto.Association.NotLoaded{} -> nil job -> Map.drop(job, [:__meta__, :__struct__, :args, :meta, :worker, :queue]) end) + |> Map.update!(:source, fn + nil -> nil + %Ecto.Association.NotLoaded{} -> nil + source -> source + end) + |> Map.update!(:media_item, fn + nil -> nil + %Ecto.Association.NotLoaded{} -> nil + item -> item + end) |> Jason.Encode.map(opts) end end diff --git a/lib/pinchflat_web/controllers/api/v1/api_media_item_controller.ex b/lib/pinchflat_web/controllers/api/v1/api_media_item_controller.ex index 11e5caa..0d8fa27 100644 --- a/lib/pinchflat_web/controllers/api/v1/api_media_item_controller.ex +++ b/lib/pinchflat_web/controllers/api/v1/api_media_item_controller.ex @@ -5,6 +5,8 @@ defmodule PinchflatWeb.Api.V1.ApiMediaItemController do use PinchflatWeb, :controller + action_fallback PinchflatWeb.Api.V1.FallbackController + alias Pinchflat.Repo alias Pinchflat.Media alias Pinchflat.Media.MediaItem @@ -14,8 +16,7 @@ defmodule PinchflatWeb.Api.V1.ApiMediaItemController do media_items = case params do %{"source_id" => source_id} -> - source = Pinchflat.Sources.get_source!(source_id) - Media.list_pending_media_items_for(source) + Media.list_media_items_for_source(source_id) _ -> Media.list_media_items() @@ -26,12 +27,14 @@ defmodule PinchflatWeb.Api.V1.ApiMediaItemController do end def show(conn, %{"id" => id}) do - media_item = - id - |> Media.get_media_item!() - |> Repo.preload([:source, tasks: [:job]]) + case Media.get_media_item(id) do + nil -> + {:error, :not_found} - json(conn, %{data: media_item}) + media_item -> + media_item = Repo.preload(media_item, [:source, tasks: [:job]]) + json(conn, %{data: media_item}) + end end def search(conn, %{"q" => query}) do diff --git a/lib/pinchflat_web/controllers/api/v1/api_source_controller.ex b/lib/pinchflat_web/controllers/api/v1/api_source_controller.ex index b55cfeb..26fed37 100644 --- a/lib/pinchflat_web/controllers/api/v1/api_source_controller.ex +++ b/lib/pinchflat_web/controllers/api/v1/api_source_controller.ex @@ -5,6 +5,8 @@ defmodule PinchflatWeb.Api.V1.ApiSourceController do use PinchflatWeb, :controller + action_fallback PinchflatWeb.Api.V1.FallbackController + alias Pinchflat.Repo alias Pinchflat.Sources alias Pinchflat.Sources.Source @@ -21,14 +23,20 @@ defmodule PinchflatWeb.Api.V1.ApiSourceController do end def show(conn, %{"id" => id}) do - source = Sources.get_source!(id) |> Repo.preload([:media_profile, :media_items]) + case Sources.get_source(id) do + nil -> + {:error, :not_found} - pending_tasks = - source - |> Tasks.list_tasks_for(nil, [:executing, :available, :scheduled, :retryable]) - |> Repo.preload(:job) + source -> + source = Repo.preload(source, [:media_profile, :media_items]) - json(conn, %{data: source, pending_tasks: pending_tasks}) + pending_tasks = + source + |> Tasks.list_tasks_for(nil, [:executing, :available, :scheduled, :retryable]) + |> Repo.preload(:job) + + json(conn, %{data: source, pending_tasks: pending_tasks}) + end end def create(conn, %{"source" => source_params}) do diff --git a/lib/pinchflat_web/controllers/api/v1/api_task_controller.ex b/lib/pinchflat_web/controllers/api/v1/api_task_controller.ex index 06d1fe3..7c9b1eb 100644 --- a/lib/pinchflat_web/controllers/api/v1/api_task_controller.ex +++ b/lib/pinchflat_web/controllers/api/v1/api_task_controller.ex @@ -5,6 +5,8 @@ defmodule PinchflatWeb.Api.V1.ApiTaskController do use PinchflatWeb, :controller + action_fallback PinchflatWeb.Api.V1.FallbackController + alias Pinchflat.Repo alias Pinchflat.Tasks diff --git a/lib/pinchflat_web/controllers/api/v1/fallback_controller.ex b/lib/pinchflat_web/controllers/api/v1/fallback_controller.ex new file mode 100644 index 0000000..e8e5f41 --- /dev/null +++ b/lib/pinchflat_web/controllers/api/v1/fallback_controller.ex @@ -0,0 +1,18 @@ +defmodule PinchflatWeb.Api.V1.FallbackController do + @moduledoc """ + Translates controller failures into JSON error responses. + """ + use PinchflatWeb, :controller + + def call(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> json(%{errors: %{detail: "Not found"}}) + end + + def call(conn, %Ecto.NoResultsError{} = _error) do + conn + |> put_status(:not_found) + |> json(%{errors: %{detail: "Not found"}}) + end +end \ No newline at end of file