Files
youtube-studio-flow/documentation/YouTube Studio Flow/05 - Development/04 - Gotchas.md
T

12 KiB

Gotchas

Non-obvious behaviors, known traps, and decisions that have caused bugs or confusion during development. Read this before debugging anything that "should work."


CONDITIONAL Block — Unknown Rule Types and Operators Silently Default to true

evaluateCondition() in render-engine.service.ts evaluates each rule in a CONDITIONAL block's condition.rules array via a switch statement. The default branch returns true for any unrecognised rule.type. A second default: return true exists inside the collab_count case for unrecognised operators.

Practical consequences:

  • A typo in rule.type (e.g. "variable_fille" instead of "variable_filled") silently makes the rule pass, causing the block to render unconditionally.
  • An unrecognised operator on a collab_count rule (e.g. "neq" instead of "eq") also silently passes.
  • No error is thrown, no warning is logged, and no lint rule checks condition JSON for valid rule types.

Known valid rule.type values: "variable_filled", "variable_empty", "collab_count".
Known valid collab_count operators: "eq", "gt", "lt", "gte", "lte".

If a CONDITIONAL block appears to render even when its condition should not be met, check the condition JSON for typos in type or operator.


Campaign.status Must Be Exactly "active" (Lowercase)

Campaign.status is a free-form String column with no enum constraint and no API-level validation. The DESC_OUTDATED_SPONSOR_COPY lint rule (linting/rules/desc-outdated-sponsor.rule.ts) checks status !== 'active' — an exact case-sensitive string match.

Any value other than the lowercase string "active" is treated as inactive:

  • "ACTIVE" → inactive (lint ERROR fires)
  • "paused", "disabled", "inactive" → inactive (lint ERROR fires)
  • Any typo → inactive (lint ERROR fires)

When a campaign is incorrectly treated as inactive, its CAMPAIGN blocks are excluded from all rendered descriptions and every video that references them shows DESC_OUTDATED_SPONSOR_COPY. If campaigns appear to have stopped working for no obvious reason, check Campaign.status for a case mismatch or typo.


remoteConflict — Detection Paths and Resolution

Video.remoteConflict can be set by three paths:

  1. Manual refreshPOST /videos/:id/refresh fetches YouTube-side metadata and recomputes lastSyncedHash. If the new hash differs from the stored one, the flag flips on.
  2. Scheduled sweep — the CONFLICT_DETECTION BullMQ queue runs on a cron pattern (CONFLICT_DETECTION_CRON, default 03:00 daily) when the operator sets CONFLICT_DETECTION_ENABLED=true on the worker AND the team opts in via Team.conflictDetectionEnabled. See 05 - Queue System.
  3. Implicit via full channel refresh — a channel-level re-import overwrites local fields wholesale, which is not conflict detection but effectively resolves any conflict by clobbering local state.

When path 1 or 2 detects a mismatch, pendingRemoteSnapshot (JSON) and pendingRemoteDescription (String) capture the freshly fetched remote state so the user can review and resolve it without a second YouTube call.

Resolution options:

  • POST /videos/:id/accept-remote — adopts the stored pending snapshot as the new local state. Zero YouTube API calls.
  • POST /videos/:id/sync — pushes local over remote (standard sync). Clears the flag on success.

Self-heal: if the sweep re-checks a video that was previously flagged but the remote now matches lastSyncedHash again (e.g. the creator reverted their out-of-band edit), the flag and pending fields are cleared automatically on the next pass.

Cost: the scheduled sweep uses videos.list batched up to 50 IDs at a time — 1 quota unit per batch, not per video. A batchSize of 250 costs roughly 5 units per team per run, plus one extra call whenever a batch spans a channel boundary (batches are grouped by channel first because the OAuth client is per-channel). QuotaService.canSpend(1) guards each batch and the sweep stops early on exhaustion.


lintStatus Is a Denormalized Cache

Video.lintStatus is not computed on read — it is a stored value written when lint jobs complete. Any code path that deletes LintResult rows (e.g. re-importing a channel, removing a lint rule) must recompute it afterward. Use POST /lint/team/recompute-status to heal all stale statuses across the team. The linting page calls this automatically on load.


Redis Eviction Policy

BullMQ silently drops jobs if Redis is configured with allkeys-lru eviction. Redis must run with --maxmemory-policy noeviction. This is already set in infrastructure/docker-compose.yml. Do not change it. If jobs seem to disappear without being processed, check the Redis eviction policy first.


TOKEN_ENCRYPTION_KEY Rotation

YouTube OAuth tokens are AES-256 encrypted in the database using TOKEN_ENCRYPTION_KEY. If this key is changed or lost, all stored tokens become unreadable and every connected channel breaks. Channels must then be fully re-authenticated. The key must remain stable for the entire lifetime of the database. See 02 - Environment Variables for the full variable reference.


Recording Date Is Never Imported from YouTube

The YouTube API does not return recordingDate in video list or playlist responses. It always imports as null. If a user sets a recording date locally, the video will show as "Push pending" (because the hash changes) and will remain so until the change is pushed to YouTube. This is expected behavior, not a bug.


freetext: IDs in blockOrder

VideoConfig.blockOrder and Template.defaultBlocks are JSON string arrays that may contain IDs with the prefix freetext: (e.g. "freetext:abc123"). These do not correspond to any DescriptionBlock row in the database. Their content comes entirely from blockOverrides[id].content. Always check for this prefix before performing a DB lookup on a block ID. See 02 - Description Engine for the full block rendering model.


hashMetadata Fields

