After each successful indexing run, the source's download_cutoff_date is automatically advanced to 7 days ago if the current cutoff date is older (or nil). This prevents yt-dlp from scanning through months of old videos on every index run, significantly improving indexing performance for channels with large backlogs. The 7-day buffer ensures recent videos are still checked on subsequent index runs while keeping indexing fast (minutes instead of hours). Also updates AGENTS.md to document Docker-based development workflow and adds Pinchflat_improvements.md for tracking known issues.
6.2 KiB
6.2 KiB
Agent Guidelines for Pinchflat
Guidelines for AI agents working on this Elixir/Phoenix codebase.
Project Overview
Pinchflat is a self-hosted media management app using:
- Backend: Elixir 1.17+, Phoenix 1.7, Ecto with SQLite
- Frontend: Phoenix LiveView, Tailwind CSS, esbuild
- Background Jobs: Oban
- Testing: ExUnit with Mox for mocking
Build/Lint/Test Commands
Always use Docker for development and testing. The project has permission issues when mixing Docker and local builds.
Database backup: Use pinchflat.db.backup-* files for testing and development with real data. These contain production-like data for debugging issues.
# Run all checks (compile, format, credo, tests, sobelow, prettier)
docker compose run --rm phx mix check
# Run tests only
docker compose run --rm phx mix test
# Run a single test file
docker compose run --rm phx mix test test/pinchflat/media_test.exs
# Run a specific test by line number
docker compose run --rm phx mix test test/pinchflat/media_test.exs:42
# Run tests matching a pattern
docker compose run --rm phx mix test --only describe:"list_media_items"
# Format Elixir code
docker compose run --rm phx mix format
# Run Credo linter
docker compose run --rm phx mix credo
# Security analysis
docker compose run --rm phx mix sobelow --config
# Format JS/CSS/YAML/JSON
docker compose run --rm phx yarn run lint:fix
# Start the development server
docker compose up
Code Style Guidelines
Module Structure
Follow this order in modules:
@moduledoc- required for all public modulesuse/import/alias/requirestatements- Module attributes (
@allowed_fields,@impl, etc.) - Public functions with
@doc - Private functions
defmodule Pinchflat.Media do
@moduledoc """
The Media context.
"""
import Ecto.Query, warn: false
use Pinchflat.Media.MediaQuery
alias Pinchflat.Repo
alias Pinchflat.Media.MediaItem
@doc """
Returns the list of media_items.
"""
def list_media_items do
Repo.all(MediaItem)
end
defp some_private_function do
# ...
end
end
Imports and Aliases
- Use
aliasfor frequently used modules - Prefer explicit imports:
import Ecto.Query, warn: false - Group aliases alphabetically
- Use
alias __MODULE__for self-reference in schemas
Naming Conventions
- Modules: PascalCase matching file path (
Pinchflat.Media.MediaItem) - Functions: snake*case with verb prefixes (
get*,list*,create*,update*,delete*) - Predicate functions: end with
?(pending_download?/1) - Private functions: prefix with
do_for wrapper pattern (do_delete_media_files) - Workers: suffix with
Worker(MediaDownloadWorker) - Fixtures: suffix with
_fixture(media_item_fixture)
Formatting
- Line length: 120 characters max
- Use
mix format- config in.formatter.exs - Prettier for JS/CSS/YAML/JSON
Return Types
Document returns in @doc:
@doc """
Creates a media_item.
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}
"""
def create_media_item(attrs) do
# ...
end
Error Handling
- Use
{:ok, result}/{:error, reason}tuples - Use
!suffix for functions that raise (get_media_item!/1) - Pattern match on error tuples explicitly
- Rescue specific exceptions when needed:
def perform(%Oban.Job{args: %{"id" => id}}) do
# ...
rescue
Ecto.NoResultsError -> Logger.info("Record not found")
Ecto.StaleEntryError -> Logger.info("Record stale")
end
Oban Workers
defmodule Pinchflat.Downloading.MediaDownloadWorker do
@moduledoc false
use Oban.Worker,
queue: :media_fetching,
priority: 5,
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
tags: ["media_item", "show_in_dashboard"]
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => id} = args}) do
# Return :ok, {:ok, result}, {:error, reason}, or {:snooze, seconds}
end
end
Testing Patterns
Use Pinchflat.DataCase for database tests:
defmodule Pinchflat.MediaTest do
use Pinchflat.DataCase
import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures
alias Pinchflat.Media
describe "list_media_items/0" do
test "returns all media_items" do
media_item = media_item_fixture()
assert Media.list_media_items() == [media_item]
end
end
end
Mocking with Mox
Mocks are defined in test/test_helper.exs:
YtDlpRunnerMock- yt-dlp commandsHTTPClientMock- HTTP requestsUserScriptRunnerMock- user scriptsAppriseRunnerMock- notifications
test "calls user script" do
expect(UserScriptRunnerMock, :run, fn :media_deleted, data ->
assert data.id == media_item.id
{:ok, "", 0}
end)
Media.delete_media_item(media_item, delete_files: true)
end
Test Fixtures
Create fixtures in test/support/fixtures/:
def media_item_fixture(attrs \\ %{}) do
{:ok, media_item} =
attrs
|> Enum.into(%{
media_id: Faker.String.base64(12),
title: Faker.Commerce.product_name(),
source_id: source_fixture().id
})
|> Pinchflat.Media.create_media_item()
media_item
end
Test Helpers
From Pinchflat.TestingHelperMethods:
now/0- current UTC datetimenow_minus/2- datetime in past (now_minus(5, :days))now_plus/2- datetime in futureassert_changed/2- verify state change
Project Structure
lib/
pinchflat/ # Business logic contexts
media/ # Media context (MediaItem, queries)
sources/ # Source context
downloading/ # Download workers and helpers
yt_dlp/ # yt-dlp integration
pinchflat_web/ # Phoenix web layer
test/
pinchflat/ # Context tests
pinchflat_web/ # Controller/LiveView tests
support/
fixtures/ # Test data factories
data_case.ex # Database test setup
conn_case.ex # HTTP test setup
Pre-commit Hooks
Lefthook runs on commit:
prettier- JS/CSS/YAML/JSON formattingmix format- Elixir formattingtypos- spell checkingactionlint- GitHub Actions validation
Bypass if needed: git commit --no-verify -m "message"