# Suheil AI Content System Specification

Source guide: https://suheilai.com/blog/content-system-one-dashboard-arabic

## 1. System overview

This is a local FastAPI dashboard for a video backlog, media processing, caption approval, staging copy, thumbnails, analytics, and platform publishing.
SQLite is the source of truth. The API and UI read durable state from it.
Workers run locally and write progress, artifacts, errors, and platform readback.
Publishing is platform-specific and approval-gated.
The system is custom infrastructure, not a product or SaaS feature.

Project tree:

```text
content-strategy/
  dashboard/app.py
  dashboard/index.html
  dashboard/backlog.db
  dashboard/caption_engine/
  dashboard/onscreen_captions.py
  dashboard/thumbnail_pipeline.py
  dashboard/companion_jobs.py
  dashboard/start.sh
  cli/backlog
  playbooks/
  assets/
  analysis/
  research/
```

## 2. Data model

Live SQLite schema. The jobs table is `media_processing_jobs`.

```text
items
  columns: id, title, priority, approved, assets, script, notes, created_at, url, script_text, archived, screen_recording, talking_video, thumbnail, final_video, transcript_raw, transcript_final, transcript_updated_at, source_url, source_creator, source_platform, source_video_id, sort_order, archived_at, pre_captions_ready, first_cut, posted, series_id, series_part
  status: no single status enum; approved, archived, posted, final_video, and related fields are flags
  queue order: sort_order
media_assets
  columns: id, item_id, kind, file_path, original_filename, stored_filename, mime_type, size_bytes, sha256, duration_seconds, width, height, has_audio, validation_status, validation_error, uploaded_at, updated_at, transcript_path, processing_status, frame_pack_path
  validation_status: pending | valid | invalid
  processing_status: none | queued | running | completed | partial_failure | failed | unknown
jobs = media_processing_jobs
  columns: id, asset_id, item_id, job_type, status, progress_message, output_json, error, created_at, started_at, completed_at
  job_type: transcribe | generate_onscreen_captions | generate_staging | extract_frame_pack | propose_thumbnail_titles | generate_thumbnail | stitch_thumbnail_frame
  status: queued | running | completed | failed
staging
  columns: id, item_id, option_label, tiktok_caption, ig_caption, yt_title, yt_description, created_at, updated_at
  option_label: A | B | C
  constraint: UNIQUE(item_id, option_label)
publish_jobs
  columns: id, item_id, asset_id, staging_option, platforms, youtube_privacy, status, youtube_url, youtube_video_id, instagram_url, instagram_media_id, error, created_at, updated_at, published_at, youtube_staging_option, instagram_staging_option, facebook_url, facebook_video_id, youtube_status, youtube_error, instagram_status, instagram_error, facebook_status, facebook_error, tiktok_status, tiktok_error
  aggregate status: draft | publishing | published | error
  per-platform status: draft | publishing | published | error
  constraint: one active draft/publishing job per item_id + asset_id
```

Runtime caption table: `media_caption_tracks`. Key fields are `asset_id`, `item_id`, `status`, `style_id`, `caption_position`, `language`, `cues_json`, `words_json`, `ass_path`, `burned_path`, `cues_version`, and `burned_version`. Status: `none | generating | draft | burning | approved | failed`.

## 3. Pipeline

1. **Resolve the item.** Select the backlog item that owns the script, notes, transcript, and later platform links.
2. **Upload and validate.** Accept `mp4`, `mov`, or `m4v` up to 500MB. Save under `uploads/final_videos/{item_id}/`, compute SHA-256, and run `ffprobe` for MIME, dimensions, duration, and audio.
3. **Create jobs.** Enqueue required jobs for transcription, on-screen captions, staging, and thumbnail-title proposals. Frame-pack extraction is optional.
4. **Transcribe locally.** Save raw text, cleaned text, and word timestamps. Word timing is required for synchronized cues and later visual edits.
5. **Generate captions.** Convert word timing to editable cues, assign the two scene presets, and burn a draft. Approval is separate from autosave.
6. **Generate staging copy.** Write one canonical Option A with TikTok, Instagram, and YouTube fields. It remains a draft until reviewed.
7. **Generate the thumbnail path.** Propose titles, wait for a title selection, generate the image, then stitch a roughly 0.05 second still at absolute frame zero. This makes the selected thumbnail part of the publish master.
8. **Extract a frame pack when needed.** Write timed JPEGs and a manifest for analysis, guides, or carousels. This is enrichment and never gates the core publish path.
9. **Create platform jobs.** Run the publish gate, create one durable `publish_jobs` row, publish per platform, and store each platform ID, URL, status, and error independently.

