TrollflixDevelopers

Code examples

Copy-paste Python and Node.js code for uploading, scheduling and handling errors.

Everything on this page builds on one small client file per language. Copy it into your project once, then use the recipes below. Pick your language once and every example on the page follows.

PythonNode.js
Version3.9+20+
Dependenciesrequestsnone (built-in fetch)
Installpip install requestsnothing to install

Both read your key from the TROLLFLIX_API_KEY environment variable:

export TROLLFLIX_API_KEY="tfx_..."

The client

trollflix.py
import mimetypes
import os
import uuid
from contextlib import ExitStack
from datetime import datetime
from pathlib import Path

import requests

UPLOAD_URL = "https://uploads.backend.trollflix.com/api/php/developer/upload_video"
CATEGORIES_URL = "https://backend.trollflix.com/api/php/categories/list"


class TrollflixError(Exception):
    """The API rejected the request. `code` is the error_message_code."""

    def __init__(self, code, response):
        super().__init__(code)
        self.code = code
        self.response = response


def _to_form_value(value):
    if isinstance(value, bool):
        return "1" if value else "0"
    if isinstance(value, datetime):
        return str(int(value.timestamp()))
    return str(value)


def _file_part(path, stack):
    path = Path(path)
    mime_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
    return (path.name, stack.enter_context(path.open("rb")), mime_type)


class TrollflixClient:
    def __init__(self, api_key=None, timeout=600):
        self.api_key = api_key or os.environ["TROLLFLIX_API_KEY"]
        self.timeout = timeout

    def upload_video(self, path, *, thumbnail=None, upload_token=None, **fields):
        """Upload one video. Extra keyword arguments are sent as form fields,
        e.g. caption="...", super_category_id=1, is_ai_generated=True."""
        data = {name: _to_form_value(value) for name, value in fields.items() if value is not None}
        data["upload_token"] = upload_token or str(uuid.uuid4())

        with ExitStack() as stack:
            files = {"file": _file_part(path, stack)}
            if thumbnail is not None:
                files["thumbnail"] = _file_part(thumbnail, stack)

            response = requests.post(
                UPLOAD_URL,
                headers={"Authorization": f"Bearer {self.api_key}"},
                files=files,
                data=data,
                timeout=self.timeout,
            )

        result = response.json()
        if result.get("success") is not True:
            code = result.get("error_message_code") or result.get("error") or "unknown_error"
            raise TrollflixError(code, result)
        return result

    @staticmethod
    def list_categories():
        """The public category tree (no API key needed)."""
        response = requests.post(CATEGORIES_URL, json={}, timeout=30)
        response.raise_for_status()
        return response.json()["super_categories"]
trollflix.mjs
import { randomUUID } from "node:crypto";
import { openAsBlob } from "node:fs";
import { basename } from "node:path";

const UPLOAD_URL =
  "https://uploads.backend.trollflix.com/api/php/developer/upload_video";
const CATEGORIES_URL = "https://backend.trollflix.com/api/php/categories/list";

/** The API rejected the request. `code` is the error_message_code. */
export class TrollflixError extends Error {
  constructor(code, response) {
    super(code);
    this.name = "TrollflixError";
    this.code = code;
    this.response = response;
  }
}

function toFormValue(value) {
  if (typeof value === "boolean") return value ? "1" : "0";
  if (value instanceof Date) return String(Math.floor(value.getTime() / 1000));
  return String(value);
}

export class TrollflixClient {
  constructor({ apiKey = process.env.TROLLFLIX_API_KEY, timeoutMs = 600_000 } = {}) {
    if (!apiKey) throw new Error("Set TROLLFLIX_API_KEY or pass { apiKey }");
    this.apiKey = apiKey;
    this.timeoutMs = timeoutMs;
  }

  /**
   * Upload one video. Other options are sent as form fields,
   * e.g. { caption: "...", super_category_id: 1, is_ai_generated: true }.
   */
  async uploadVideo(path, { thumbnail, uploadToken = randomUUID(), ...fields } = {}) {
    const form = new FormData();
    form.append("file", await openAsBlob(path), basename(path));
    if (thumbnail) {
      form.append("thumbnail", await openAsBlob(thumbnail), basename(thumbnail));
    }
    form.append("upload_token", uploadToken);
    for (const [name, value] of Object.entries(fields)) {
      if (value !== undefined && value !== null) form.append(name, toFormValue(value));
    }

    const response = await fetch(UPLOAD_URL, {
      method: "POST",
      headers: { Authorization: `Bearer ${this.apiKey}` },
      body: form,
      signal: AbortSignal.timeout(this.timeoutMs),
    });

    const result = await response.json();
    if (result.success !== true) {
      const code = result.error_message_code ?? result.error ?? "unknown_error";
      throw new TrollflixError(code, result);
    }
    return result;
  }

