Compare commits

...

29 Commits

Author SHA1 Message Date
hermes-agent 544c6ffc61 flake: use self.outPath instead of fetchFromGitea with hardcoded rev
Nix Flake Check / Nix Flake Check (push) Successful in 1m29s
Build and Test / Build and Test (push) Successful in 7m49s
The overlay was pinning a specific commit via fetchFromGitea, requiring
manual hash updates every time the fork changed. Now uses self.outPath
which points to the flake's own source — the consuming repo's flake.lock
tracks the rev and hash automatically.
2026-07-12 09:38:57 +00:00
hermes-agent 4734e94f26 flake: update overlay rev to pagination fix (need correct hash)
Nix Flake Check / Nix Flake Check (push) Failing after 1m22s
Build and Test / Build and Test (push) Successful in 8m9s
2026-07-11 18:56:55 +00:00
hermes-agent a3b75771e1 fix: split long from() query to satisfy mix formatter
Nix Flake Check / Nix Flake Check (push) Successful in 1m28s
Build and Test / Build and Test (push) Successful in 7m31s
2026-07-09 08:25:04 +00:00
hermes-agent 5667ea67c3 fix: format Elixir code to pass mix formatter check
Nix Flake Check / Nix Flake Check (push) Successful in 1m33s
Build and Test / Build and Test (push) Failing after 7m46s
2026-07-09 06:43:19 +00:00
hermes-agent a2da433cdc api: add pagination (limit/offset) and downloaded filter to media endpoint
Nix Flake Check / Nix Flake Check (push) Successful in 1m34s
Build and Test / Build and Test (push) Failing after 7m49s
The /api/v1/media endpoint was loading ALL media items from the database
with no pagination, causing timeouts with large libraries (1800+ items
per source). Now supports:
- ?limit=N (default 50)
- ?offset=N (default 0)
- ?downloaded=true (filter to only downloaded media)
- Ordered by uploaded_at desc
2026-07-08 21:54:39 +00:00
hermes-agent 75fb7dd9d8 Update flake.nix rev/hash to include copy button fix
Nix Flake Check / Nix Flake Check (push) Successful in 1m29s
Build and Test / Build and Test (push) Successful in 7m30s
Previous rev (5280baf) was the commit before the fix. Now points
to fc39a7d which has the data-token attribute fix.
2026-07-04 21:45:07 +00:00
hermes-agent fc39a7df6c Fix: copy button was storing literal template string
Nix Flake Check / Nix Flake Check (push) Successful in 1m30s
Build and Test / Build and Test (push) Successful in 7m42s
HEEX doesn't interpolate {} inside quoted attribute values.
Use data-token attribute + .dataset.token instead.
2026-07-04 18:06:54 +00:00
hermes-agent 049773ab34 Update flake rev/hash for reveal/copy token UI
Build and Test / Build and Test (push) Failing after 6m3s
Nix Flake Check / Nix Flake Check (push) Successful in 1m30s
2026-07-04 17:39:04 +00:00
hermes-agent 5280baf33d Add reveal/copy buttons for API token in settings UI
Nix Flake Check / Nix Flake Check (push) Successful in 1m51s
Build and Test / Build and Test (push) Successful in 8m30s
The token was only shown masked (first 12 chars + bullets) with no
way to see or copy the full value. Added:
- Show/Hide toggle to reveal the full token
- Copy button that writes the token to clipboard
Both use Alpine.js (already used in the template).
2026-07-04 14:02:32 +00:00
hermes-agent 0e0fd20da7 Update flake.nix rev and hash for mandatory API auth commit
Build and Test / Build and Test (push) Failing after 5m53s
Nix Flake Check / Nix Flake Check (push) Successful in 1m29s
2026-07-04 11:52:33 +00:00
hermes-agent 75f75d8d19 Make API auth mandatory, manage tokens via web UI
Nix Flake Check / Nix Flake Check (push) Successful in 1m55s
Build and Test / Build and Test (push) Successful in 8m25s
BREAKING CHANGE: API authentication is now always required. The
PINCHFLAT_API_TOKEN env var is no longer used. Instead, tokens are
stored in the database and managed via Settings → API Access.