## 4. Job system

The upload request writes asset and job rows, then starts a daemon thread. `POST /api/media/assets/{asset_id}/process-now` requeues failed work and creates missing jobs. `POST /api/media/process-pending` scans queued rows in dependency order.

A coordinator uses a lock and active-asset set so two coordinators cannot process one asset. `process_media_job` claims a row atomically with `UPDATE ... WHERE status IN ('queued','failed')`.

The execution graph is:

```text
transcribe
  ├── generate_onscreen_captions
  └── generate_staging
        ├── propose_thumbnail_titles
        └── extract_frame_pack
selected title
  └── generate_thumbnail → stitch_thumbnail_frame
```

Transcription runs first. Caption generation and staging can run concurrently after transcription. Thumbnail-title proposals and frame-pack extraction can run concurrently after staging. Image generation and stitch are explicitly user-gated.

Each job stores `status`, `progress_message`, `output_json`, and `error`. Exceptions become `failed` rows; `process-now` requeues them. Never retry an unknown external side effect blindly. Read back the platform ID first.

## 5. Captions

The cue contract is an editable JSON object. `position` is percent from the bottom of the 1080x1920 canvas.

```json
{"id":7,"start":12.340,"end":12.980,"text":"نظام","position":20,"mode":"single","words":[{"word":"نظام","start":12.340,"end":12.980}],"position_source":"auto","position_confidence":1.0,"position_reason":"two_preset_scene_layout"}
```

The locked style is compact white Arabic text, `SF Arabic Rounded` with `Geeza Pro` fallback, size 98, black outline 4, no shadow, no box, no glow, and no forced uppercase. The two approved scene presets are `20%` from the bottom for full-screen face footage and `50%` near the purple divider for split-screen footage.

Representative ASS output:

```ass
[Script Info]
ScriptType: v4.00+
PlayResX: 1080
PlayResY: 1920
[V4+ Styles]
Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding
Style: Default,SF Arabic Rounded,98,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,-1,0,1,4,0,5,48,48,384,1
[Events]
Format: Layer,Start,End,Style,Text
Dialogue: 0,0:00:12.340,0:00:12.980,Default,{\an5\pos(540,1536)}نظام
Dialogue: 0,0:01:03.100,0:01:03.840,Default,{\an5\pos(540,960)}نظام
```

The first event is the 20% preset. The second is the 50% preset. If libass or drawtext is unavailable, the fallback renders Arabic cue images and overlays them with FFmpeg using half-open timing.

## 6. API layer

These boundaries come from code. Verify OAuth apps, redirect URIs, audit state, and rate limits against current official documentation before rebuilding.

### YouTube

Auth is OAuth. The exact scopes in the code are:

```text
https://www.googleapis.com/auth/youtube.readonly
https://www.googleapis.com/auth/yt-analytics.readonly
https://www.googleapis.com/auth/youtube.upload
https://www.googleapis.com/auth/youtube.force-ssl
```

The first two are required read and analytics scopes. Upload is required for video publishing. `youtube.force-ssl` is used for comments. The upload helper uses resumable 8MB chunks, five client retries per chunk, and an outer transport retry with exponential backoff capped at 30 seconds and 12 consecutive retryable errors. Quota units and current quota costs are TODO until the official quota documentation is checked.

### Instagram

The Graph API base is `https://graph.facebook.com/{IG_API_VERSION}`. The account must be Business or Creator and connected through Meta. The code requires `instagram_basic`, `instagram_content_publish`, and `instagram_manage_insights`. Comment management uses `instagram_manage_comments`.

Instagram cannot fetch a local or Tailscale-only file. The actual R2 flow is:

1. Put the validated master in temporary Cloudflare R2 storage.
2. Build a clean HTTPS URL from `R2_PUBLIC_BASE_URL`.
3. Verify the URL with a range request and a readable first byte.
4. Create the Reel container with `video_url`, caption, and `share_to_feed`.
5. Poll container status every 5 seconds for up to 600 seconds.
6. Call `media_publish` only after `FINISHED`.
7. Read media ID and permalink, then delete the temporary R2 object.

The R2 client uses standard retries up to 10 attempts. Public URL verification retries every 2 seconds for up to 45 seconds. There is no blind retry for an unknown `media` or `media_publish` result. Current Graph API rate-limit numbers are TODO until verified from Meta docs. Honor `Retry-After` and reconcile the creation ID before retrying.

