# YouTube Sync API Base path: `/api/v1/youtube-sync` All endpoints require `Authorization: Bearer `. 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:** ```json { "channelId": "cuid" } ``` **Response:** Import summary ```json { "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:** ```json { "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:** ```json { "channelId": "cuid", "videoIds": ["dQw4w9WgXcQ", "abc123"] } ``` **Response:** ```json { "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:** ```json { "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:** ```json { "channelId": "cuid" } ``` **Response:** ```json { "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:** ```json { "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.