Add JSON REST API for MCP integration
Build, Lint, and Test / Build, Lint, and Test (push) Failing after 13m0s
Build, Lint, and Test / Build, Lint, and Test (push) Failing after 13m0s
- 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
This commit is contained in:
@@ -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 <token>
|
||||
"""
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user