### Facebook Page

Publishing targets the linked Page, not a personal profile. The code requests:

```text
pages_show_list
pages_read_engagement
pages_manage_posts
instagram_basic
instagram_content_publish
instagram_manage_insights
business_management
instagram_manage_comments
```

`instagram_manage_comments` is for comment operations and is not required for the Reel upload itself. The Page Reel path is `video_reels` start, binary upload, and finish. It stores Page video ID and permalink separately from Instagram.

There is no automatic retry after the start or binary upload phases because the side effect may already exist. Store the returned video ID, read status and permalink, and only retry after reconciliation. Current Page and Graph rate limits are TODO until verified from Meta docs. Honor `Retry-After` for read requests.

### TikTok

The code uses these official endpoints:

```text
https://www.tiktok.com/v2/auth/authorize/
https://open.tiktokapis.com/v2/oauth/token/
https://open.tiktokapis.com/v2/user/info/
https://open.tiktokapis.com/v2/video/list/
https://open.tiktokapis.com/v2/post/publish/creator_info/query/
https://open.tiktokapis.com/v2/post/publish/video/init/
https://open.tiktokapis.com/v2/post/publish/inbox/video/init/
https://open.tiktokapis.com/v2/post/publish/status/fetch/
```

The exact scopes are `user.info.basic`, `user.info.profile`, `user.info.stats`, `video.list`, `video.upload`, and `video.publish`. `video.upload` is the Inbox or draft path. `video.publish` is the Direct Post path. An unaudited app connected to a public creator account cannot use public Direct Post. The code allows `SELF_ONLY` when audit is still required and blocks a public direct attempt.

Video chunks are planned up to 64MiB. Save `publish_id` before checking status. Do not retry init or upload blindly after a timeout. Failed drafts can be archived and resubmitted. Current Content Posting API rate limits are TODO until verified from TikTok docs. Use backoff for status reads, honor `Retry-After`, and persist `next_retry_at`.

### Analytics

The local analytics layer exposes `GET /api/analytics`, `POST /api/analytics/refresh`, `GET /api/analytics/health`, `GET /api/analytics/content`, `GET /api/analytics/content/movers`, `GET /api/analytics/growth`, and TikTok import or Studio routes. YouTube analytics uses `yt-analytics.readonly`. Instagram insights use `instagram_manage_insights`. TikTok reads use `user.info.stats` and `video.list`. Exact provider quota and rate-limit values remain TODO until checked against the live official docs.

## 7. Routes

The core route surface is:

```text
GET  /api/items
GET  /api/media/items/{item_id}
POST /api/media/items/{item_id}/final-video
GET  /api/media/assets/{asset_id}/processing
GET  /api/media/assets/{asset_id}/stream?burned=1&stitched=1
GET  /api/media/assets/{asset_id}/captions
PUT  /api/media/assets/{asset_id}/captions
POST /api/media/assets/{asset_id}/captions/burn
GET  /api/publish/config
POST /api/publish/jobs
GET  /api/publish/jobs?item_id={item_id}
POST /api/publish/jobs/{job_id}/youtube
POST /api/publish/jobs/{job_id}/instagram
POST /api/publish/jobs/{job_id}/facebook
POST /api/publish/jobs/{job_id}/tiktok
GET  /api/publish/jobs/{job_id}/tiktok/status
GET  /api/analytics/health
GET  /openapi.json
```

### Final-video upload

Request:

```http
POST /api/media/items/163/final-video
Content-Type: multipart/form-data
file=@final.mp4
```

Representative response:

```json
{"id":138,"item_id":163,"kind":"final_video","file_path":"<local-upload-path>","original_filename":"final.mp4","stored_filename":"<timestamp>_final.mp4","mime_type":"video/mp4","size_bytes":<bytes>,"sha256":"<sha256>","duration_seconds":68.4,"width":1080,"height":1920,"has_audio":true,"validation_status":"valid","validation_error":"","processing_status":"queued","transcript_path":"","frame_pack_path":"","has_frame_pack":false}
```

### Caption burn

Request:

```http
POST /api/media/assets/138/captions/burn
Content-Type: application/json
{"approve":true}
```

Representative response:

