pinchflat/Pinchflat_improvements.md
Amadeus Mader 426c80fa92 feat(indexing): auto-advance download_cutoff_date after successful indexing
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.
2026-03-07 18:31:45 +01:00

9.6 KiB

Pinchflat Improvements

Issues discovered while debugging Jellyfin artwork problems.

Issue 1: Source images not downloaded for early sources

Problem: Sources created before download_source_images was added to the media profile have empty poster_filepath, fanart_filepath, and banner_filepath in the sources table. The source_metadata table may have stale references to files that were never actually downloaded.

Example: Tsoding (source id 11, created 2025-06-04)

  • sources table has empty artwork paths
  • source_metadata references /config/metadata/sources/11/*.jpg but folder doesn't exist

Expected behavior: When download_source_images: true is set on a media profile, existing sources using that profile should have their artwork downloaded on next index.

Suggested fix: Add a migration or background job that:

  1. Checks all sources using profiles with download_source_images: true
  2. Downloads missing source images for sources that don't have them
  3. Updates both sources and source_metadata tables

Issue 2: Source images not downloaded for some new sources

Problem: Some recently added sources have no source_metadata entry at all, even though they use a profile with download_source_images: true.

Example: Jimmy_Broadbent (source id 97, created 2026-01-13)

  • No entry in source_metadata table
  • No metadata folder at /config/metadata/sources/97/
  • No artwork files in show folder
  • 1521 media items indexed, videos were downloaded (then culled due to 3-day retention)

Expected behavior: Source metadata and images should be downloaded when a source is first indexed, regardless of retention settings.

Suggested fix:

  1. Investigate why source_metadata entry wasn't created during initial indexing
  2. Ensure source image download happens before/independently of media downloads
  3. Add error logging if source image download fails

Issue 3: No way to re-trigger source image download

Problem: There's no UI option or API endpoint to re-download source images for an existing source. Users must delete and re-add the source.

Suggested fix: Add a "Refresh source metadata" button in the source settings that:

  1. Re-fetches channel metadata from YouTube
  2. Downloads/updates poster, fanart, and banner images
  3. Updates the source_metadata and sources tables
  4. Copies images to the show folder

Database schema reference

-- sources table (relevant columns)
SELECT id, custom_name, poster_filepath, fanart_filepath, banner_filepath FROM sources;

-- source_metadata table
SELECT source_id, metadata_filepath, fanart_filepath, poster_filepath, banner_filepath FROM source_metadata;

-- Metadata files stored in
/config/metadata/sources/{source_id}/
  - metadata.json.gz
  - poster.jpg
  - fanart.jpg
  - banner.jpg

-- Show folder should have copies
/downloads/shows/{source_custom_name}/
  - poster.jpg
  - fanart.jpg
  - banner.jpg
  - tvshow.nfo

Issue 4: SQLite "Database busy" errors blocking job processing

Problem: The Oban job queue can get stuck with "Database busy" errors, preventing all indexing and download jobs from processing. This occurs when:

  1. Long-running operations hold database locks
  2. The WAL (Write-Ahead Log) file grows very large (observed 252MB)
  3. Multiple concurrent operations compete for write access

Symptoms:

  • Logs show repeated (Exqlite.Error) Database busy errors
  • Oban.Stager GenServer terminates repeatedly
  • Jobs stay in available state but never execute
  • last_indexed_at for sources becomes very stale (months old)

Example log:

[error] | GenServer {Oban.Registry, {Oban, Oban.Stager}} terminating
** (Exqlite.Error) Database busy
UPDATE "oban_jobs" AS o0 SET "state" = ? WHERE (o0."id" IN (?))

Root cause: SQLite's busy_timeout PRAGMA defaults to 0 in the application, meaning queries fail immediately instead of waiting for locks to be released.

Suggested fixes:

  1. Set busy_timeout PRAGMA - Configure Exqlite/Ecto to use a reasonable busy_timeout (e.g., 5000ms) so queries wait for locks instead of failing immediately
  2. Configure WAL autocheckpoint - Ensure WAL doesn't grow unbounded; set wal_autocheckpoint to a reasonable value
  3. Add connection pool limits - Limit concurrent database writers to reduce lock contention
  4. Implement job queue cleanup - Automatically prune old completed/cancelled jobs to keep the oban_jobs table smaller

Workaround: Restart Pinchflat container to clear stuck state and checkpoint WAL:

docker compose restart pinchflat

Manual database maintenance:

# Stop Pinchflat first
docker compose stop pinchflat

# Clean old jobs and optimize
sqlite3 /path/to/pinchflat.db "
  DELETE FROM oban_jobs WHERE state = 'completed' AND completed_at < datetime('now', '-7 days');
  DELETE FROM oban_jobs WHERE state = 'cancelled' AND scheduled_at < datetime('now', '-7 days');
  ANALYZE;
  VACUUM;
"

# Start Pinchflat
docker compose start pinchflat

Issue 5: Orphaned deletion jobs for non-existent sources

Problem: When a source is deleted, a SourceDeletionWorker job is created. If this job fails repeatedly (e.g., due to database busy errors or files already deleted), it keeps retrying indefinitely even after the source no longer exists.

Example: Source ID 97 was deleted, but the deletion job kept retrying (14 attempts) even though there was nothing left to delete.

Suggested fix:

  1. Add a check at the start of SourceDeletionWorker to verify the source still exists
  2. If source doesn't exist, mark job as completed (or cancelled) instead of retrying
  3. Consider adding idempotency - if files/records are already gone, succeed gracefully

Issue 6: Sources with last_indexed_at = NULL never get indexed

Problem: Some sources show last_indexed_at as NULL in the database, indicating they were never successfully indexed. These sources have indexing jobs in the queue but the jobs may have failed silently or been stuck.

Example sources with NULL last_indexed_at:

  • PietSmietTV (id 91)
  • Rory Alexander (id 93)
  • BigfryTV (id 94)
  • Multiple sources added on 2026-03-06 (ids 101-118)

Suggested fix:

  1. Add monitoring/alerting for sources that haven't been indexed within expected timeframe
  2. Add a "Force re-index" button in the UI
  3. Log more details when indexing jobs fail
  4. Consider a health check that identifies sources with stale/null last_indexed_at

Issue 7: SlowIndexing jobs don't show as "executing" in UI

Problem: When MediaCollectionIndexingWorker (SlowIndexing) jobs start running, they spawn yt-dlp processes but fail to update their job state from available to executing in the database. This causes:

  1. No tasks visible in the frontend at /#tab-active-tasks
  2. Jobs appear stuck in available state even though yt-dlp is actively running
  3. Users have no visibility into what Pinchflat is currently doing

Observed behavior:

# Database shows no executing jobs
sqlite3 pinchflat.db "SELECT state, COUNT(*) FROM oban_jobs WHERE state = 'executing'"
# Returns: (empty)

# But yt-dlp processes are actively running
ps aux | grep yt-dlp
# Shows multiple yt-dlp processes for OutdoorBoys, USCSB, etc.

Contrast with FastIndexing: FastIndexingWorker jobs DO properly transition to executing state and show in the UI. Only SlowIndexing jobs have this issue.

Root cause hypothesis: The SlowIndexing worker spawns an external yt-dlp process but the database state update either:

  1. Fails silently due to SQLite busy errors
  2. Happens in a transaction that doesn't commit before the long-running process starts
  3. Is not implemented for this worker type

Impact:

  • Users think nothing is happening when indexing is actually in progress
  • No way to see progress or estimate completion time
  • Difficult to debug issues without visibility

Suggested fix:

  1. Ensure job state is updated to executing BEFORE spawning the yt-dlp process
  2. Use a database transaction with proper busy_timeout to ensure the state change persists
  3. Add periodic heartbeat updates during long-running indexing operations
  4. Consider adding progress tracking (e.g., "indexed 50/500 videos")

Issue 8: Old download_cutoff_date causes unnecessary full channel scans [COMPLETED]

Problem: The download_cutoff_date field on sources doesn't automatically update over time. Sources created months ago still have their original cutoff date, causing yt-dlp to scan through thousands of old videos on every index.

Example: OutdoorBoys had download_cutoff_date = 2025-11-08 even though it was March 2026, causing yt-dlp to check 4+ months of videos (~493 videos) on every indexing run.

Impact:

  • Extremely slow indexing (hours instead of minutes)
  • Unnecessary YouTube API calls (risk of rate limiting)
  • High CPU usage from yt-dlp processing old video metadata

Observed timing: With 40-second sleep intervals between requests, indexing 493 videos takes ~5.5 hours minimum.

Suggested fixes:

  1. Auto-advance cutoff date - After successful indexing, update download_cutoff_date to a recent date (e.g., 7 days before the oldest video that was actually downloaded)
  2. Use relative cutoff - Instead of absolute dates, allow setting "download videos from last N days"
  3. Add --break-on-existing consistently - Ensure all indexing jobs use this flag to stop early when hitting known videos
  4. UI warning - Show a warning if cutoff date is very old

Manual fix:

-- Update all sources to have cutoff date of 7 days ago
UPDATE sources SET download_cutoff_date = date('now', '-7 days')
WHERE download_cutoff_date < date('now', '-7 days');