Files

7.5 KiB

YouTube Sync API

Base path: /api/v1/youtube-sync

All endpoints require Authorization: Bearer <jwt>.

Related: 13 - Team Settings, 01 - Video Management, 05 - Queue System, 06 - Authentication


How channel connection works

There is no separate channel-connect endpoint. A YouTube channel is connected automatically during the user's first Google OAuth login:

  1. User hits GET /auth/google — redirected to Google consent screen.
  2. Google returns an OAuth callback with accessToken and refreshToken.
  3. AuthService.upsertGoogleUser detects that the user has no team memberships.
  4. It calls the YouTube API (channels.list?mine=true) using the login OAuth tokens to fetch the user's channel ID, name, and uploads playlist ID.
  5. A Team record is created (user as OWNER), and a Channel record is created and linked — with the YouTube access and refresh tokens stored AES-256 encrypted (TOKEN_ENCRYPTION_KEY).

On subsequent logins, if the user already has a team, the access token is refreshed on all channels they own (connectedBy = user.id). A new refresh token is only stored if Google returns one (which only happens on first auth or after explicit re-consent).

Listing connected channels: GET /teams/:teamId/channels — see 09 - Teams API.

Channel disconnect: Not currently implemented. Removing a channel requires direct database access.

Important: The channel OAuth tokens are the same tokens obtained during user login — not a separate OAuth flow. This means one Google account = one YouTube channel connection at team-creation time. Adding a second channel to an existing team is not currently supported through the API.


POST /youtube-sync/channel-import

Pull all videos from a connected YouTube channel into the local database.

Minimum role: EDITOR

Request body:

{ "channelId": "cuid" }

Response: Import summary

{
  "total": 57,
  "created": 12,
  "updated": 45,
  "deleted": 0,
  "deletedTitles": []
}
Field Description
total Total number of video IDs processed from the YouTube playlist
created New Video rows created
updated Existing Video rows updated
deleted Videos soft- or hard-deleted during this import (see channel-purge-deleted for the purge lifecycle)
deletedTitles Titles of soft-deleted videos

There is no skipped field. A video item returned by the YouTube API with no id field is silently dropped and counted in neither created nor updated — this is an edge case in malformed API responses and does not appear in the summary.

Behavior:

  • Fetches the channel's uploads playlist from YouTube page by page.
  • For each video: creates a new Video row (with VideoConfig) or updates the existing one.
  • Sets lastSyncedHash on every upserted video so the YouTube state is the baseline (no false "push pending" after import).
  • Also syncs playlists and their video memberships — subject to the itemCount optimization described below.
  • Does not delete local videos that are missing from YouTube — use channel-purge-deleted for that.

Playlist itemCount optimization: During playlist membership sync, each playlist's current itemCount (from the YouTube API) is compared against the cached count stored in the local Playlist row from the previous import. If the counts match, that playlist's full video-membership sync is skipped entirely — no playlistItems.list API call is made for it. This saves quota on large channels where most playlists are unchanged. The cached itemCount is updated on every import regardless of whether the sync was skipped.

Known limitation: If videos are moved between playlists such that one playlist gains a video and another loses one in the same import run, both playlists may have unchanged itemCount values and their membership sync will be skipped, leaving the local VideoPlaylist join table stale. Use channel-full-refresh to force-sync all playlists.

Costs YouTube API quota. Each page of 50 videos costs 1 playlistItems.list unit; individual video detail fetches cost 1 unit each.


POST /youtube-sync/channel-full-refresh

Force-reimport all videos and playlists for a channel, bypassing the itemCount optimization — all playlists have their full video membership re-synced regardless of cached counts. Use this when a normal import appears to have missed playlist membership changes.

Minimum role: ADMIN

Request body:

{ "channelId": "cuid" }

Response: Same import summary shape as channel-import.


POST /youtube-sync/import-video-ids

Immediately import specific YouTube video IDs into the database, bypassing the normal playlist-based discovery.

Minimum role: ADMIN

Request body:

{
  "channelId": "cuid",
  "videoIds": ["dQw4w9WgXcQ", "abc123"]
}

Response:

{
  "total": 2,
  "created": 1,
  "updated": 1,
  "notFound": []
}

notFound lists any requested YouTube video IDs that the YouTube API did not return (private, deleted, or wrong ID). Unlike channel-import, this endpoint has no deleted/deletedTitles fields — it does not run the purge lifecycle.

Use this for videos that are unlisted or otherwise not reachable via the uploads playlist.


POST /youtube-sync/channels/:channelId/supplemental-ids

Add YouTube video IDs to the channel's supplemental list. Videos on this list are always imported during future channel-import runs, regardless of playlist membership.

Minimum role: ADMIN

Request body:

{ "videoIds": ["dQw4w9WgXcQ"] }

Response: Updated supplemental ID list.


POST /youtube-sync/channel-purge-deleted

Check each local video against YouTube and soft-delete any that no longer exist. Also restores soft-deleted videos that have reappeared.

Minimum role: ADMIN

Request body:

{ "channelId": "cuid" }

Response:

{
  "checked": 200,
  "softDeleted": 3,
  "restored": 0,
  "hardDeleted": 0,
  "softDeletedTitles": ["Old Video Title", ...]
}

Each video follows a three-state lifecycle during purge:

  1. First miss → soft delete. If a video is not found on YouTube and youtubeDeletedAt is null, youtubeDeletedAt is set to now. The video is hidden from normal list queries but remains in the database.
  2. Grace period → no action. If youtubeDeletedAt is already set but fewer than 30 days have passed, the video is left untouched.
  3. 30-day expiry → hard delete. If youtubeDeletedAt is set and is more than 30 days old, the video row (and its related records) is permanently deleted from the database. This is what hardDeleted counts.
  4. Reappearance → restore. If a previously soft-deleted video reappears on YouTube (e.g. it was temporarily private), youtubeDeletedAt is cleared and the video is visible again.

GET /youtube-sync/queue-status

Inspect the current state of the YouTube sync (youtube-sync) BullMQ queue.

Minimum role: Any authenticated team member

Response:

{
  "active": [
    { "jobId": "string", "videoId": "cuid", "videoTitle": "string", "addedAt": "ISO datetime" }
  ],
  "waiting": [...],
  "recentFailed": [
    { "jobId": "string", "videoId": "cuid", "videoTitle": "string", "addedAt": "ISO datetime", "failedReason": "string", "failedAt": "ISO datetime" }
  ],
  "recentCompleted": [
    { "jobId": "string", "videoId": "cuid", "videoTitle": "string", "addedAt": "ISO datetime", "completedAt": "ISO datetime" }
  ]
}

Returns up to 10 recent failed jobs and 5 recent completed jobs.