Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user