```json
{"ok":true,"track":{"asset_id":138,"item_id":163,"status":"approved","style_id":"suheil","caption_position":20,"language":"ar","cues_version":4,"burned_version":4,"ass_path":"<upload-dir>/captions/138/captions.ass","burned_path":"<upload-dir>/captions/138/burned.mp4","needs_reburn":false,"cues":[{"id":7,"start":12.34,"end":12.98,"text":"نظام","position":20,"mode":"single"}]}}
```

### Publish job creation

Request:

```http
POST /api/publish/jobs
Content-Type: application/json
{"item_id":163,"asset_id":138,"staging_option":"A","youtube_staging_option":"A","instagram_staging_option":"A","platforms":"youtube,instagram","youtube_privacy":"private"}
```

Representative response:

```json
{"id":41,"item_id":163,"asset_id":138,"staging_option":"A","platforms":"youtube,instagram","youtube_privacy":"private","status":"draft","youtube_status":"draft","instagram_status":"draft","youtube_url":"","instagram_url":"","error":"","platform_states":{"youtube":{"status":"draft","error":"","url":""},"instagram":{"status":"draft","error":"","url":""}}}
```

Creating a job is not publishing. Each platform has its own confirmation and readback.

## 8. Config, as of August 2026

The roster is swappable. Keep it in configuration, not business logic:

```text
orchestration=via Hermes
primary_text_provider=openai-codex
primary_text_model=gpt-5.6-luna
fallback_text_provider=xai-oauth
fallback_text_model=grok-composer-2.5-fast
image_model=gpt-image-2-medium
local_asr_backend=mlx-whisper
local_asr_model=whisper-large-v3-turbo
fallback_asr_backend=faster-whisper
fallback_asr_model=large-v3-turbo → base
reasoning_effort=max
```

Redacted `.env.example`:

```dotenv
# Provider credentials and OAuth files stay outside this file.
SUHEIL_AI_TEXT_PROVIDER=<primary-provider>
SUHEIL_AI_TEXT_MODEL=<primary-model>
SUHEIL_AI_FALLBACK_TEXT_PROVIDER=<fallback-provider>
SUHEIL_AI_FALLBACK_TEXT_MODEL=<fallback-model>
SUHEIL_AI_REASONING_EFFORT=max
SUHEIL_AI_HERMES_BIN=<absolute-harness-path>
SUHEIL_AI_CAPTION_LAYOUT_PROVIDER=<caption-layout-provider>
SUHEIL_AI_CAPTION_LAYOUT_MODEL=<caption-layout-model>
SUHEIL_AI_CAPTION_LAYOUT_REASONING=medium
SUHEIL_AI_CAPTION_LAYOUT_QA_REASONING=medium
SUHEIL_AI_CAPTION_LAYOUT_TIMEOUT=300
CAPTION_ASR_BACKEND=<local-asr-backend>
CAPTION_ASR_MODEL_MLX=<local-asr-model>
CAPTION_ASR_MODEL_FW=<fallback-asr-model>
CAPTION_CUE_MODE=single
FFMPEG_BIN=/opt/homebrew/bin/ffmpeg
FFPROBE_BIN=/opt/homebrew/bin/ffprobe
OPENAI_API_KEY=<redacted>
OPENROUTER_API_KEY=<redacted>
SUHEIL_AI_OPENROUTER_MAX_TOKENS=<max-output-tokens>
SUHEIL_AI_OPENROUTER_TEMPERATURE=<temperature>
SUHEIL_AI_OPENROUTER_REFERER=https://<your-site>
SUHEIL_AI_OPENROUTER_TITLE=<app-title>
R2_ACCOUNT_ID=<redacted>
R2_ACCESS_KEY_ID=<redacted>
R2_SECRET_ACCESS_KEY=<redacted>
R2_BUCKET=<bucket-name>
R2_PUBLIC_BASE_URL=https://<public-https-host>
R2_PREFIX=suheil-ai-publish
IG_ACCOUNT_ID=<redacted>
IG_API_VERSION=<verified-graph-version>
IG_USERNAME=<redacted-username>
INSTAGRAM_USERNAME=<redacted-username>
IG_COMMENT_REAL_SEND_ENABLED=0
FB_PAGE_ID=<redacted>
FB_PAGE_NAME=<page-name>
FB_OAUTH_REDIRECT_URI=https://localhost/
TIKTOK_CREDENTIALS_PATH=<local-tiktok-credentials-json>
TIKTOK_TOKEN_PATH=<local-tiktok-token-json>
TIKTOK_OAUTH_PENDING_PATH=<local-tiktok-oauth-pending-json>
TIKTOK_PUBLISH_LEDGER_PATH=<local-tiktok-publish-ledger-json>
TIKTOK_REDIRECT_URI=<registered-tiktok-redirect-uri>
ALLOW_REMOTE_DASHBOARD=0
ALLOW_REMOTE_PUBLISH=0
DASHBOARD_CORS_ORIGINS=http://localhost:8000
SUHEIL_AI_BROLL_BACKEND=<backend>
SUHEIL_AI_COMPANION_TIMEOUT=<seconds>
SUHEIL_AI_DASHBOARD_URL=http://localhost:8000
```

