# denopipe

A YouTube data proxy service built on Deno. Wraps the YouTube InnerTube API and exposes structured HTTP endpoints for video playback info, channels, playlists, and search.

## Getting Started

```bash
# Start the dev server (listens on http://localhost:8000 by default)
deno task dev

# Run tests
deno task test

# Type-check and lint
deno task check && deno task lint
```

### Proxy Configuration

Configure an upstream proxy via environment variables:

```bash
HTTPS_PROXY=http://proxy:7890 deno task dev
```

---

## API Overview

All responses are JSON with a unified envelope:

**Success**
```json
{
  "ok": true,
  "data": { ... },
  "meta": { "requestId": "...", "clientUsed": "desktop", "degraded": false, "cache": { ... }, "durationMs": 120 }
}
```

**Error**
```json
{
  "ok": false,
  "error": { "code": "NOT_FOUND", "message": "...", "retryable": false },
  "meta": { ... }
}
```

### Common Query Parameters

The following parameters are accepted by all `/v1/*` endpoints:

| Parameter | Description | Default |
|-----------|-------------|---------|
| `hl` | Language code (e.g. `en`, `zh-CN`) | `en` |
| `gl` | Region code (e.g. `US`, `CN`) | `US` |
| `tz` | Timezone (e.g. `UTC`, `Asia/Shanghai`) | `UTC` |
| `visitorData` | Manually supply a Visitor Data token | auto-managed |

---

## Endpoints

### GET /healthz

Health check.

```bash
curl http://localhost:8000/healthz
```

```json
{
  "ok": true,
  "data": { "status": "ok", "botguardReady": true }
}
```

---

### GET /v1/resolve

Resolve any YouTube URL or ID into a normalized resource identifier.

```
GET /v1/resolve?input=<url_or_id>
```

**Parameters**

| Parameter | Required | Description |
|-----------|----------|-------------|
| `input` | Yes | A YouTube URL or ID (video ID, channel ID, playlist ID, channel handle, etc.) |

**Examples**

```bash
# Resolve a video URL
curl "http://localhost:8000/v1/resolve?input=https://www.youtube.com/watch?v=dQw4w9WgXcQ"

# Resolve a channel handle
curl "http://localhost:8000/v1/resolve?input=@username"

# Resolve a playlist ID
curl "http://localhost:8000/v1/resolve?input=PLxxxxxx"
```

**Response**

```json
{
  "ok": true,
  "data": {
    "kind": "video",
    "id": "dQw4w9WgXcQ",
    "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "startTime": 30
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `kind` | `video` \| `channel` \| `playlist` \| `album` | Resource type |
| `id` | string | Resource ID |
| `url` | string | Canonical URL |
| `startTime` | number? | Start time in seconds (video only) |

---

### GET /v1/player/:videoId

Fetch full playback info for a video, including all audio/video stream URLs and subtitles.

```
GET /v1/player/<videoId>
```

**Parameters**

| Parameter | Description |
|-----------|-------------|
| `prefer` | `web` — prefer the Web client |
| `client` | Force a specific client: `desktop` \| `desktop_music` \| `mobile` \| `tv` \| `android` \| `ios` |

**Examples**

```bash
curl "http://localhost:8000/v1/player/dQw4w9WgXcQ"