Changes:
- Add api_token column to settings table (migration)
- ApiAuthPlug reads from DB; returns 401 if no token configured
- Add API Access section to Settings page with generate/regenerate/revoke
- Add POST /settings/generate_api_token and /settings/revoke_api_token
- Remove api_token from config.exs and runtime.exs
- Update all API controller tests to set auth token in setup
- Update auth plug tests for mandatory authentication
- 1008 tests pass, zero warnings
2026-07-04 11:51:40 +00:00
hermes-agent 7ec48045ce Add Nix flake CI: eval check + hash verification
Build and Test / Build and Test (push) Successful in 2m6s
Nix Flake Check / Nix Flake Check (push) Successful in 1m32s
Checks that the flake evaluates, and verifies the source, mixFodDeps,
and yarnOfflineCache hashes are correct. Does not do a full nix build
(which would be too slow for CI) — the hash checks catch the most
common issues before merging to config-nixos.
2026-07-04 10:58:26 +00:00
hermes-agent 254b712747 Add flake.nix exporting overlay for custom pinchflat package
Build and Test / Build and Test (push) Failing after 6m55s
The overlay overrides pkgs.pinchflat to build from this fork (with REST
API) instead of upstream kieraneglin/pinchflat. Consumed by config-nixos
as a flake input: pinchflat.overlays.default
2026-07-04 10:54:44 +00:00
hermes-agent 8e0ec4b8b9 Format ci.yml and API.md with prettier
Build and Test / Build and Test (push) Successful in 8m8s
2026-07-04 10:12:11 +00:00
hermes-agent 517f3f0329 CI: use docker compose build --no-cache to prevent stale images
Build and Test / Build and Test (push) Failing after 7m34s
Replace build-push-action with docker compose build --no-cache to
ensure the image always contains the latest code. Remove buildx action
since we don't need it anymore.
2026-07-04 10:03:31 +00:00
hermes-agent 1d3bb1919b Disable Docker GHA cache to fix stale image builds in Gitea Actions
Build, Lint, and Test / Build, Lint, and Test (push) Failing after 9m0s
The type=gha cache was serving stale COPY layers, so the Docker image
contained old code even after pushes. Removing cache-from/cache-to
forces a fresh build every time.
2026-07-04 09:52:27 +00:00
hermes-agent 5647e5642a Fix CI: remove unused aliases, run mix format, install root yarn deps
Build, Lint, and Test / Build, Lint, and Test (push) Failing after 1m21s
- Remove unused aliases that caused warnings-as-errors in EX_CHECK mode
- Run mix format to fix formatter check
- Add root-level yarn install to CI for prettier (used by mix check)
- All 1007 tests pass, zero warnings
2026-07-04 09:48:47 +00:00
hermes-agent a891cb8902 Fix CI for Gitea Actions: remove volume mount, run all commands in container
Build, Lint, and Test / Build, Lint, and Test (push) Failing after 1m36s
The docker-compose.ci.yml mounted '.:/app' which works on GitHub Actions
(where the workspace is the repo root) but not on Gitea Actions (where
the workspace path differs). The built Docker image already has all files
from COPY . ./ so the volume mount is unnecessary for CI.