  /** The public category tree (no API key needed). */
  static async listCategories() {
    const response = await fetch(CATEGORIES_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: "{}",
    });
    if (!response.ok) throw new Error(`categories/list: HTTP ${response.status}`);
    return (await response.json()).super_categories;
  }
}

Field names

Upload fields keep the API's own names (caption, super_category_id, ai_summary_of_content...), so everything in the upload reference works as-is. Booleans become "1"/"0" and dates become Unix seconds for you.

Upload a video

upload.py
from trollflix import TrollflixClient

client = TrollflixClient()

result = client.upload_video(
    "meme.mp4",
    caption="When the build passes on the first try #programming",
    super_category_id=1,
    ai_summary_of_content="A developer stares at a green CI check in disbelief, then cheers.",
)

print(f"https://www.trollflix.com/meme/{result['slug_url']}")
upload.mjs
import { TrollflixClient } from "./trollflix.mjs";

const client = new TrollflixClient();

const result = await client.uploadVideo("meme.mp4", {
  caption: "When the build passes on the first try #programming",
  super_category_id: 1,
  ai_summary_of_content:
    "A developer stares at a green CI check in disbelief, then cheers.",
});

console.log(`https://www.trollflix.com/meme/${result.slug_url}`);

Add a thumbnail

Send your own image, or pick a frame of the video. See Thumbnails for the size rules.

# Your own image (at least 500x300, at most 5 MB)
client.upload_video("meme.mp4", thumbnail="cover.jpg", caption="Monday mood")

# Or a frame of the video
client.upload_video("meme.mp4", thumbnail_time="00:00:03", caption="Monday mood")
// Your own image (at least 500x300, at most 5 MB)
await client.uploadVideo("meme.mp4", { thumbnail: "cover.jpg", caption: "Monday mood" });

// Or a frame of the video
await client.uploadVideo("meme.mp4", { thumbnail_time: "00:00:03", caption: "Monday mood" });

Pick a category by name

Look the ID up once and cache it. See Choosing a category.

def find_category_id(name):
    for category in TrollflixClient.list_categories():
        if name.lower() in category["tag_name"].lower():
            return category["id"]
    raise LookupError(f"No category matching {name!r}")


gaming_id = find_category_id("gaming")
client.upload_video("clip.mp4", caption="Lag spike at the worst moment #gaming", super_category_id=gaming_id)
async function findCategoryId(name) {
  const categories = await TrollflixClient.listCategories();
  const match = categories.find((category) =>
    category.tag_name.toLowerCase().includes(name.toLowerCase()),
  );
  if (!match) throw new Error(`No category matching "${name}"`);
  return match.id;
}

const gamingId = await findCategoryId("gaming");
await client.uploadVideo("clip.mp4", {
  caption: "Lag spike at the worst moment #gaming",
  super_category_id: gamingId,
});

Retry safely

Retry network failures and server errors with the same upload_token, so a retry can never post the meme twice. Validation errors are not retried: they would fail the same way again. See Safe retries.

retry.py
import time
import uuid

import requests

from trollflix import TrollflixError

RETRYABLE_CODES = {
    "file_upload_failed",
    "content_upload_failed",
    "media_container_upload_failed",
    "thumbnail_file_error",
}


def upload_with_retries(client, path, attempts=5, **fields):
    token = str(uuid.uuid4())  # one token per meme, reused on every attempt
    for attempt in range(1, attempts + 1):
        try:
            result = client.upload_video(path, upload_token=token, **fields)
            if not result.get("duplicate_in_progress"):
                return result  # created now, or by an earlier attempt
        except TrollflixError as error:
            if error.code not in RETRYABLE_CODES:
                raise
        except requests.RequestException:
            pass  # network problem: try again
        time.sleep(attempt * 5)
    raise RuntimeError(f"Gave up on {path} after {attempts} attempts")
retry.mjs
import { randomUUID } from "node:crypto";
import { TrollflixError } from "./trollflix.mjs";

const RETRYABLE_CODES = new Set([
  "file_upload_failed",
  "content_upload_failed",
  "media_container_upload_failed",
  "thumbnail_file_error",
]);

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export async function uploadWithRetries(client, path, fields = {}, attempts = 5) {
  const uploadToken = randomUUID(); // one token per meme, reused on every attempt
  for (let attempt = 1; attempt <= attempts; attempt++) {
    try {
      const result = await client.uploadVideo(path, { ...fields, uploadToken });
      if (!result.duplicate_in_progress) return result; // created now, or by an earlier attempt
    } catch (error) {
      if (error instanceof TrollflixError && !RETRYABLE_CODES.has(error.code)) throw error;
      // network problem or temporary server error: try again
    }
    await sleep(attempt * 5000);
  }
  throw new Error(`Gave up on ${path} after ${attempts} attempts`);
}

Schedule a folder of videos