# Force iOS client
curl "http://localhost:8000/v1/player/dQw4w9WgXcQ?client=ios"
```

**Response**

```json
{
  "ok": true,
  "data": {
    "id": "dQw4w9WgXcQ",
    "title": "...",
    "description": "...",
    "durationSeconds": 212,
    "channelId": "UCuAXFkgsw1L7xaCfnd5JJOw",
    "channelName": "Rick Astley",
    "viewCount": 1000000000,
    "isLive": false,
    "isLiveContent": false,
    "thumbnails": [
      { "url": "...", "width": 1280, "height": 720 }
    ],
    "expiresInSeconds": 21540,
    "validUntil": "2026-04-19T12:00:00.000Z",
    "videoStreams": [
      {
        "url": "...",
        "mimeType": "video/mp4; codecs=\"avc1.640028\"",
        "bitrate": 2500000,
        "width": 1920,
        "height": 1080,
        "fps": 30,
        "qualityLabel": "1080p",
        "contentLength": 12345678,
        "durationMs": 212000,
        "hasAudio": false,
        "hasVideo": true
      }
    ],
    "audioStreams": [
      {
        "url": "...",
        "mimeType": "audio/webm; codecs=\"opus\"",
        "bitrate": 160000,
        "audioSampleRate": "48000",
        "audioChannels": 2,
        "hasAudio": true,
        "hasVideo": false
      }
    ],
    "subtitles": [
      {
        "url": "...",
        "language": "en",
        "label": "English",
        "isAutoGenerated": false
      }
    ],
    "hlsManifestUrl": "...",
    "dashManifestUrl": "..."
  },
  "meta": {
    "requestId": "abc123",
    "clientUsed": "ios",
    "degraded": false,
    "degradedFrom": null,
    "cache": {
      "visitorData": "memory",
      "clientVersion": "kv",
      "botguard": "memory"
    },
    "durationMs": 850
  }
}
```

> **Note:** Stream URLs in `videoStreams` and `audioStreams` expire — see `expiresInSeconds`. Re-fetch when expired.

---

### GET /v1/video/:videoId

Fetch video metadata without stream URLs. Useful for displaying video info without triggering full player resolution.

```
GET /v1/video/<videoId>
```

**Parameters:** same as `/v1/player` (`prefer`, `client`).

**Example**

```bash
curl "http://localhost:8000/v1/video/dQw4w9WgXcQ?hl=en"
```

**Response**

```json
{
  "ok": true,
  "data": {
    "id": "dQw4w9WgXcQ",
    "title": "...",
    "description": "...",
    "channel": {
      "id": "UCuAXFkgsw1L7xaCfnd5JJOw",
      "name": "Rick Astley",
      "handle": "@RickAstleyYT",
      "url": "https://www.youtube.com/@RickAstleyYT",
      "verification": "verified",
      "thumbnails": [{ "url": "..." }]
    },
    "thumbnails": [{ "url": "...", "width": 1280, "height": 720 }],
    "durationSeconds": 212,
    "viewCount": 1000000000,
    "isLive": false,
    "isLiveContent": false
  }
}
```

---

### GET /v1/search

Search YouTube content with optional type filtering and pagination.

```
GET /v1/search?q=<query>
```

**Parameters**

| Parameter | Description |
|-----------|-------------|
| `q` | Search query (required) |
| `type` | Filter by type: `video` \| `channel` \| `playlist` (omit for no filter) |
| `cursor` | Pagination cursor from a previous response |

**Examples**

```bash
# Search for videos
curl "http://localhost:8000/v1/search?q=deno+javascript&type=video"

# Fetch the next page
curl "http://localhost:8000/v1/search?q=deno+javascript&type=video&cursor=<cursor_from_previous>"
```

**Response**

```json
{
  "ok": true,
  "data": {
    "query": "deno javascript",
    "correctedQuery": null,
    "items": [
      {
        "id": "dQw4w9WgXcQ",
        "kind": "video",
        "title": "...",
        "subtitle": "...",
        "thumbnails": [{ "url": "...", "width": 1280, "height": 720 }],
        "author": {
          "id": "UC...",
          "name": "...",
          "handle": "@...",
          "verification": "none"
        },
        "durationSeconds": 600,
        "viewCount": 50000,
        "publishedText": "2 years ago",
        "isLive": false,
        "isUpcoming": false,
        "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
      }
    ],
    "cursor": "eyJlbmRwb2ludCI6...",
    "visitorData": "..."
  }
}
```

---

### GET /v1/channel/:channelId

Fetch channel info and content listing with pagination.

```
GET /v1/channel/<channelId>
```

`channelId` accepts either a `UC…` channel ID or a `@handle`.

**Parameters**

| Parameter | Description | Default |
|-----------|-------------|---------|
| `tab` | Content tab: `videos` \| `shorts` \| `live` \| `playlists` | `videos` |
| `order` | Sort order: `newest` \| `popular` \| `oldest` | `newest` |
| `cursor` | Pagination cursor | — |

**Examples**

```bash
# Latest videos
curl "http://localhost:8000/v1/channel/UCuAXFkgsw1L7xaCfnd5JJOw?tab=videos&order=newest"