## 9. Publish gate

```text
function create_publish_job(item_id, asset_id, platforms):
  item = db.items.get(item_id)
  asset = db.media_assets.get(asset_id)
  if not item or not asset or asset.item_id != item_id: stop("wrong source")
  if asset.validation_status != "valid": stop("asset not validated")
  if asset.processing_status != "completed": stop("processing incomplete")
  if any(required_job.status != "completed" for required_job in required_jobs(asset_id)): stop("required job failed or queued")
  if caption_track.exists and caption_track.status != "approved": stop("captions need approval and burn")
  if thumbnail_track.blocks_publish: stop("thumbnail title, image, or stitch incomplete")
  if no staging row or empty platform copy: stop("staging missing")
  if active_publish_job(item_id, asset_id): stop("duplicate active job")
  for platform in platforms: verify(platform_auth, platform_copy, platform_constraints)
  insert publish_jobs(status="draft", per_platform_status="draft")
  return job
```

## 10. Build order

Build one column at a time. Every step needs a local test and durable output:

1. Create the SQLite schema and foreign keys. Test migrations and a clean empty database.
2. Implement item and asset routes. Test upload, SHA-256, `ffprobe`, size limits, and invalid files.
3. Implement job rows and atomic claiming. Test queued, running, completed, failed, and requeued states.
4. Implement local ASR and transcript artifacts. Test word timestamps and persisted paths.
5. Implement cue editing and final burn. Test JSON validation, 20% and 50% placement, ASS output, and approval gating.
6. Implement one staging row. Test exact field shape and empty-output rejection.
7. Implement thumbnail title selection, image generation, and first-frame stitch. Test that publish selects the stitched master.
8. Implement non-destructive provider health checks and read-only analytics. Verify scopes and redacted responses.
9. Implement draft or private upload for one platform. Read back the platform ID and URL before adding another platform.
10. Add Instagram R2 hosting, then Facebook Page and TikTok draft paths. Keep public Direct Post behind audit and approval.
11. Add public publishing only after the previous steps pass and the owner explicitly approves it.

## 11. Builder prompt

Paste this block into the agent that will build your version:

```text
You are helping me build a local content system for my own channel, based on the architecture in this guide. Work in two phases and do not skip phase 1.
SETUP FIRST
Before anything else, tell me to connect Chrome to you (extension or browser control).
You will need it to read the official developer docs and walk me through the developer consoles for YouTube, Instagram/Meta, Facebook Pages, and TikTok. Do not proceed until I confirm it is connected.
PHASE 1 - SPEC
Interview me, one question at a time, until you can answer:
- Which platforms, and for each: read only, draft upload, or public publish?
- Analytics and comments, or publishing only?
- What language is the content, and do I need burned-in captions?
- What machine am I on, and is local ASR viable?
- Which agent harness and model provider do I have?
Then produce, before any code:
1. An API inventory per platform, verified against the live official docs: docs URL, base URL, auth method, OAuth URLs, redirect URI rules, exact scopes, read/upload/publish endpoints, rate limits, audit requirements, non-destructive health check. Do not guess any of these.
2. Data model: tables, columns, status enums.
3. Backend route list.
4. .env.example, variable names only.
5. A build order where every step is independently testable.
Show me the spec and wait for approval.
PHASE 2 - BUILD
Build in the approved order. After each step, run a real test and show the result.
Integration testing order is always: status check, read-only request, draft or private upload, then public publish only after I explicitly approve it.
RULES
- Never ask me to paste an API key, client secret, refresh token, or password into chat. Placeholders and local env vars only.
- Stop before login, OAuth consent, billing, paid resources, and the first public publish. Those are mine.
- Never claim an integration works from a screenshot. Show the redacted response.
- Write a README with exact env var names, scopes, callback URLs, test commands, and recovery steps.
```
