Post

Python Media Indexer

Python Media Indexer

Python Media Indexer

Self-hosted media library management and encoding queue. The service scans watch directories for video files (.mp4, .m4v, .mkv), records codec/resolution/track metadata to PostgreSQL via MediaInfo, surfaces an encoding queue for files that do not match the configured target codec/extension/subtitle-language rules, and moves files to resolution-bucketed HandBrake ingest directories. A background thread inside the backend container watches the HandBrake output directory and moves completed encodes back automatically.

An optional auto-encode feature lets the worker drain the encoding queue unattended, one file at a time. The UI is a dark-mode HTML/JS frontend served by nginx.


Architecture

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Browser
  └── nginx  (port 80, frontend_container)
        ├── serves  compose/html/  (static UI pages)
        └── proxies /api/*  →  FastAPI backend  (port 8000)
                                  ├── main.py        API routes + lifespan startup/recovery
                                  ├── encoder.py     Move-queue thread + encoding helpers
                                  ├── worker.py      Output-watcher + reanalysis + auto-encode
                                  ├── analyzer.py    MediaInfo parse + PostgreSQL upsert
                                  ├── cache.py       Redis get/set/delete with rate-limited error logging
                                  ├── config.py      INI read + MEDIA_* env-var overlay
                                  ├── auth.py        Basic Auth → session cookie; admin/viewer roles
                                  └── db_context.py  ThreadedConnectionPool (max 20)
                                          │
                                          ├── PostgreSQL 18  (external, on proxy network)
                                          │     ├── media_files
                                          │     ├── media_file_tracks
                                          │     ├── encode_jobs
                                          │     └── users / sessions
                                          │
                                          └── Redis 7  (redis container)
                                                ├── media:watchdirs_content   TTL 60 s
                                                ├── media:encoding_queue      TTL 30 s
                                                ├── media:encode_jobs         TTL 25 s
                                                ├── media:output_files        TTL 10 s
                                                └── media:config_media        TTL 300 s

Container Summary

ContainerImageRole
frontend_containernginx:alpine-slimStatic UI + /api/* reverse proxy
backendCustom Python 3.13-slim imageFastAPI + uvicorn + output-watcher thread
redisredis:7-alpineResponse cache; TTLs 10–300 s per endpoint

PostgreSQL 18 is external to the compose stack — it runs separately and is reached via the shared proxy Docker network. All three compose services join that same network. The backend waits for redis to pass its health check (redis-cli ping) before starting.

Database schema (tables, indexes, ENUM types, migrations) is initialized idempotently on every backend startup by db_init.py’s ensure_schema(). No manual SQL import is required.


Backend Module Map

FileRole
main.pyFastAPI app, all API route handlers, lifespan startup/recovery
encoder.pyMove-queue thread (queue_encode_job), build_encoding_queue_query(), get_dest_path()
worker.pyOutput-watcher loop, flag-based re-analysis, auto-encode step, reanalysis scheduler
analyzer.pyMediaInfo parsing (_parse_mediainfo) and PostgreSQL upsert (parse_and_upsert)
cache.pyRedis get/set/delete; errors suppressed with rate-limited warnings (one per 60 s)
config.pyINI read + env-var overlay; exposes get_cfg() and env_controlled_fields()
db_context.pyThreadedConnectionPool (min 1, max 20); db_conn(cfg) context manager
auth.pyHTTP Basic Auth → session cookie; two groups: admin (read/write) and viewer (GET only)
utils.pyjson_error, internal_error, split_tracks, read_file_tail, get_watch_dirs()
db_init.pyIdempotent schema bootstrap; ensure_schema() called on every startup

Worker Thread

worker.py runs as a daemon thread inside the backend container — not a separate container. Each poll cycle has four phases, in order:

  1. Reanalysis scheduler checkmaybe_trigger_reanalyze() fires if the configured interval_hours has elapsed since the last bulk re-flag.
  2. Stable-file mover — scans the output directory for files unchanged for stable_seconds (default 300 s), matches them to encode_jobs by basename/stem, commits the DB cleanup row before calling shutil.move, then calls parse_and_upsert() directly to re-analyze the moved file.
  3. Flag-based re-analysis — fetches up to chunk_size (default 50) media_files rows where analyzed_flag = TRUE, calls parse_and_upsert() for each, and clears the flag on success. Files currently in encode_jobs are skipped.
  4. Auto-encode step — if [auto_encode] enabled = true, submits one file from the encoding queue to the move-queue (pauses if any job is already in-flight).

A separate heartbeat sub-thread writes HEARTBEAT <unix_ts> to worker.log every 10 seconds. /api/worker-health reads that timestamp to report liveness.


Encoding Queue

There is no SQL VIEW. The queue is built at runtime from a parameterized query in encoder.build_encoding_queue_query() driven by three [media] config values:

  • final_codec — comma-separated codec substrings (case-insensitive). Files whose codec matches any fragment are excluded.
  • final_file_extension — files whose basename does not end with this are included.
  • keep_subtitle_languages — files with at least one subtitle track in a language not listed here are included.

Only files with a known resolution bucket (480 / 576 / 720 / 1080 / 2160) appear in the queue.


Auto-Encode

When [auto_encode] enabled = true, auto_encode_step() runs at the end of each worker poll cycle. It:

  1. Checks encode_jobs for any row with status NOT IN ('error') — pending, copying, or complete. If one exists, it returns immediately. A complete row means the file is in the HandBrake ingest bucket and HandBrake has not yet finished; only once the worker moves the output file back (and deletes the encode_jobs row) is the next file submitted.
  2. Queries the encoding queue (via build_encoding_queue_query) excluding files that already have an encode_jobs row. Ordering is controlled by sort_order (size_asc = smallest first, size_desc = largest first).
  3. Picks the first result, inserts an encode_jobs row (ON CONFLICT … DO UPDATE to reset stale entries), and calls queue_encode_job().

Set MEDIA_AUTO_ENCODE_ENABLED=false (or toggle in the UI) to stop it at any time; any copy already started will still complete.


Quick Start

Prerequisites

  • Docker + Docker Compose
  • External Docker network named proxy
1
docker network create proxy

1. Configure the environment

1
cp compose/.env.example compose/.env

Edit compose/.env and fill in at minimum:

MEDIA_DB_USER=<your-db-user>
MEDIA_DB_PASSWORD=<your-db-password>

Also set MEDIA_REDIS_HOST=redis (and MEDIA_REDIS_PORT=6379) so the backend can reach the Redis container. All other variables have working defaults.

2. Deploy

1
./build_app.sh

Or manually:

1
2
3
docker build -t <registry>/media-indexer:latest app/
docker push <registry>/media-indexer:latest
cd compose && docker compose up -d --force-recreate

3. Access

ServiceURL
Web UIhttp://<host>/
API docs (FastAPI)http://<host>/docs
Backend API directhttp://<host>:8000/api/

First run: A default admin account is seeded if the users table is empty. Change the default password immediately via the Users page (/users.html) or PATCH /api/users/{id}.


Configuration

Configuration is read from /config/media_indexer.ini inside the backend container. The file is optional — all settings can be supplied entirely through MEDIA_* environment variables, which take precedence over any INI value.

Key environment variables

Env varDefaultDescription
MEDIA_DB_DBNAMEmediaPostgreSQL database name
MEDIA_DB_USERPostgreSQL user
MEDIA_DB_PASSWORDPostgreSQL password
MEDIA_DB_HOSTpostgresPostgreSQL host
MEDIA_WATCH_DIRSComma-separated watch directories
MEDIA_OUTPUT_DIRHandBrake output directory
MEDIA_2160P_WATCHHandBrake ingest dir for 2160p
MEDIA_1080P_WATCHHandBrake ingest dir for 1080p
MEDIA_720P_WATCHHandBrake ingest dir for 720p
MEDIA_FINAL_EXT.mkvTarget file extension
MEDIA_FINAL_CODEChevc,hvc1,…Codec substrings indicating already-encoded files
MEDIA_KEEP_LANGSeng,en,en-usSubtitle languages to keep
MEDIA_WORKER_POLL_INTERVAL30Output-dir poll interval (seconds)
MEDIA_WORKER_STABLE_SECS300Min seconds unchanged before a file is “stable”
MEDIA_REDIS_HOSTRedis hostname; if unset, caching is disabled
MEDIA_REANALYZE_INTERVAL_HOURS0Hours between auto bulk re-analysis; 0 = off
MEDIA_AUTO_ENCODE_ENABLEDfalseSet to true to enable automatic queue draining
MEDIA_AUTO_ENCODE_SORT_ORDERsize_ascsize_asc (smallest first) or size_desc
MEDIA_AUTH_MODEbasicAuth backend: basic or tinyauth
MEDIA_SESSION_TTL_HOURS0Session lifetime; 0 = never expire

Authentication

All routes except GET /api/health, GET /api/auth/challenge, and POST /api/auth/logout require authentication.

Flow: The login page (/login.html) posts to POST /api/auth/login with form fields (username, password). The backend verifies the password against a bcrypt hash in the users table, creates a server-side session, and returns an HttpOnly SameSite=Strict cookie (session_id). Subsequent requests use the cookie (fast UUID lookup, no bcrypt round-trip). Direct HTTP Basic Auth to any protected endpoint is also supported.

Roles:

RolePermissions
adminFull read/write access to all endpoints, including user management
viewerGET / HEAD / OPTIONS only; 405 on any mutation

Changing a user’s password or setting is_active = false immediately invalidates all existing sessions for that user. The last active admin account is protected — it cannot be deleted or demoted to viewer.


API Overview

Interactive docs (FastAPI auto-generated): http://<host>/docs

Health and diagnostics

MethodPathDescription
GET/api/healthLiveness check; always returns {"status": "ok"}
GET/api/dbcheckTests the PostgreSQL connection
GET/api/worker-healthReports output-watcher and move-worker liveness via heartbeat

Encoding

MethodPathDescription
GET/api/encoding-queueFiles that need encoding per current [media] rules
POST/api/encodeQueues files for encoding; max 500 paths per request
GET/api/encode-jobsAll encode job records, newest first
DELETE/api/encode-jobs/completedRemove all complete-status job records (admin only)
GET/api/output-filesFiles in the configured output directory

Media

MethodPathDescription
GET/api/watchdirs-contentLists all video files under each watch directory
GET/api/file-info?path=…Runs MediaInfo, upserts to DB, returns codec/resolution/track detail
POST/api/reanalyze-triggerImmediately flags all watch-dir files for re-analysis (admin only)

Logs

1
GET /api/service-log?service=<name>&lines=<n>

Valid values for service: backend, sql, worker, nginx_access, nginx_error. Returns the last lines lines (default 300) as plain text.


Database Schema

Schema is initialized by db_init.py’s ensure_schema() on every backend startup. All DDL is idempotent (CREATE TABLE IF NOT EXISTS, ALTER TABLE … ADD COLUMN IF NOT EXISTS, etc.). No manual SQL import is required.

media_files

One row per unique file path.

ColumnTypeNotes
idSERIAL PK 
pathTEXT UNIQUEAbsolute path
basenameTEXTFilename only
size_bytesBIGINT 
codecTEXTRaw codec ID from MediaInfo
media_typeTEXTmovie or show (inferred from path)
resolutionINTEGERActual video height in pixels
resolution_bucketINTEGERBucketed to 480 / 576 / 720 / 1080 / 2160
last_modifiedTIMESTAMPFile mtime
run_atTIMESTAMPLast analysis timestamp
analyzed_flagBOOLEANTRUE = queued for re-analysis by worker

encode_jobs

Lifecycle record for each file sent for encoding.

ColumnTypeNotes
idSERIAL PK 
media_idINTEGER FKReferences media_files.id; nullable (ON DELETE SET NULL)
source_pathTEXT UNIQUEOriginal file path
dest_pathTEXTPath in the resolution ingest directory
statusTEXTpendingcopyingcomplete / error
progressINTEGERCopy progress 0–100
queued_atTIMESTAMP 

Logs

All services write to the shared compose/logs/ volume.

FileSourceContents
backend.logFastAPIHTTP request log + application messages (RotatingFileHandler, 100 MB, 5 backups)
sql.logFastAPISQL queries at DEBUG level
worker.logworker threadPoll activity, file moves, re-analysis events, HEARTBEAT <ts> lines
nginx_access.lognginxISO 8601 access log
nginx_error.lognginxnginx errors

All Python log output is also mirrored to stdout for docker logs.


Commands

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Lint (Python)
./run_lint.sh

# Lint (HTML)
./run_html_lint.sh

# Tests (safe API suite — default)
./run_tests.sh

# Tests with Playwright browser tests
./run_tests.sh --browser

# Tests with real file moves (destructive)
./run_tests.sh --destructive

# Build + push + restart stack
./build_app.sh

Tests are integration-only and require a live backend at http://localhost:8000/api. They do not mock any dependencies and do not touch real media files — all encode tests use nonexistent paths or paths outside configured watch directories.


Known Issues / Roadmap

IssueTitleStatus
#1Add OIDC authentication via TinyAuth (forward-auth)Open
#15Auto-encoder TOCTOU race with manual encode submissionsOpen — fix planned: merge check+insert into a single transaction with INSERT … SELECT … WHERE NOT EXISTS guard
This post is licensed under CC BY 4.0 by the author.