# Most popular Shorts
curl "http://localhost:8000/v1/channel/@RickAstleyYT?tab=shorts&order=popular"

# Next page
curl "http://localhost:8000/v1/channel/UCuAXFkgsw1L7xaCfnd5JJOw?cursor=<cursor>"
```

**Response**

```json
{
  "ok": true,
  "data": {
    "id": "UCuAXFkgsw1L7xaCfnd5JJOw",
    "title": "Rick Astley",
    "description": "...",
    "handle": "@RickAstleyYT",
    "thumbnails": [{ "url": "..." }],
    "banner": [{ "url": "...", "width": 2560, "height": 424 }],
    "subscriberCount": 3800000,
    "videoCount": 120,
    "tab": "videos",
    "order": "newest",
    "items": [ /* MediaItem[] */ ],
    "cursor": "eyJlbmRwb2ludCI6...",
    "visitorData": "..."
  }
}
```

---

### GET /v1/playlist/:playlistId

Fetch playlist metadata and its video listing with pagination.

```
GET /v1/playlist/<playlistId>
```

**Parameters**

| Parameter | Description |
|-----------|-------------|
| `cursor` | Pagination cursor |

**Example**

```bash
curl "http://localhost:8000/v1/playlist/PLbpi6ZahtOH6Ar_3GPy3workqa89UKXIm"
```

**Response**

```json
{
  "ok": true,
  "data": {
    "id": "PLbpi6ZahtOH6Ar_3GPy3workqa89UKXIm",
    "title": "...",
    "description": "...",
    "thumbnails": [{ "url": "..." }],
    "author": {
      "id": "UC...",
      "name": "...",
      "handle": "@...",
      "url": "..."
    },
    "itemCount": 42,
    "items": [ /* MediaItem[] */ ],
    "cursor": "eyJlbmRwb2ludCI6..."
  }
}
```

---

## Error Codes

| Code | HTTP Status | Description | Retryable |
|------|-------------|-------------|-----------|
| `BAD_REQUEST` | 400 | Invalid request parameters | No |
| `NOT_FOUND` | 404 | Resource does not exist | No |
| `UNAVAILABLE` | 503 | Service unavailable | Yes |
| `UPSTREAM_ERROR` | 503 | YouTube API returned an error | Yes |
| `UPSTREAM_INVALID` | 502 | YouTube API returned an invalid response | Yes |
| `BOTGUARD_UNAVAILABLE` | 503 | Botguard service unavailable | Yes |
| `INTERNAL_ERROR` | 500 | Internal server error | Yes |

---

## Response Metadata

Every response includes a `meta` object with request diagnostics:

```json
{
  "requestId": "unique-request-id",
  "clientUsed": "ios",
  "degraded": true,
  "degradedFrom": "desktop",
  "cache": {
    "visitorData": "memory",
    "clientVersion": "kv",
    "botguard": "memory"
  },
  "durationMs": 850
}
```

| Field | Description |
|-------|-------------|
| `requestId` | Unique request ID |
| `clientUsed` | The YouTube client that handled the request |
| `degraded` | Whether a client fallback occurred |
| `degradedFrom` | The client that was originally attempted |
| `cache.visitorData` | `manual` / `memory` / `kv` / `fresh` / `none` |
| `cache.clientVersion` | `default` / `kv` / `refreshed` |
| `cache.botguard` | `disabled` / `memory` / `kv` / `cold` / `failed` / `skipped` |
| `durationMs` | Request duration in milliseconds |

---

## Pagination

Endpoints that support pagination (search, channel, playlist) include a `cursor` field in the response. Pass it as the `cursor` query parameter in your next request to fetch the next page. A `null` or absent `cursor` means there are no more pages.

```bash
# First page
curl "http://localhost:8000/v1/search?q=deno"
# → returns cursor: "eyJlbmRwb2ludCI6..."

# Second page
curl "http://localhost:8000/v1/search?q=deno&cursor=eyJlbmRwb2ludCI6..."
```

---

## Internal Endpoints

| Endpoint | Description |
|----------|-------------|
| `GET /_internal/botguard/status` | Get Botguard status |
| `POST /_internal/botguard/warm` | Manually warm up Botguard |
