Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)

This commit is contained in:
2026-08-11 12:27:44 +02:00
commit d5af006443
304 changed files with 74604 additions and 0 deletions
@@ -0,0 +1,200 @@
# 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:**
```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.