hashMetadata() (in backend/src/shared/render-engine/hash.ts) hashes exactly these fields:

  • title
  • description
  • tags
  • categoryId
  • privacyStatus
  • defaultLanguage
  • defaultAudioLanguage
  • selfDeclaredMadeForKids
  • embeddable
  • license
  • recordingDate

All of these fields are included in the YouTube push payload. A local change to any of them will flip hasPendingChanges to true and trigger a sync. privacyStatus was added to the hash after it was discovered that privacy-only changes were silently dropped (the hash did not change, so the push was skipped).


Date Formatting Single-Pass Regex

applyDateFormat() in backend/src/shared/render-engine/render-engine.service.ts uses a single-pass regex replacement. Do not refactor it to use chained .replace() calls. Chained replacements cause re-substitution bugs — for example, the M token in a date format would match again inside the already-substituted word "March", corrupting the output. The single-pass approach was introduced specifically to fix this class of bug.


collab.youtube Resolves to a Full URL

The token {collab.youtube} resolves to the full YouTube channel URL, for example https://www.youtube.com/@handle. It does not resolve to just the handle string. If you need only the handle portion, that is not currently a supported token.


{video.publishedAt} Does Not Exist

{video.publishedAt} is not a supported render token and has never been registered in VIDEO_RESOLVERS or SYSTEM_VARIABLE_TOKENS. It was present in earlier versions of this documentation in error.

If any description block contains {video.publishedAt}, the placeholder will remain unreplaced in the rendered output — it resolves to nothing, leaving the literal string {video.publishedAt} in the description. The DESC_EMPTY_PLACEHOLDER lint rule will flag it.

The correct tokens for dates are {video.scheduledAt} and {video.recordingDate}. publishedAt is a database field accessible via the API but has no corresponding render token.


collab.handle Is Deprecated

The token {collab.handle} was renamed to {collab.youtube}. Any description block content still containing {collab.handle} will not resolve — it will remain as an unresolved placeholder in the rendered output. The DESC_EMPTY_PLACEHOLDER lint rule will catch this and flag it. Update affected blocks manually by replacing {collab.handle} with {collab.youtube}.


Prisma Enum Values Cannot Be Removed

Removing a value from a PostgreSQL enum requires a raw SQL migration and risks data corruption if any existing rows reference the removed value. Never remove values from Prisma enums. Instead, mark them as deprecated in the UI so they are hidden from users but remain valid in the database. The BlockType values GLOBAL and REPEATABLE are the current examples of this pattern.


Two Separate Entry Points

The backend has two separate NestJS entry points that must both be running:

  • src/main.tsAppModule → HTTP API on port 3001
  • src/worker.tsWorkerModule → BullMQ queue processor (no HTTP)

When you add a new module, register it in AppModule. If the worker's queue processors also need to use services from that module, register it in WorkerModule as well. Forgetting the WorkerModule registration causes runtime errors in background jobs that are invisible until a relevant job is actually processed.


Collaborator IDs Live on Video, Not VideoConfig

Collaborators are assigned via Video.collaboratorIds — a JSON string array on the Video row itself, not on VideoConfig. There is no VideoCollaborator join table. This single field is used by the render engine to resolve {collab.*} tokens, by the video list filter (collaboratorId query param), and by the calendar.

Use the array_contains Prisma operator when querying this JSON field.


SiYoutube Does Not Exist

react-icons/si v5 does not export SiYoutube. Attempting to import it will cause a build error. Use FaYoutube from react-icons/fa instead for the YouTube icon. All other platform icons (SiTwitch, SiInstagram, SiTiktok, SiX, SiBluesky, SiDiscord) are available in react-icons/si.


Middle-Click on Windows

Windows browsers intercept mousedown for middle-click before the auxclick event fires, entering autoscroll mode instead. Using onAuxClick or window.open() to handle middle-click will not work reliably on Windows. To support middle-click navigation on table rows or cards, use a real <a> element as an absolutely positioned overlay over the clickable area. The row overlay pattern in VideoTable.tsx is the reference implementation for this.


min-width: 0 on Flex and Grid Children

Flex and grid children default to min-width: auto, meaning they cannot shrink below their content's natural size. This causes horizontal overflow on any flex or grid child that contains long text, wide tables, or deeply nested content. Add min-width: 0 to every flex/grid child at every nesting level that might contain wide content. This applies in CSS Modules and must be repeated at each level — the parent setting does not propagate. See 02 - CSS Conventions for the full CSS pattern reference.


Deleted Blocks Are Silently Skipped at Render Time

If a block ID in VideoConfig.blockOrder no longer has a corresponding DescriptionBlock row (because the block was deleted), the render engine silently skips it — render-engine.service.ts line 196: if (!block) continue;. No error is thrown, no warning is logged, and no lint result is produced.

The deleted block simply disappears from the rendered description without any indication that the output is incomplete. A video can silently lose description content with no user-facing signal.

There is no lint rule that checks for orphaned block IDs in blockOrder. If a block that is referenced by many videos is deleted, all of those videos will render incomplete descriptions until their blockOrder is manually cleaned up.


BullMQ Job Deduplication

BullMQ deduplicates jobs by jobId — if a job with the same ID already exists in the queue and has not yet run, the new submission is silently ignored. Using a static ID like lint-{videoId} is intentional for normal lint enqueueing (prevents duplicate lint jobs from piling up). For forced reruns — such as "rerun all lint checks" — use a timestamp suffix to bypass deduplication:

jobId: `lint-${videoId}-${Date.now()}`

Without the suffix, the "rerun" submits a job that is immediately deduplicated against the existing queued job and never actually runs.