# ADR-005 — App-side scheduling, because the Content Posting API has none

**Status:** accepted, implemented (initial schema + scheduler container) · **Date:** 2026-10-13 · **Deciders:** RG

## Context

TikTok's Content Posting API publishes a video either immediately (direct post) or as a draft
the user finishes in the TikTok app. There is **no scheduled publish** endpoint — scheduling
exists only inside TikTok's own app, on the phone. The sibling managers (YouTube, Facebook)
both expose scheduling as a first-class feature, and the owner expects the same capability
here: "publish this lesson video tomorrow at 09:00".

## Decision

1. **Scheduling is app-side.** A `scheduled_posts` table holds every pending post: caption,
   privacy, mode, the video (a file stored under `MEDIA_FOLDER`, or a public URL), the
   scheduled time (naive UTC) and a status machine: `pending → publishing → published | failed`.
2. **A dedicated scheduler container** (`tiktok-scheduler` in the same compose file, same image,
   command `python -m app.scheduler`) runs a plain loop: every `SCHEDULER_INTERVAL_SECONDS`
   (default 30) it calls `services.scheduled.run_due_posts()`, which publishes every pending
   post whose time has come, records the outcome with a **client-safe** error message on
   failure, and deletes the stored file after a successful publish.
3. **One writer.** Exactly one scheduler container runs against a database. The loop is
   sequential and each post is claimed (`pending → publishing`, committed) before publishing,
   so a crash mid-publish leaves the row in `publishing`, visible in the UI, never
   double-posted by the same loop.
4. **Tokens refresh in the worker too.** Access tokens live 24 hours, so `run_due_posts` calls
   `auth.ensure_fresh_token()` per post; TikTok's rotated refresh tokens are persisted the same
   way the web process does.
5. **Failure is a first-class state.** A failed post keeps its stored file, shows the safe error
   in the UI, and can be retried (re-queued) or rescheduled from both the GUI and the API.
6. **Rejected:** a background thread inside the web container — gunicorn runs 2 workers, so a
   per-worker scheduler would double-publish. **Rejected:** APScheduler/Celery — a plain loop
   container is one process, zero new dependencies, and the tick is idempotent.

## Consequences

- The compose file grows a second always-on service (no ports, no gunicorn); it shares the
  `data` and `media` volumes with the web container.
- Immediate publishes (publish now) do **not** go through the table — they upload directly and
  wait ~90 s for TikTok's verdict; only scheduled posts persist.
- `MEDIA_FOLDER` is a persistent volume (unlike the temp `UPLOAD_FOLDER`); deleting a scheduled
  post deletes its file.
- If the scheduler is down, posts stay `pending` and publish late when it returns — visible in
  the UI, not silent.