Also moved yarn install inside the container and run full test suite.
2026-07-04 09:42:10 +00:00
hermes-agent d111a3ffd5 Merge pull request 'Add JSON REST API for MCP integration' (#1) from feature/api into master
Build, Lint, and Test / Build, Lint, and Test (push) Failing after 1m2s
Merge feature/api: Add REST API with Bearer token auth, fix all test failures
2026-07-04 11:36:34 +02:00
hermes-agent 0cf96f88be Fix API controller tests: 404 handling, JSON encoding, media filtering
Build, Lint, and Test / Build, Lint, and Test (pull_request) Failing after 1m25s
Build, Lint, and Test / Build, Lint, and Test (push) Failing after 1m26s
- 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
2026-07-04 09:35:17 +00:00
hermes-agent 51f93c521e Fix CI branch names: master instead of main
Build, Lint, and Test / Build, Lint, and Test (push) Failing after 12m23s
Build, Lint, and Test / Build, Lint, and Test (pull_request) Failing after 1m16s
2026-07-03 23:23:23 +00:00
hermes-agent 172c1ff264 Add JSON REST API for MCP integration
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
2026-07-03 23:22:24 +00:00
Felix Jancso-Szabo 67d8bd5598 Fix spelling of 'Unaffected' in help texts (#838)
Noticed this while setting up pinchflat, figured I'd submit a fix.
2025-12-16 09:30:00 -08:00
Daniel Da Cunha 6cb715c1d0 Move Active Tasks to tab in Media History section (#836)
Consolidate the home page UI by moving Active Tasks from a separate
section into a third tab alongside Downloaded and Pending tabs.

Co-authored-by: Daniel <ddacunha@MacBook-Pro-14.local>
2025-12-16 09:29:32 -08:00
Edward Horsey d38c26f6fd Enable overflow scroll for tables inside tabs (#822) 2025-12-16 09:28:51 -08:00
Googleplex 163e8eb8cc Update selfhosted.Dockerfile (#802) 2025-09-27 21:31:17 -07:00
Kieran Eglin 0688e880f5 Added yt=dlp for arm64 2025-09-27 10:20:33 -07:00
Kieran Eglin 4f8cba3f9c Bumped version 2025-09-26 16:04:56 -07:00
Kieran 2a371c4419 [Dev] Add Deno to Dockerfiles (#801)
* Added Deno to Dockerfiles

* Updated yt-dlp source

* Added unzip

* Update deno directory

* The ACTUAL deno install path this time. Christ, am I new here?

* Linting
2025-09-26 16:04:22 -07:00
35 changed files with 1512 additions and 24 deletions
+47
View File
@@ -0,0 +1,47 @@
name: Build and Test
on:
push:
branches:
- master
- feature/*
pull_request:
branches:
- master
workflow_dispatch:
jobs:
build-and-test:
name: Build 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
DOCKER_BUILDKIT: 1
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build and run Docker image
run: |
docker compose build --no-cache
docker compose up --detach
- name: Install deps
run: |
docker compose exec -T phx mix deps.get
docker compose exec -T phx yarn install
docker compose exec -T phx sh -c "cd assets && yarn install"
- 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 tests
run: docker compose exec -T phx mix test --trace
+38
View File
@@ -0,0 +1,38 @@
name: Nix Flake Check
on:
push:
branches:
- master
- feature/*
pull_request:
branches:
- master
workflow_dispatch:
jobs:
flake-check:
name: Nix Flake Check
runs-on: ubuntu-latest
if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v27
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Check flake evaluates
run: nix flake check --no-build
- name: Verify source hash
run: nix build .#pinchflat.src --no-link --print-out-paths
- name: Verify mix deps hash
run: nix build .#pinchflat.mixFodDeps --no-link --print-out-paths
- name: Verify yarn cache hash
run: nix build .#pinchflat.yarnOfflineCache --no-link --print-out-paths
+1
View File
@@ -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
-5
View File
@@ -5,11 +5,6 @@ services:
dockerfile: ./docker/dev.Dockerfile
environment:
- MIX_ENV=test
volumes:
- '.:/app'
# These lines ensure the deps can be saved as build artifacts for caching
- '/app/deps'
- '/app/_build'
ports:
- '4008:4008'
command: tail -F /dev/null
+8 -2
View File
@@ -13,7 +13,7 @@ RUN echo "Building for ${TARGETPLATFORM:?}"
RUN apt-get update -qq && \
apt-get install -y inotify-tools curl git openssh-client jq \
python3 python3-setuptools python3-wheel python3-dev pipx \
python3-mutagen locales procps build-essential graphviz zsh
python3-mutagen locales procps build-essential graphviz zsh unzip
# Install ffmpeg
RUN export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
@@ -32,8 +32,14 @@ RUN curl -sL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh && \
# Install baseline Elixir packages
mix local.hex --force && \
mix local.rebar --force && \
# Install Deno - required for YouTube downloads (See yt-dlp#14404)
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y --no-modify-path && \
# Download and update YT-DLP
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
export YT_DLP_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
"linux/amd64") echo "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux" ;; \
"linux/arm64") echo "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux_aarch64" ;; \
*) echo "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux" ;; esac) && \
curl -L ${YT_DLP_DOWNLOAD} -o /usr/local/bin/yt-dlp && \
chmod a+rx /usr/local/bin/yt-dlp && \
yt-dlp -U && \
# Install Apprise
+10 -1
View File
@@ -73,6 +73,7 @@ RUN mix release
FROM ${RUNNER_IMAGE}
ARG TARGETPLATFORM
ARG PORT=8945
COPY --from=builder ./usr/local/bin/ffmpeg /usr/bin/ffmpeg
@@ -94,13 +95,21 @@ RUN apt-get update -y && \
python3 \
pipx \
jq \
# unzip is needed for Deno
unzip \
procps && \
# Install Deno - required for YouTube downloads (See yt-dlp#14404)
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y --no-modify-path && \
# Apprise
export PIPX_HOME=/opt/pipx && \
export PIPX_BIN_DIR=/usr/local/bin && \
pipx install apprise && \
# yt-dlp
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
export YT_DLP_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
"linux/amd64") echo "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux" ;; \
"linux/arm64") echo "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux_aarch64" ;; \
*) echo "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux" ;; esac) && \
curl -L ${YT_DLP_DOWNLOAD} -o /usr/local/bin/yt-dlp && \
chmod a+rx /usr/local/bin/yt-dlp && \
yt-dlp -U && \
# Set the locale
+103
View File
@@ -0,0 +1,103 @@
# 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 always requires a Bearer token — there is no open-access mode.
Generate a token from the web UI under **Settings → API Access**:
1. Navigate to Settings
2. Click "Generate API Token"
3. Copy the token (it won't be shown in full again)
Clients must send the token in the `Authorization` header:
```
Authorization: Bearer *** to regenerate or revoke the token at any time.
```
## 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"
}
}'
```
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1782959384,
"narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+57
View File
@@ -0,0 +1,57 @@
{
description = "Pinchflat fork with JSON REST API for MCP integration";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{ self, nixpkgs, ... }:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
in
{
overlays = {
default = final: prev: {
pinchflat = prev.pinchflat.overrideAttrs (
old: {
# Use the flake's own source — rev and hash are tracked
# by flake.lock in the consuming repo, no manual updates needed
src = self.outPath;
# Deps hashes are identical to upstream — mix.lock and yarn.lock
# are unchanged in this fork. If they drift, nix build will
# print the correct hash to use.
mixFodDeps = old.mixFodDeps.overrideAttrs (_: {
hash = "sha256-7zLlOzBJcvookYX/4SNC0O1Yr62LIKH9R8rONl3diSs=";
});
yarnOfflineCache = old.yarnOfflineCache.overrideAttrs (_: {
sha256 = "sha256-xJL+qcohtu+OmZ31E1QU9uqBWAFGejKIO3XRd+R6z/4=";
});
}
);
};
};
packages = forAllSystems (
system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ self.overlays.default ];
};
in
{
default = pkgs.pinchflat;
pinchflat = pkgs.pinchflat;
}
);
};
}
+46 -2
View File
@@ -23,8 +23,22 @@ defmodule Pinchflat.Media do
Returns [%MediaItem{}, ...].
"""
def list_media_items do
Repo.all(MediaItem)
def list_media_items(opts \\ []) do
limit = Keyword.get(opts, :limit, 50)
offset = Keyword.get(opts, :offset, 0)
downloaded_only = Keyword.get(opts, :downloaded_only, false)
query =
from(mi in MediaItem, limit: ^limit, offset: ^offset, order_by: [desc: mi.uploaded_at])
query =
if downloaded_only do
from(mi in query, where: not is_nil(mi.media_downloaded_at))
else
query
end
Repo.all(query)
end
@doc """
@@ -66,6 +80,35 @@ 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, opts \\ []) do
limit = Keyword.get(opts, :limit, 50)
offset = Keyword.get(opts, :offset, 0)
downloaded_only = Keyword.get(opts, :downloaded_only, false)
query =
from(mi in MediaItem,
where: mi.source_id == ^source_id,
limit: ^limit,
offset: ^offset,
order_by: [desc: mi.uploaded_at]
)
query =
if downloaded_only do
from(mi in query, where: not is_nil(mi.media_downloaded_at))
else
query
end
Repo.all(query)
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 +153,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.
+3 -1
View File
@@ -17,7 +17,8 @@ defmodule Pinchflat.Settings.Setting do
:youtube_api_key,
:extractor_sleep_interval_seconds,
:download_throughput_limit,
:restrict_filenames
:restrict_filenames,
:api_token
]
@required_fields [
@@ -40,6 +41,7 @@ defmodule Pinchflat.Settings.Setting do
# This is a string because it accepts values like "100K" or "4.2M"
field :download_throughput_limit, :string
field :restrict_filenames, :boolean, default: false
field :api_token, :string
field :video_codec_preference, :string
field :audio_codec_preference, :string
+1
View File
@@ -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
+28
View File
@@ -23,4 +23,32 @@ 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
|> 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
+49
View File
@@ -0,0 +1,49 @@
defmodule PinchflatWeb.ApiAuthPlug do
@moduledoc """
Authenticates API requests using a Bearer token.
The token is stored in the settings database and managed via the web UI.
A token must be generated before API access is possible — the API is
always authenticated, there is no open-access mode.
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 = Settings.get!(: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 has been generated yet — deny all access
send_unauthorized(conn, "API token not configured. Generate one in Settings.")
end
end
defp credential_set?(credential) do
credential && credential != ""
end
defp send_unauthorized(conn, message \\ "Unauthorized") do
conn
|> put_resp_content_type("application/json")
|> send_resp(401, ~s({"errors":{"detail":"#{message}"}}))
|> halt()
end
end
@@ -41,7 +41,7 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
{render_slot(@tab_append)}
</div>
</header>
<div class="mt-4 min-h-60">
<div class="mt-4 min-h-60 overflow-x-auto">
<div :for={tab <- @tab} x-show={"openTab === '#{tab.id}'"} class="font-medium leading-relaxed">
{render_slot(tab)}
</div>
@@ -0,0 +1,113 @@
defmodule PinchflatWeb.Api.V1.ApiMediaItemController do
@moduledoc """
JSON API controller for MediaItems.
"""
use PinchflatWeb, :controller
action_fallback PinchflatWeb.Api.V1.FallbackController
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Downloading.MediaDownloadWorker
def index(conn, params) do
limit = parse_param(params, "limit", 50)
offset = parse_param(params, "offset", 0)
downloaded_only = params["downloaded"] == "true"
media_items =
case params do
%{"source_id" => source_id} ->
Media.list_media_items_for_source(source_id, limit: limit, offset: offset, downloaded_only: downloaded_only)
_ ->
Media.list_media_items(limit: limit, offset: offset, downloaded_only: downloaded_only)
end
media_items = Repo.preload(media_items, :source)
json(conn, %{data: media_items})
end
def show(conn, %{"id" => id}) do
case Media.get_media_item(id) do
nil ->
{:error, :not_found}
media_item ->
media_item = Repo.preload(media_item, [:source, tasks: [:job]])
json(conn, %{data: media_item})
end
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
defp parse_param(params, key, default) do
case Map.get(params, key) do
nil ->
default
value ->
case Integer.parse(value) do
{n, _} -> n
:error -> default
end
end
end
end
@@ -0,0 +1,67 @@
defmodule PinchflatWeb.Api.V1.ApiMediaProfileController do
@moduledoc """
JSON API controller for MediaProfiles.
"""
use PinchflatWeb, :controller
alias Pinchflat.Profiles
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,126 @@
defmodule PinchflatWeb.Api.V1.ApiSourceController do
@moduledoc """
JSON API controller for Sources.
"""
use PinchflatWeb, :controller
action_fallback PinchflatWeb.Api.V1.FallbackController
alias Pinchflat.Repo
alias Pinchflat.Sources
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
case Sources.get_source(id) do
nil ->
{:error, :not_found}
source ->
source = Repo.preload(source, [: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
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,34 @@
defmodule PinchflatWeb.Api.V1.ApiTaskController do
@moduledoc """
JSON API controller for Tasks.
"""
use PinchflatWeb, :controller
action_fallback PinchflatWeb.Api.V1.FallbackController
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
@@ -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
@@ -100,7 +100,7 @@
field={f[:embed_subs]}
type="toggle"
label="Embed Subtitles"
help="Downloads and embeds subtitles in the media file itself, if supported. Uneffected by 'Download Subtitles'"
help="Downloads and embeds subtitles in the media file itself, if supported. Unaffected by 'Download Subtitles'"
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
@@ -154,7 +154,7 @@
field={f[:embed_thumbnail]}
type="toggle"
label="Embed Thumbnail"
help="Downloads and embeds thumbnail in the media file itself, if supported. Uneffected by 'Download Thumbnail' (recommended)"
help="Downloads and embeds thumbnail in the media file itself, if supported. Unaffected by 'Download Thumbnail' (recommended)"
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
@@ -178,7 +178,7 @@
field={f[:embed_metadata]}
type="toggle"
label="Embed Metadata"
help="Downloads and embeds metadata in the media file itself, if supported. Uneffected by 'Download Metadata' (recommended)"
help="Downloads and embeds metadata in the media file itself, if supported. Unaffected by 'Download Metadata' (recommended)"
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
@@ -56,12 +56,8 @@
session: %{"media_state" => "pending"}
)}
</:tab>
<:tab title="Active Tasks" id="active-tasks">
{live_render(@conn, Pinchflat.Pages.JobTableLive)}
</:tab>
</.tabbed_layout>
</div>
<div class="rounded-sm border shadow-default border-strokedark bg-boxdark mt-4 p-5">
<span class="text-2xl font-medium mb-4">Active Tasks</span>
<section class="mt-6 min-h-80">
{live_render(@conn, Pinchflat.Pages.JobTableLive)}
</section>
</div>
@@ -28,6 +28,36 @@ defmodule PinchflatWeb.Settings.SettingController do
render(conn, "app_info.html")
end
def generate_api_token(conn, _params) do
token = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
case Settings.set(api_token: token) do
{:ok, _} ->
conn
|> put_flash(:info, "API token generated successfully.")
|> redirect(to: ~p"/settings")
{:error, _} ->
conn
|> put_flash(:error, "Failed to generate API token.")
|> redirect(to: ~p"/settings")
end
end
def revoke_api_token(conn, _params) do
case Settings.set(api_token: nil) do
{:ok, _} ->
conn
|> put_flash(:info, "API token revoked.")
|> redirect(to: ~p"/settings")
{:error, _} ->
conn
|> put_flash(:error, "Failed to revoke API token.")
|> redirect(to: ~p"/settings")
end
end
def download_logs(conn, _params) do
log_path = Application.get_env(:pinchflat, :log_path)
@@ -26,6 +26,59 @@
)}
</section>
<section class="mt-8">
<h3 class="text-2xl text-black dark:text-white">
API Access
</h3>
<p class="text-sm mt-2 max-w-prose text-bodydark2">
Generate a token to authenticate requests to the <code class="font-mono">/api/v1</code> REST API.
Required for MCP server integration and other programmatic clients.
Send it as <code class="font-mono">Authorization: Bearer &lt;token&gt;</code>.
</p>
<div class="mt-4" x-data="{ revealed: false, copied: false }">
<%= if Settings.get!(:api_token) do %>
<% token = Settings.get!(:api_token) %>
<div class="flex items-center gap-3 flex-wrap">
<code class="font-mono text-sm bg-meta-2 dark:bg-meta-4 px-3 py-2 rounded break-all">
<span x-show="!revealed">{String.slice(token, 0, 12)}••••••••</span>
<span x-show="revealed" x-cloak>{token}</span>
</code>
<button type="button" class="text-sm underline" @click="revealed = !revealed">
<span x-show="!revealed">Show</span>
<span x-show="revealed" x-cloak>Hide</span>
</button>
<button
type="button"
class="text-sm underline"
data-token={token}
@click="navigator.clipboard.writeText($el.dataset.token).then(() => { copied = true; setTimeout(() => copied = false, 2000) })"
>
<span x-show="!copied">Copy</span>
<span x-show="copied" x-cloak>Copied!</span>
</button>
<.link href={~p"/settings/generate_api_token"} method="post">
<.button rounding="rounded-lg" class="bg-warning hover:bg-warning/80">Regenerate Token</.button>
</.link>
<.link
href={~p"/settings/revoke_api_token"}
method="post"
data-confirm="Are you sure? This will immediately disable all API access."
>
<.button rounding="rounded-lg" class="bg-danger hover:bg-danger/80">Revoke Token</.button>
</.link>
</div>
<% else %>
<p class="text-sm text-danger mb-3">
No API token configured. The API is currently inaccessible.
</p>
<.link href={~p"/settings/generate_api_token"} method="post">
<.button rounding="rounded-lg" class="bg-primary hover:bg-primary/80">Generate API Token</.button>
</.link>
<% end %>
</div>
</section>
<section class="mt-8">
<section>
<h3 class="text-2xl text-black dark:text-white">
@@ -23,7 +23,7 @@ defmodule PinchflatWeb.Sources.MediaItemTableLive do
<header class="flex justify-between items-center mb-4">
<span class="flex items-center">
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" tooltip="Refresh" />
<span class="ml-2">
<span class="mx-2">
Showing <.localized_number number={length(@records)} /> of <.localized_number number={@filtered_record_count} />
</span>
</span>
+48
View File
@@ -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]
@@ -48,6 +52,8 @@ defmodule PinchflatWeb.Router do
resources "/search", Searches.SearchController, only: [:show], singleton: true
resources "/settings", Settings.SettingController, only: [:show, :update], singleton: true
post "/settings/generate_api_token", Settings.SettingController, :generate_api_token
post "/settings/revoke_api_token", Settings.SettingController, :revoke_api_token
get "/app_info", Settings.SettingController, :app_info
get "/download_logs", Settings.SettingController, :download_logs
@@ -71,6 +77,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
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do
[
app: :pinchflat,
version: "2025.6.6",
version: "2025.9.26",
elixir: "~> 1.17",
elixirc_paths: elixirc_paths(Mix.env()),
elixirc_options: [warnings_as_errors: System.get_env("EX_CHECK") == "1"],
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddApiTokenToSettings do
use Ecto.Migration
def change do
alter table(:settings) do
add :api_token, :string
end
end
end
+75
View File
@@ -0,0 +1,75 @@
defmodule PinchflatWeb.ApiAuthPlugTest do
use PinchflatWeb.ConnCase
import Pinchflat.ProfilesFixtures
alias Pinchflat.Settings
setup do
# Ensure api_token is cleared in DB for each test
Settings.set(api_token: nil)
:ok
end
describe "when no api_token is configured" do
test "returns 401 without Authorization header", %{conn: conn} do
conn = get(conn, ~p"/api/v1/media_profiles")
assert %{status: 401} = conn
end
test "returns 401 even with a Bearer token", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer some-token")
|> get(~p"/api/v1/media_profiles")
assert %{status: 401} = conn
end
end
describe "when api_token is configured" do
setup do
Settings.set(api_token: "secret-token-123")
: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
@@ -0,0 +1,90 @@
defmodule PinchflatWeb.Api.V1.ApiMediaItemControllerTest do
use PinchflatWeb.ConnCase
setup do
Pinchflat.Settings.set(api_token: "test-token")
conn =
build_conn()
|> put_req_header("authorization", "Bearer test-token")
{:ok, conn: conn}
end
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
@@ -0,0 +1,56 @@
defmodule PinchflatWeb.Api.V1.ApiMediaProfileControllerTest do
use PinchflatWeb.ConnCase
setup do
Pinchflat.Settings.set(api_token: "test-token")
conn =
build_conn()
|> put_req_header("authorization", "Bearer test-token")
{:ok, conn: conn}
end
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
@@ -0,0 +1,55 @@
defmodule PinchflatWeb.Api.V1.ApiSettingControllerTest do
use PinchflatWeb.ConnCase
setup do
Pinchflat.Settings.set(api_token: "test-token")
conn =
build_conn()
|> put_req_header("authorization", "Bearer test-token")
{:ok, conn: conn}
end
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
@@ -0,0 +1,197 @@
defmodule PinchflatWeb.Api.V1.ApiSourceControllerTest do
use PinchflatWeb.ConnCase
setup do
Pinchflat.Settings.set(api_token: "test-token")
conn =
build_conn()
|> put_req_header("authorization", "Bearer test-token")
{:ok, conn: conn}
end
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
@@ -0,0 +1,58 @@
defmodule PinchflatWeb.Api.V1.ApiTaskControllerTest do
use PinchflatWeb.ConnCase
setup do
Pinchflat.Settings.set(api_token: "test-token")
conn =
build_conn()
|> put_req_header("authorization", "Bearer test-token")
{:ok, conn: conn}
end
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