Compare commits

...

39 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
Kieran Eglin 076f2fe78b Version bump 2025-06-06 14:39:32 -07:00
Kieran 68da8bc522 [Housekeeping] Dependency updates 6-Jun-2025 (#733)
* Bumped Elixir

* Silenced mix check warnings

* Updated all deps with minor version upgrades

* Updated more deps; Refactored text components to work with phoenix_html updates
2025-06-06 13:44:14 -07:00
Kieran 1cee7a19ee Made source sorting case-insensitive (#708) 2025-04-28 11:43:51 -07:00
Kieran a55f17ac5f Update the link (#697) 2025-04-10 09:39:37 -07:00
Brandon Philips f637bbd195 [Docs] Add podman to README (#686)
* README: add podman

Docker always has a tendency to get in my way on Debian. Also, I really
like the userns setup for podman for giving permissions between host and
container.

* Ran linting on README

---------

Co-authored-by: Kieran Eglin <kieran.eglin@gmail.com>
2025-04-10 09:33:34 -07:00
Kieran 7f56c0c802 Better copy (#696) 2025-04-10 09:20:56 -07:00
Kieran Eglin 6d97c8c1c4 Bumped version 2025-03-17 15:02:16 -07:00
Kieran 030f5fbdfe [Enhancement] Add setting to restrict filenames to ASCII characters (#660)
* Added a new column for restricting filenames

* Adds restrict-filenames to command runner

* Added UI to settings form
2025-03-17 14:58:25 -07:00
Kieran ee2db3e9b7 Stopped logging healthcheck requests (#659) 2025-03-17 14:48:07 -07:00
Kieran 4554648ba7 [Enhancement] Add download rate limiting to app settings (#646)
* Added rate limit column to settings

* Added limit_rate option to command runner

* Added rate limit to settings form
2025-03-11 15:45:56 -07:00
53 changed files with 1746 additions and 119 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
+17
View File
@@ -129,6 +129,23 @@ docker run \
ghcr.io/kieraneglin/pinchflat:latest ghcr.io/kieraneglin/pinchflat:latest
``` ```
### Podman
The Podman setup is similar to Docker but changes a few flags to run under a User Namespace instead of root. To run Pinchflat under Podman and use the current user's UID/GID for file access run this:
```
podman run \
--security-opt label=disable \
--userns=keep-id --user=$UID \
-e TZ=America/Los_Angeles \
-p 8945:8945 \
-v /host/path/to/config:/config:rw \
-v /host/path/to/downloads/:/downloads:rw \
ghcr.io/kieraneglin/pinchflat:latest
```
Using this setup consider creating a new `pinchflat` user and giving that user ownership to the config and download directory. See [Podman --userns](https://docs.podman.io/en/v4.6.1/markdown/options/userns.container.html) docs.
### IMPORTANT: File permissions ### IMPORTANT: File permissions
You _must_ ensure the host directories you've mounted are writable by the user running the Docker container. If you get a permission error follow the steps it suggests. See [#106](https://github.com/kieraneglin/pinchflat/issues/106) for more. You _must_ ensure the host directories you've mounted are writable by the user running the Docker container. If you get a permission error follow the steps it suggests. See [#106](https://github.com/kieraneglin/pinchflat/issues/106) for more.
+1
View File
@@ -6,6 +6,7 @@ config :pinchflat,
apprise_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]), apprise_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]),
media_directory: Path.join([System.tmp_dir!(), "test", "media"]), media_directory: Path.join([System.tmp_dir!(), "test", "media"]),
metadata_directory: Path.join([System.tmp_dir!(), "test", "metadata"]), 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"]), tmpfile_directory: Path.join([System.tmp_dir!(), "test", "tmpfiles"]),
file_watcher_poll_interval: 50 file_watcher_poll_interval: 50
-5
View File
@@ -5,11 +5,6 @@ services:
dockerfile: ./docker/dev.Dockerfile dockerfile: ./docker/dev.Dockerfile
environment: environment:
- MIX_ENV=test - MIX_ENV=test
volumes:
- '.:/app'
# These lines ensure the deps can be saved as build artifacts for caching
- '/app/deps'
- '/app/_build'
ports: ports:
- '4008:4008' - '4008:4008'
command: tail -F /dev/null command: tail -F /dev/null
+12 -5
View File
@@ -1,6 +1,7 @@
ARG ELIXIR_VERSION=1.17.0 ARG ELIXIR_VERSION=1.18.4
ARG OTP_VERSION=26.2.5 ARG OTP_VERSION=27.2.4
ARG DEBIAN_VERSION=bookworm-20240612-slim ARG DEBIAN_VERSION=bookworm-20250428-slim
ARG DEV_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" ARG DEV_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
FROM ${DEV_IMAGE} FROM ${DEV_IMAGE}
@@ -12,7 +13,7 @@ RUN echo "Building for ${TARGETPLATFORM:?}"
RUN apt-get update -qq && \ RUN apt-get update -qq && \
apt-get install -y inotify-tools curl git openssh-client jq \ apt-get install -y inotify-tools curl git openssh-client jq \
python3 python3-setuptools python3-wheel python3-dev pipx \ 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 # Install ffmpeg
RUN export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \ RUN export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
@@ -31,8 +32,14 @@ RUN curl -sL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh && \
# Install baseline Elixir packages # Install baseline Elixir packages
mix local.hex --force && \ mix local.hex --force && \
mix local.rebar --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 # 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 && \ chmod a+rx /usr/local/bin/yt-dlp && \
yt-dlp -U && \ yt-dlp -U && \
# Install Apprise # Install Apprise
+13 -4
View File
@@ -1,8 +1,8 @@
# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian
# instead of Alpine to avoid DNS resolution issues in production. # instead of Alpine to avoid DNS resolution issues in production.
ARG ELIXIR_VERSION=1.17.0 ARG ELIXIR_VERSION=1.18.4
ARG OTP_VERSION=26.2.5 ARG OTP_VERSION=27.2.4
ARG DEBIAN_VERSION=bookworm-20240612-slim ARG DEBIAN_VERSION=bookworm-20250428-slim
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
@@ -73,6 +73,7 @@ RUN mix release
FROM ${RUNNER_IMAGE} FROM ${RUNNER_IMAGE}
ARG TARGETPLATFORM
ARG PORT=8945 ARG PORT=8945
COPY --from=builder ./usr/local/bin/ffmpeg /usr/bin/ffmpeg COPY --from=builder ./usr/local/bin/ffmpeg /usr/bin/ffmpeg
@@ -94,13 +95,21 @@ RUN apt-get update -y && \
python3 \ python3 \
pipx \ pipx \
jq \ jq \
# unzip is needed for Deno
unzip \
procps && \ 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 # Apprise
export PIPX_HOME=/opt/pipx && \ export PIPX_HOME=/opt/pipx && \
export PIPX_BIN_DIR=/usr/local/bin && \ export PIPX_BIN_DIR=/usr/local/bin && \
pipx install apprise && \ pipx install apprise && \
# yt-dlp # 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 && \ chmod a+rx /usr/local/bin/yt-dlp && \
yt-dlp -U && \ yt-dlp -U && \
# Set the locale # 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{}, ...]. Returns [%MediaItem{}, ...].
""" """
def list_media_items do def list_media_items(opts \\ []) do
Repo.all(MediaItem) 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 end
@doc """ @doc """
@@ -66,6 +80,35 @@ defmodule Pinchflat.Media do
|> Repo.all() |> Repo.all()
end 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 """ @doc """
For a given media_item, tells you if it is pending download. This is defined as 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. 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. 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)
def get_media_item(id), do: Repo.get(MediaItem, id)
@doc """ @doc """
Creates a media_item. Creates a media_item.
+8 -1
View File
@@ -15,7 +15,10 @@ defmodule Pinchflat.Settings.Setting do
:video_codec_preference, :video_codec_preference,
:audio_codec_preference, :audio_codec_preference,
:youtube_api_key, :youtube_api_key,
:extractor_sleep_interval_seconds :extractor_sleep_interval_seconds,
:download_throughput_limit,
:restrict_filenames,
:api_token
] ]
@required_fields [ @required_fields [
@@ -35,6 +38,10 @@ defmodule Pinchflat.Settings.Setting do
field :youtube_api_key, :string field :youtube_api_key, :string
field :route_token, :string field :route_token, :string
field :extractor_sleep_interval_seconds, :integer, default: 0 field :extractor_sleep_interval_seconds, :integer, default: 0
# 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 :video_codec_preference, :string
field :audio_codec_preference, :string field :audio_codec_preference, :string
@@ -106,7 +106,7 @@ defmodule Pinchflat.SlowIndexing.FileFollowerServer do
{:noreply, %{state | last_activity: DateTime.utc_now()}} {:noreply, %{state | last_activity: DateTime.utc_now()}}
:eof -> :eof ->
Logger.debug("EOF reached, waiting before trying to read new lines") Logger.debug("Current batch of media processed. Will check again in #{@poll_interval_ms}ms")
Process.send_after(self(), :read_new_lines, @poll_interval_ms) Process.send_after(self(), :read_new_lines, @poll_interval_ms)
{:noreply, state} {:noreply, state}
+1
View File
@@ -67,6 +67,7 @@ defmodule Pinchflat.Sources do
Returns %Source{}. Raises `Ecto.NoResultsError` if the Source does not exist. 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)
def get_source(id), do: Repo.get(Source, id)
@doc """ @doc """
Creates a source. May attempt to pull additional source details from the 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]) |> cast(attrs, [:job_id, :source_id, :media_item_id])
|> validate_required([:job_id]) |> validate_required([:job_id])
end 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 end
+27 -15
View File
@@ -35,7 +35,7 @@ defmodule Pinchflat.YtDlp.CommandRunner do
output_filepath = generate_output_filepath(addl_opts) output_filepath = generate_output_filepath(addl_opts)
print_to_file_opts = [{:print_to_file, output_template}, output_filepath] print_to_file_opts = [{:print_to_file, output_template}, output_filepath]
user_configured_opts = cookie_file_options(addl_opts) ++ sleep_interval_opts(addl_opts) user_configured_opts = cookie_file_options(addl_opts) ++ rate_limit_options(addl_opts) ++ misc_options()
# These must stay in exactly this order, hence why I'm giving it its own variable. # These must stay in exactly this order, hence why I'm giving it its own variable.
all_opts = command_opts ++ print_to_file_opts ++ user_configured_opts ++ global_options() all_opts = command_opts ++ print_to_file_opts ++ user_configured_opts ++ global_options()
formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts) formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts)
@@ -116,20 +116,6 @@ defmodule Pinchflat.YtDlp.CommandRunner do
end end
end end
defp sleep_interval_opts(addl_opts) do
sleep_interval = Settings.get!(:extractor_sleep_interval_seconds)
if sleep_interval <= 0 || Keyword.get(addl_opts, :skip_sleep_interval) do
[]
else
[
sleep_requests: NumberUtils.add_jitter(sleep_interval),
sleep_interval: NumberUtils.add_jitter(sleep_interval),
sleep_subtitles: NumberUtils.add_jitter(sleep_interval)
]
end
end
defp add_cookie_file do defp add_cookie_file do
base_dir = Application.get_env(:pinchflat, :extras_directory) base_dir = Application.get_env(:pinchflat, :extras_directory)
filename_options_map = %{cookies: "cookies.txt"} filename_options_map = %{cookies: "cookies.txt"}
@@ -145,6 +131,32 @@ defmodule Pinchflat.YtDlp.CommandRunner do
end) end)
end end
defp rate_limit_options(addl_opts) do
throughput_limit = Settings.get!(:download_throughput_limit)
sleep_interval_opts = sleep_interval_opts(addl_opts)
throughput_option = if throughput_limit, do: [limit_rate: throughput_limit], else: []
throughput_option ++ sleep_interval_opts
end
defp sleep_interval_opts(addl_opts) do
sleep_interval = Settings.get!(:extractor_sleep_interval_seconds)
if sleep_interval <= 0 || Keyword.get(addl_opts, :skip_sleep_interval) do
[]
else
[
sleep_requests: NumberUtils.add_jitter(sleep_interval),
sleep_interval: NumberUtils.add_jitter(sleep_interval),
sleep_subtitles: NumberUtils.add_jitter(sleep_interval)
]
end
end
defp misc_options do
if Settings.get!(:restrict_filenames), do: [:restrict_filenames], else: []
end
defp backend_executable do defp backend_executable do
Application.get_env(:pinchflat, :yt_dlp_executable) Application.get_env(:pinchflat, :yt_dlp_executable)
end end
+2 -2
View File
@@ -43,7 +43,7 @@ defmodule PinchflatWeb do
layouts: [html: PinchflatWeb.Layouts] layouts: [html: PinchflatWeb.Layouts]
import Plug.Conn import Plug.Conn
import PinchflatWeb.Gettext use Gettext, backend: PinchflatWeb.Gettext
alias Pinchflat.Settings alias Pinchflat.Settings
alias PinchflatWeb.Layouts alias PinchflatWeb.Layouts
@@ -94,7 +94,7 @@ defmodule PinchflatWeb do
# HTML escaping functionality # HTML escaping functionality
import Phoenix.HTML import Phoenix.HTML
# Core UI components and translation # Core UI components and translation
import PinchflatWeb.Gettext use Gettext, backend: PinchflatWeb.Gettext
import PinchflatWeb.CoreComponents import PinchflatWeb.CoreComponents
import PinchflatWeb.CustomComponents.TabComponents import PinchflatWeb.CustomComponents.TabComponents
import PinchflatWeb.CustomComponents.TextComponents import PinchflatWeb.CustomComponents.TextComponents
+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
@@ -15,8 +15,7 @@ defmodule PinchflatWeb.CoreComponents do
Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
""" """
use Phoenix.Component, global_prefixes: ~w(x-) use Phoenix.Component, global_prefixes: ~w(x-)
use Gettext, backend: PinchflatWeb.Gettext
import PinchflatWeb.Gettext
alias Phoenix.LiveView.JS alias Phoenix.LiveView.JS
alias PinchflatWeb.CustomComponents.TextComponents alias PinchflatWeb.CustomComponents.TextComponents
@@ -41,7 +41,7 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
{render_slot(@tab_append)} {render_slot(@tab_append)}
</div> </div>
</header> </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"> <div :for={tab <- @tab} x-show={"openTab === '#{tab.id}'"} class="font-medium leading-relaxed">
{render_slot(tab)} {render_slot(tab)}
</div> </div>
@@ -71,19 +71,39 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
formatted_text = formatted_text =
Regex.split(~r{https?://\S+}, assigns.text, include_captures: true) Regex.split(~r{https?://\S+}, assigns.text, include_captures: true)
|> Enum.map(fn |> Enum.map(fn
"http" <> _ = url -> "http" <> _ = url -> {:url, url}
Phoenix.HTML.Tag.content_tag(:a, url, class: "text-blue-500 hover:text-blue-300", href: url, target: "_blank") text -> Regex.split(~r{\n}, text, include_captures: true, trim: true)
text ->
text
|> String.split("\n", trim: false)
|> Enum.intersperse(Phoenix.HTML.Tag.tag(:span, class: "inline-block mt-2"))
end) end)
assigns = Map.put(assigns, :text, formatted_text) assigns = Map.put(assigns, :text, formatted_text)
~H""" ~H"""
<span>{@text}</span> <span>
<.rendered_description_line :for={line <- @text} content={line} />
</span>
"""
end
defp rendered_description_line(%{content: {:url, url}} = assigns) do
assigns = Map.put(assigns, :url, url)
~H"""
<a href={@url} target="_blank" class="text-blue-500 hover:text-blue-300">
{@url}
</a>
"""
end
defp rendered_description_line(%{content: list_of_content} = assigns) do
assigns = Map.put(assigns, :list_of_content, list_of_content)
~H"""
<span
:for={inner_content <- @list_of_content}
class={[if(inner_content == "\n", do: "block", else: "mt-2 inline-block")]}
>
{inner_content}
</span>
""" """
end end
@@ -8,7 +8,7 @@
> >
<section> <section>
<div class="flex items-center justify-between gap-2 px-6 py-4"> <div class="flex items-center justify-between gap-2 px-6 py-4">
<a href="/" class="flex items-center"> <a href={~p"/"} class="flex items-center">
<img src={~p"/images/logo-2024-03-20.png"} alt="Pinchflat" class="w-auto" /> <img src={~p"/images/logo-2024-03-20.png"} alt="Pinchflat" class="w-auto" />
</a> </a>
@@ -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]} field={f[:embed_subs]}
type="toggle" type="toggle"
label="Embed Subtitles" 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]))" x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/> />
</section> </section>
@@ -154,7 +154,7 @@
field={f[:embed_thumbnail]} field={f[:embed_thumbnail]}
type="toggle" type="toggle"
label="Embed Thumbnail" 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]))" x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/> />
</section> </section>
@@ -178,7 +178,7 @@
field={f[:embed_metadata]} field={f[:embed_metadata]}
type="toggle" type="toggle"
label="Embed Metadata" 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]))" x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/> />
</section> </section>
@@ -56,12 +56,8 @@
session: %{"media_state" => "pending"} session: %{"media_state" => "pending"}
)} )}
</:tab> </:tab>
<:tab title="Active Tasks" id="active-tasks">
{live_render(@conn, Pinchflat.Pages.JobTableLive)}
</:tab>
</.tabbed_layout> </.tabbed_layout>
</div> </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") render(conn, "app_info.html")
end 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 def download_logs(conn, _params) do
log_path = Application.get_env(:pinchflat, :log_path) log_path = Application.get_env(:pinchflat, :log_path)
@@ -26,6 +26,59 @@
)} )}
</section> </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 class="mt-8">
<section> <section>
<h3 class="text-2xl text-black dark:text-white"> <h3 class="text-2xl text-black dark:text-white">
@@ -47,7 +100,21 @@
placeholder="0" placeholder="0"
type="number" type="number"
label="Sleep Interval (seconds)" label="Sleep Interval (seconds)"
help="Sleep interval in seconds between each extractor request. Must be a positive whole number (or set to 0 to disable)" help="Sleep interval in seconds between each extractor request. Must be a positive whole number. Set to 0 to disable"
/>
<.input
field={f[:download_throughput_limit]}
placeholder="4.2M"
label="Download Throughput"
help="Sets the max bytes-per-second throughput when downloading media. Examples: '50K' or '4.2M'. Leave blank to disable"
/>
<.input
field={f[:restrict_filenames]}
type="toggle"
label="Restrict Filenames"
help="Restrict filenames to only ASCII characters and avoid ampersands/spaces in filenames"
/> />
</section> </section>
</section> </section>
@@ -23,7 +23,7 @@ defmodule PinchflatWeb.Sources.MediaItemTableLive do
<header class="flex justify-between items-center mb-4"> <header class="flex justify-between items-center mb-4">
<span class="flex items-center"> <span class="flex items-center">
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" tooltip="Refresh" /> <.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} /> Showing <.localized_number number={length(@records)} /> of <.localized_number number={@filtered_record_count} />
</span> </span>
</span> </span>
@@ -54,8 +54,8 @@ defmodule PinchflatWeb.Sources.SourceLive.IndexTableLive do
defp sort_attr(:pending_count), do: dynamic([s, mp, dl, pe], pe.pending_count) defp sort_attr(:pending_count), do: dynamic([s, mp, dl, pe], pe.pending_count)
defp sort_attr(:downloaded_count), do: dynamic([s, mp, dl], dl.downloaded_count) defp sort_attr(:downloaded_count), do: dynamic([s, mp, dl], dl.downloaded_count)
defp sort_attr(:media_size_bytes), do: dynamic([s, mp, dl], dl.media_size_bytes) defp sort_attr(:media_size_bytes), do: dynamic([s, mp, dl], dl.media_size_bytes)
defp sort_attr(:media_profile_name), do: dynamic([s, mp], mp.name) defp sort_attr(:media_profile_name), do: dynamic([s, mp], fragment("? COLLATE NOCASE", mp.name))
defp sort_attr(:custom_name), do: dynamic([s], s.custom_name) defp sort_attr(:custom_name), do: dynamic([s], fragment("? COLLATE NOCASE", s.custom_name))
defp sort_attr(:enabled), do: dynamic([s], s.enabled) defp sort_attr(:enabled), do: dynamic([s], s.enabled)
defp set_sources(%{assigns: assigns} = socket) do defp set_sources(%{assigns: assigns} = socket) do
+8 -1
View File
@@ -39,7 +39,10 @@ defmodule PinchflatWeb.Endpoint do
cookie_key: "request_logger" cookie_key: "request_logger"
plug Plug.RequestId plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Telemetry,
event_prefix: [:phoenix, :endpoint],
log: {__MODULE__, :log_level, []}
plug Plug.Parsers, plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json], parsers: [:urlencoded, :multipart, :json],
@@ -55,6 +58,10 @@ defmodule PinchflatWeb.Endpoint do
plug PinchflatWeb.Router plug PinchflatWeb.Router
# Disables logging in Plug.Telemetry for healthcheck requests
def log_level(%Plug.Conn{path_info: ["healthcheck"]}), do: false
def log_level(_), do: :info
# URLs need to be generated using the host of the current page being accessed # URLs need to be generated using the host of the current page being accessed
# for things like Podcast RSS feeds to contain links to the right location. # for things like Podcast RSS feeds to contain links to the right location.
# #
+2 -2
View File
@@ -5,7 +5,7 @@ defmodule PinchflatWeb.Gettext do
By using [Gettext](https://hexdocs.pm/gettext), By using [Gettext](https://hexdocs.pm/gettext),
your module gains a set of macros for translations, for example: your module gains a set of macros for translations, for example:
import PinchflatWeb.Gettext use Gettext, backend: PinchflatWeb.Gettext
# Simple translation # Simple translation
gettext("Here is the string to translate") gettext("Here is the string to translate")
@@ -20,5 +20,5 @@ defmodule PinchflatWeb.Gettext do
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
""" """
use Gettext, otp_app: :pinchflat use Gettext.Backend, otp_app: :pinchflat
end end
+49 -1
View File
@@ -20,6 +20,10 @@ defmodule PinchflatWeb.Router do
plug :accepts, ["json"] plug :accepts, ["json"]
end end
pipeline :api_auth do
plug PinchflatWeb.ApiAuthPlug
end
scope "/", PinchflatWeb do scope "/", PinchflatWeb do
pipe_through [:maybe_basic_auth, :token_protected_route] 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 "/search", Searches.SearchController, only: [:show], singleton: true
resources "/settings", Settings.SettingController, only: [:show, :update], 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 "/app_info", Settings.SettingController, :app_info
get "/download_logs", Settings.SettingController, :download_logs get "/download_logs", Settings.SettingController, :download_logs
@@ -68,7 +74,49 @@ defmodule PinchflatWeb.Router do
scope "/", PinchflatWeb do scope "/", PinchflatWeb do
pipe_through :api pipe_through :api
get "/healthcheck", HealthController, :check 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 end
scope "/dev" do scope "/dev" do
+8 -6
View File
@@ -4,9 +4,10 @@ defmodule Pinchflat.MixProject do
def project do def project do
[ [
app: :pinchflat, app: :pinchflat,
version: "2025.3.6", version: "2025.9.26",
elixir: "~> 1.17", elixir: "~> 1.17",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
elixirc_options: [warnings_as_errors: System.get_env("EX_CHECK") == "1"],
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
aliases: aliases(), aliases: aliases(),
deps: deps(), deps: deps(),
@@ -46,13 +47,13 @@ defmodule Pinchflat.MixProject do
# Type `mix help deps` for examples and options. # Type `mix help deps` for examples and options.
defp deps do defp deps do
[ [
{:phoenix, "~> 1.7.14"}, {:phoenix, "~> 1.7.21"},
{:phoenix_ecto, "~> 4.4"}, {:phoenix_ecto, "~> 4.4"},
{:ecto, "~> 3.12.3"}, {:ecto, "~> 3.12.3"},
{:ecto_sql, "~> 3.12"}, {:ecto_sql, "~> 3.12"},
{:ecto_sqlite3, ">= 0.0.0"}, {:ecto_sqlite3, ">= 0.0.0"},
{:ecto_sqlite3_extras, "~> 1.2.0"}, {:ecto_sqlite3_extras, "~> 1.2.0"},
{:phoenix_html, "~> 3.3"}, {:phoenix_html, "~> 4.2"},
{:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 1.0.0"}, {:phoenix_live_view, "~> 1.0.0"},
{:floki, ">= 0.36.0", only: :test}, {:floki, ">= 0.36.0", only: :test},
@@ -65,16 +66,17 @@ defmodule Pinchflat.MixProject do
{:telemetry_poller, "~> 1.1"}, {:telemetry_poller, "~> 1.1"},
{:gettext, "~> 0.20"}, {:gettext, "~> 0.20"},
{:jason, "~> 1.2"}, {:jason, "~> 1.2"},
{:dns_cluster, "~> 0.1.1"}, {:dns_cluster, "~> 0.2"},
{:plug_cowboy, "~> 2.5"}, {:plug_cowboy, "~> 2.5"},
{:oban, "~> 2.17"}, {:oban, "~> 2.17"},
{:nimble_parsec, "~> 1.4"}, {:nimble_parsec, "~> 1.4"},
{:timex, "~> 3.0"}, # See: https://github.com/bitwalker/timex/issues/778
{:timex, git: "https://github.com/bitwalker/timex.git", ref: "cc649c7a586f1266b17d57aff3c6eb1a56116ca2"},
{:prom_ex, "~> 1.11.0"}, {:prom_ex, "~> 1.11.0"},
{:mox, "~> 1.0", only: :test}, {:mox, "~> 1.0", only: :test},
{:credo, "~> 1.7.7", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7.7", only: [:dev, :test], runtime: false},
{:credo_naming, "~> 2.1", only: [:dev, :test], runtime: false}, {:credo_naming, "~> 2.1", only: [:dev, :test], runtime: false},
{:ex_check, "~> 0.14.0", only: [:dev, :test], runtime: false}, {:ex_check, "~> 0.16.0", only: [:dev, :test], runtime: false},
{:faker, "~> 0.17", only: :test}, {:faker, "~> 0.17", only: :test},
{:sobelow, "~> 0.13", only: [:dev, :test], runtime: false} {:sobelow, "~> 0.13", only: [:dev, :test], runtime: false}
] ]
+46 -45
View File
@@ -1,72 +1,73 @@
%{ %{
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.11", "4bbd584741601eb658007339ea730b082cc61f3554cf2e8f39bf693a11b49073", [:mix], [], "hexpm", "e03990b4db988df56262852f20de0f659871c35154691427a5047f4967a16a62"}, "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"},
"certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, "cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"},
"credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"},
"credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"}, "credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"},
"db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"},
"decimal": {:hex, :decimal, "2.2.0", "df3d06bb9517e302b1bd265c1e7f16cda51547ad9d99892049340841f3e15836", [:mix], [], "hexpm", "af8daf87384b51b7e611fb1a1f2c4d4876b65ef968fa8bd3adf44cff401c7f21"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.1.2", "3eb5be824c7888dadf9781018e1a5f1d3d1113b333c50bce90fb1b83df1015f2", [:mix], [], "hexpm", "7494272040f847637bbdb01bcdf4b871e82daf09b813e7d3cb3b84f112c6f2f8"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.12.3", "1a9111560731f6c3606924c81c870a68a34c819f6d4f03822f370ea31a582208", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9efd91506ae722f95e48dc49e70d0cb632ede3b7a23896252a60a14ac6d59165"}, "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"},
"ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"}, "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"},
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.17.2", "200226e057f76c40be55fbac77771eb1a233260ab8ec7283f5da6d9402bde8de", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "a3838919c5a34c268c28cafab87b910bcda354a9a4e778658da46c149bb2c1da"}, "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.19.0", "00030bbaba150369ff3754bbc0d2c28858e8f528ae406bf6997d1772d3a03203", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "297b16750fe229f3056fe32afd3247de308094e8b0298aef0d73a8493ce97c81"},
"ecto_sqlite3_extras": {:hex, :ecto_sqlite3_extras, "1.2.2", "36e60b561a11441d15f26c791817999269fb578b985162207ebb08b04ca71e40", [:mix], [{:exqlite, ">= 0.13.2", [hex: :exqlite, repo: "hexpm", optional: false]}, {:table_rex, "~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "2b66ba7246bb4f7e39e2578acd4a0e4e4be54f60ff52d450a01be95eeb78ff1e"}, "ecto_sqlite3_extras": {:hex, :ecto_sqlite3_extras, "1.2.2", "36e60b561a11441d15f26c791817999269fb578b985162207ebb08b04ca71e40", [:mix], [{:exqlite, ">= 0.13.2", [hex: :exqlite, repo: "hexpm", optional: false]}, {:table_rex, "~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "2b66ba7246bb4f7e39e2578acd4a0e4e4be54f60ff52d450a01be95eeb78ff1e"},
"elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ex_check": {:hex, :ex_check, "0.14.0", "d6fbe0bcc51cf38fea276f5bc2af0c9ae0a2bb059f602f8de88709421dae4f0e", [:mix], [], "hexpm", "8a602e98c66e6a4be3a639321f1f545292042f290f91fa942a285888c6868af0"}, "ex_check": {:hex, :ex_check, "0.16.0", "07615bef493c5b8d12d5119de3914274277299c6483989e52b0f6b8358a26b5f", [:mix], [], "hexpm", "4d809b72a18d405514dda4809257d8e665ae7cf37a7aee3be6b74a34dec310f5"},
"expo": {:hex, :expo, "0.5.1", "249e826a897cac48f591deba863b26c16682b43711dd15ee86b92f25eafd96d9", [:mix], [], "hexpm", "68a4233b0658a3d12ee00d27d37d856b1ba48607e7ce20fd376958d0ba6ce92b"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"},
"exqlite": {:hex, :exqlite, "0.23.0", "6e851c937a033299d0784994c66da24845415072adbc455a337e20087bce9033", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "404341cceec5e6466aaed160cf0b58be2019b60af82588c215e1224ebd3ec831"}, "exqlite": {:hex, :exqlite, "0.31.0", "bdf87c618861147382cee29eb8bd91d8cfb0949f89238b353d24fa331527a33a", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "df352de99ba4ce1bac2ad4943d09dbe9ad59e0e7ace55917b493ae289c78fc75"},
"faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, "faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
"finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},
"floki": {:hex, :floki, "0.36.3", "1102f93b16a55bc5383b85ae3ec470f82dee056eaeff9195e8afdf0ef2a43c30", [:mix], [], "hexpm", "fe0158bff509e407735f6d40b3ee0d7deb47f3f3ee7c6c182ad28599f9f6b27a"}, "floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"},
"gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, "hackney": {:hex, :hackney, "1.24.1", "f5205a125bba6ed4587f9db3cc7c729d11316fa8f215d3e57ed1c067a9703fa9", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "f4a7392a0b53d8bbc3eb855bdcc919cd677358e65b2afd3840b5b3690c4c8a39"},
"hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
"mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"}, "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"oban": {:hex, :oban, "2.18.3", "1608c04f8856c108555c379f2f56bc0759149d35fa9d3b825cb8a6769f8ae926", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "36ca6ca84ef6518f9c2c759ea88efd438a3c81d667ba23b02b062a0aa785475e"}, "oban": {:hex, :oban, "2.19.4", "045adb10db1161dceb75c254782f97cdc6596e7044af456a59decb6d06da73c1", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fcc6219e6464525b808d97add17896e724131f498444a292071bf8991c99f97"},
"octo_fetch": {:hex, :octo_fetch, "0.4.0", "074b5ecbc08be10b05b27e9db08bc20a3060142769436242702931c418695b19", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "cf8be6f40cd519d7000bb4e84adcf661c32e59369ca2827c4e20042eda7a7fc6"}, "octo_fetch": {:hex, :octo_fetch, "0.4.0", "074b5ecbc08be10b05b27e9db08bc20a3060142769436242702931c418695b19", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "cf8be6f40cd519d7000bb4e84adcf661c32e59369ca2827c4e20042eda7a7fc6"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"peep": {:hex, :peep, "3.4.1", "0e5263710fa0b42675bd0a11fdcdd3ee4f484e319105b6ad9a576c91a5d3cb55", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "7a9b8c1f17b8b9475efb27b7048afa4d89ab84ef33a3d1df13696c85c12cd632"}, "peep": {:hex, :peep, "3.4.1", "0e5263710fa0b42675bd0a11fdcdd3ee4f484e319105b6ad9a576c91a5d3cb55", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "7a9b8c1f17b8b9475efb27b7048afa4d89ab84ef33a3d1df13696c85c12cd632"},
"phoenix": {:hex, :phoenix, "1.7.17", "2fcdceecc6fb90bec26fab008f96abbd0fd93bc9956ec7985e5892cf545152ca", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "50e8ad537f3f7b0efb1509b2f75b5c918f697be6a45d48e49a30d3b7c0e464c9"}, "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.4", "dcf3483ab45bab4c15e3a47c34451392f64e433846b08469f5d16c2a4cd70052", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f5b8584c36ccc9b903948a696fc9b8b81102c79c7c0c751a9f00cdec55d5f2d7"},
"phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.3", "7ff51c9b6609470f681fbea20578dede0e548302b0c8bdf338b5a753a4f045bf", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f9470a0a8bae4f56430a23d42f977b5a6205fdba6559d76f932b876bfaec652d"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.0", "2791fac0e2776b640192308cc90c0dbcf67843ad51387ed4ecae2038263d708d", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b3a1fa036d7eb2f956774eda7a7638cf5123f8f2175aca6d6420a7f95e598e1c"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.0.0", "3a10dfce8f87b2ad4dc65de0732fc2a11e670b2779a19e8d3281f4619a85bce4", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "254caef0028765965ca6bd104cc7d68dcc7d57cc42912bef92f6b03047251d99"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.17", "beeb16d83a7d3760f7ad463df94e83b087577665d2acc0bf2987cd7d9778068f", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a4ca05c1eb6922c4d07a508a75bfa12c45e5f4d8f77ae83283465f02c53741e1"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, "plug": {:hex, :plug, "1.18.0", "d78df36c41f7e798f2edf1f33e1727eae438e9dd5d809a9997c463a108244042", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "819f9e176d51e44dc38132e132fe0accaf6767eab7f0303431e404da8476cfa2"},
"plug_cowboy": {:hex, :plug_cowboy, "2.7.2", "fdadb973799ae691bf9ecad99125b16625b1c6039999da5fe544d99218e662e4", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "245d8a11ee2306094840c000e8816f0cbed69a23fc0ac2bcf8d7835ae019bb2f"}, "plug_cowboy": {:hex, :plug_cowboy, "2.7.3", "1304d36752e8bdde213cea59ef424ca932910a91a07ef9f3874be709c4ddb94b", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "77c95524b2aa5364b247fa17089029e73b951ebc1adeef429361eab0bb55819d"},
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"prom_ex": {:hex, :prom_ex, "1.11.0", "1f6d67f2dead92224cb4f59beb3e4d319257c5728d9638b4a5e8ceb51a4f9c7e", [:mix], [{:absinthe, ">= 1.7.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.11.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "76b074bc3730f0802978a7eb5c7091a65473eaaf07e99ec9e933138dcc327805"}, "prom_ex": {:hex, :prom_ex, "1.11.0", "1f6d67f2dead92224cb4f59beb3e4d319257c5728d9638b4a5e8ceb51a4f9c7e", [:mix], [{:absinthe, ">= 1.7.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.11.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "76b074bc3730f0802978a7eb5c7091a65473eaaf07e99ec9e933138dcc327805"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"}, "sobelow": {:hex, :sobelow, "0.14.0", "dd82aae8f72503f924fe9dd97ffe4ca694d2f17ec463dcfd365987c9752af6ee", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7ecf91e298acfd9b24f5d761f19e8f6e6ac585b9387fb6301023f1f2cd5eed5f"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"swoosh": {:hex, :swoosh, "1.14.4", "94e9dba91f7695a10f49b0172c4a4cb658ef24abef7e8140394521b7f3bbb2d4", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "081c5a590e4ba85cc89baddf7b2beecf6c13f7f84a958f1cd969290815f0f026"}, "swoosh": {:hex, :swoosh, "1.19.1", "77e839b27fc7af0704788e5854934c77d4dea7b437270c924a717513d598b8a4", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "eab57462d41a3330e82cb93a9d7640f5c79a85951f3457db25c1eb28fda193a6"},
"table_rex": {:hex, :table_rex, "4.0.0", "3c613a68ebdc6d4d1e731bc973c233500974ec3993c99fcdabb210407b90959b", [:mix], [], "hexpm", "c35c4d5612ca49ebb0344ea10387da4d2afe278387d4019e4d8111e815df8f55"}, "table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"},
"tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"}, "tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"}, "telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"},
"telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, "telemetry_poller": {:hex, :telemetry_poller, "1.2.0", "ba82e333215aed9dd2096f93bd1d13ae89d249f82760fcada0850ba33bac154b", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7216e21a6c326eb9aa44328028c34e9fd348fb53667ca837be59d0aa2a0156e8"},
"timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"}, "timex": {:git, "https://github.com/bitwalker/timex.git", "cc649c7a586f1266b17d57aff3c6eb1a56116ca2", [ref: "cc649c7a586f1266b17d57aff3c6eb1a56116ca2"]},
"tzdata": {:hex, :tzdata, "1.1.2", "45e5f1fcf8729525ec27c65e163be5b3d247ab1702581a94674e008413eef50b", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cec7b286e608371602318c414f344941d5eb0375e14cfdab605cca2fe66cba8b"}, "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
} }
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 KiB