Publish one meme a day at 18:00 UTC, starting tomorrow. Scheduling has its own allowance, separate from the daily limit on immediate uploads, and reaches up to 32 days ahead. See Scheduling uploads.

schedule_folder.py
from datetime import datetime, timedelta, timezone
from pathlib import Path

from retry import upload_with_retries
from trollflix import TrollflixClient

client = TrollflixClient()
now = datetime.now(timezone.utc)
publish_at = (now + timedelta(days=1)).replace(hour=18, minute=0, second=0, microsecond=0)
last_allowed = now + timedelta(days=32)

for video in sorted(Path("memes").glob("*.mp4")):
    if publish_at > last_allowed:
        print("32-day scheduling window is full, stopping here.")
        break
    result = upload_with_retries(
        client,
        video,
        caption=video.stem.replace("_", " "),
        scheduled_publish_at=publish_at,
    )
    print(f"{video.name} -> {publish_at:%Y-%m-%d %H:%M} UTC (content {result['content_id']})")
    publish_at += timedelta(days=1)
schedule-folder.mjs
import { readdir } from "node:fs/promises";
import { join, parse } from "node:path";
import { uploadWithRetries } from "./retry.mjs";
import { TrollflixClient } from "./trollflix.mjs";

const DAY_MS = 24 * 60 * 60 * 1000;
const client = new TrollflixClient();

const now = Date.now();
const publishAt = new Date(now + DAY_MS);
publishAt.setUTCHours(18, 0, 0, 0);
const lastAllowed = now + 32 * DAY_MS;

const videos = (await readdir("memes")).filter((name) => name.endsWith(".mp4")).sort();

for (const name of videos) {
  if (publishAt.getTime() > lastAllowed) {
    console.log("32-day scheduling window is full, stopping here.");
    break;
  }
  const result = await uploadWithRetries(client, join("memes", name), {
    caption: parse(name).name.replaceAll("_", " "),
    scheduled_publish_at: publishAt,
  });
  console.log(`${name} -> ${publishAt.toISOString()} (content ${result.content_id})`);
  publishAt.setTime(publishAt.getTime() + DAY_MS);
}

Respect the daily limit

Immediate uploads are limited per day. Every response tells you when you used your last slot, and the limit error tells you when it resets. See Rate limits.

from datetime import datetime, timezone

from trollflix import TrollflixError

try:
    result = client.upload_video("meme.mp4", caption="One more for today")
    if result["is_limit_activated"]:
        print("That was the last upload for today.")
except TrollflixError as error:
    if error.code != "content_upload_limit_failure":
        raise
    reset = datetime.fromtimestamp(error.response["limit_reset_time_unix"], timezone.utc)
    print(f"Daily limit reached. Uploads open again at {reset:%H:%M} UTC.")
import { TrollflixError } from "./trollflix.mjs";

try {
  const result = await client.uploadVideo("meme.mp4", { caption: "One more for today" });
  if (result.is_limit_activated) console.log("That was the last upload for today.");
} catch (error) {
  if (!(error instanceof TrollflixError) || error.code !== "content_upload_limit_failure") {
    throw error;
  }
  const reset = new Date(error.response.limit_reset_time_unix * 1000);
  console.log(`Daily limit reached. Uploads open again at ${reset.toISOString()}.`);
}

Turn errors into messages

Map the codes you care about to your own wording, and keep the code itself in your logs. The full list is in Errors.

from trollflix import TrollflixError

MESSAGES = {
    "api_key_invalid": "The API key was rotated or deleted. Update TROLLFLIX_API_KEY.",
    "content_video_duration_invalid": "Videos must be 2 to 120 seconds long.",
    "content_file_invalid_format": "Use an MP4, MOV, MKV, AVI, FLV, WMV, M4V or MPEG video.",
    "content_category_invalid": "That category no longer exists. Refresh your category list.",
    "content_upload_limit_failure": "Daily upload limit reached. Schedule it instead.",
}

try:
    client.upload_video("meme.mp4")
except TrollflixError as error:
    print(MESSAGES.get(error.code, f"Upload rejected ({error.code})."))
import { TrollflixError } from "./trollflix.mjs";

const MESSAGES = {
  api_key_invalid: "The API key was rotated or deleted. Update TROLLFLIX_API_KEY.",
  content_video_duration_invalid: "Videos must be 2 to 120 seconds long.",
  content_file_invalid_format: "Use an MP4, MOV, MKV, AVI, FLV, WMV, M4V or MPEG video.",
  content_category_invalid: "That category no longer exists. Refresh your category list.",
  content_upload_limit_failure: "Daily upload limit reached. Schedule it instead.",
};

try {
  await client.uploadVideo("meme.mp4");
} catch (error) {
  if (!(error instanceof TrollflixError)) throw error;
  console.log(MESSAGES[error.code] ?? `Upload rejected (${error.code}).`);
}