After

Width:  |  Height:  |  Size: 506 KiB

@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddRateLimitSpeedToSettings do
use Ecto.Migration
def change do
alter table(:settings) do
add :download_throughput_limit, :string
end
end
end
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddRestrictFilenamesToSettings do
use Ecto.Migration
def change do
alter table(:settings) do
add :restrict_filenames, :boolean, default: false
end
end
end
@@ -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
+35 -1
View File
@@ -96,7 +96,7 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
end end
end end
describe "run/4 when testing sleep interval options" do describe "run/4 when testing rate limit options" do
test "includes sleep interval options by default" do test "includes sleep interval options by default" do
Settings.set(extractor_sleep_interval_seconds: 5) Settings.set(extractor_sleep_interval_seconds: 5)
@@ -124,6 +124,22 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
refute String.contains?(output, "--sleep-requests") refute String.contains?(output, "--sleep-requests")
refute String.contains?(output, "--sleep-subtitles") refute String.contains?(output, "--sleep-subtitles")
end end
test "includes limit_rate option when specified" do
Settings.set(download_throughput_limit: "100K")
assert {:ok, output} = Runner.run(@media_url, :foo, [], "")
assert String.contains?(output, "--limit-rate 100K")
end
test "doesn't include limit_rate option when download_throughput_limit is nil" do
Settings.set(download_throughput_limit: nil)
assert {:ok, output} = Runner.run(@media_url, :foo, [], "")
refute String.contains?(output, "--limit-rate")
end
end end
describe "run/4 when testing global options" do describe "run/4 when testing global options" do
@@ -146,6 +162,24 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
end end
end end
describe "run/4 when testing misc options" do
test "includes --restrict-filenames when enabled" do
Settings.set(restrict_filenames: true)
assert {:ok, output} = Runner.run(@media_url, :foo, [], "")
assert String.contains?(output, "--restrict-filenames")
end
test "doesn't include --restrict-filenames when disabled" do
Settings.set(restrict_filenames: false)
assert {:ok, output} = Runner.run(@media_url, :foo, [], "")
refute String.contains?(output, "--restrict-filenames")
end
end
describe "version/0" do describe "version/0" do
test "adds the version arg" do test "adds the version arg" do
assert {:ok, output} = Runner.version() assert {:ok, output} = Runner.version()
+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
@@ -75,6 +75,16 @@ defmodule PinchflatWeb.Sources.SourceLive.IndexTableLiveTest do
assert render_element(view, "tbody tr:first-child") =~ source1.custom_name assert render_element(view, "tbody tr:first-child") =~ source1.custom_name
assert render_element(view, "tbody tr:last-child") =~ source2.custom_name assert render_element(view, "tbody tr:last-child") =~ source2.custom_name
end end
test "name is sorted without case sensitivity", %{conn: conn} do
source1 = source_fixture(custom_name: "Source_B")
source2 = source_fixture(custom_name: "source_a")
{:ok, view, _html} = live_isolated(conn, IndexTableLive, session: create_session())
assert render_element(view, "tbody tr:first-child") =~ source2.custom_name
assert render_element(view, "tbody tr:last-child") =~ source1.custom_name
end
end end
describe "when testing pagination" do describe "when testing pagination" do
-2
View File
@@ -10,8 +10,6 @@ Application.put_env(:pinchflat, :http_client, HTTPClientMock)
Mox.defmock(UserScriptRunnerMock, for: Pinchflat.Lifecycle.UserScripts.UserScriptCommandRunner) Mox.defmock(UserScriptRunnerMock, for: Pinchflat.Lifecycle.UserScripts.UserScriptCommandRunner)
Application.put_env(:pinchflat, :user_script_runner, UserScriptRunnerMock) Application.put_env(:pinchflat, :user_script_runner, UserScriptRunnerMock)
if System.get_env("EX_CHECK"), do: Code.put_compiler_option(:warnings_as_errors, true)
ExUnit.start() ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(Pinchflat.Repo, :manual) Ecto.Adapters.SQL.Sandbox.mode(Pinchflat.Repo, :manual)
Faker.start() Faker.start()
+1
View File
@@ -18,6 +18,7 @@
{:sobelow, "mix sobelow --config"}, {:sobelow, "mix sobelow --config"},
{:prettier_formatting, "yarn run lint:check", fix: "yarn run lint:fix"}, {:prettier_formatting, "yarn run lint:check", fix: "yarn run lint:fix"},
{:npm_test, false}, {:npm_test, false},
{:gettext, false},
{:ex_unit, env: %{"MIX_ENV" => "test", "EX_CHECK" => "1"}} {:ex_unit, env: %{"MIX_ENV" => "test", "EX_CHECK" => "1"}}
## curated tools may be disabled (e.g. the check for compilation warnings) ## curated tools may be disabled (e.g. the check for compilation warnings)