{
  "openapi": "3.0.0",
  "info": {
    "title": "Youtarr API",
    "version": "1.86.1",
    "description": "API documentation for Youtarr - YouTube channel downloader and media server integration",
    "license": {
      "name": "ISC"
    }
  },
  "servers": [
    {
      "url": "/",
      "description": "Current server"
    }
  ],
  "components": {
    "securitySchemes": {
      "SessionAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "x-access-token",
        "description": "Session token obtained from /auth/login"
      },
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "x-api-key",
        "description": "API key for external integrations (bookmarklets, shortcuts). Only works for /api/videos/download endpoint."
      }
    }
  },
  "security": [
    {
      "SessionAuth": []
    },
    {
      "ApiKeyAuth": []
    }
  ],
  "tags": [
    {
      "name": "Authentication",
      "description": "User authentication and session management"
    },
    {
      "name": "Setup",
      "description": "Initial setup endpoints (localhost only)"
    },
    {
      "name": "Channels",
      "description": "YouTube channel management"
    },
    {
      "name": "Videos",
      "description": "Video management and downloads"
    },
    {
      "name": "Jobs",
      "description": "Download job management"
    },
    {
      "name": "Configuration",
      "description": "Application configuration"
    },
    {
      "name": "Plex",
      "description": "Plex media server integration"
    },
    {
      "name": "Health",
      "description": "Health check endpoints"
    },
    {
      "name": "API Keys",
      "description": "API key management for external integrations"
    },
    {
      "name": "Playlists",
      "description": "YouTube playlist subscriptions and downloads"
    },
    {
      "name": "Maintenance",
      "description": "Filesystem reconciliation actions"
    },
    {
      "name": "Schedules",
      "description": "Scheduled task status and manual runs"
    }
  ],
  "paths": {
    "/api/keys": {
      "get": {
        "summary": "List API keys",
        "description": "Get all API keys (without the actual key values). Only accessible via session auth.",
        "tags": [
          "API Keys"
        ],
        "responses": {
          "200": {
            "description": "List of API keys",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "keys": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer"
                          },
                          "name": {
                            "type": "string"
                          },
                          "key_prefix": {
                            "type": "string"
                          },
                          "created_at": {
                            "type": "string",
                            "format": "date-time"
                          },
                          "last_used_at": {
                            "type": "string",
                            "format": "date-time"
                          },
                          "is_active": {
                            "type": "boolean"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "API keys cannot manage other API keys"
          }
        }
      },
      "post": {
        "summary": "Create API key",
        "description": "Generate a new API key. The key is only shown once! Only accessible via session auth.",
        "tags": [
          "API Keys"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 100,
                    "description": "Human-readable name for the key"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "API key created successfully",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "message": {
                      "type": "string"
                    },
                    "id": {
                      "type": "integer"
                    },
                    "name": {
                      "type": "string"
                    },
                    "key": {
                      "type": "string",
                      "description": "The full API key (only shown once!)"
                    },
                    "prefix": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid name"
          },
          "403": {
            "description": "API keys cannot create other API keys"
          }
        }
      }
    },
    "/api/keys/{id}": {
      "delete": {
        "summary": "Delete API key",
        "description": "Permanently delete an API key. Only accessible via session auth.",
        "tags": [
          "API Keys"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "API key ID"
          }
        ],
        "responses": {
          "200": {
            "description": "API key deleted successfully"
          },
          "403": {
            "description": "API keys cannot delete other API keys"
          },
          "404": {
            "description": "API key not found"
          }
        }
      }
    },
    "/validateToken": {
      "get": {
        "summary": "Validate token",
        "description": "Check if the current session token is valid.",
        "tags": [
          "Authentication"
        ],
        "responses": {
          "200": {
            "description": "Token is valid",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "valid": {
                      "type": "boolean"
                    },
                    "username": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Invalid or expired token"
          },
          "403": {
            "description": "No token provided"
          }
        }
      }
    },
    "/auth/login": {
      "post": {
        "summary": "User login",
        "description": "Authenticate with username and password to receive a session token.",
        "tags": [
          "Authentication"
        ],
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "username",
                  "password"
                ],
                "properties": {
                  "username": {
                    "type": "string",
                    "maxLength": 32
                  },
                  "password": {
                    "type": "string",
                    "maxLength": 64
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Login successful",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "token": {
                      "type": "string",
                      "description": "Session token to use in x-access-token header"
                    },
                    "expires": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "username": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Invalid credentials"
          },
          "429": {
            "description": "Too many failed login attempts"
          },
          "503": {
            "description": "Authentication not configured"
          }
        }
      }
    },
    "/auth/logout": {
      "post": {
        "summary": "User logout",
        "description": "Invalidate the current session token.",
        "tags": [
          "Authentication"
        ],
        "responses": {
          "200": {
            "description": "Logout successful",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean",
                      "example": true
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "No token provided"
          }
        }
      }
    },
    "/auth/sessions": {
      "get": {
        "summary": "Get active sessions",
        "description": "Retrieve all active sessions for the current user.",
        "tags": [
          "Authentication"
        ],
        "responses": {
          "200": {
            "description": "List of active sessions",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "id": {
                        "type": "integer"
                      },
                      "user_agent": {
                        "type": "string"
                      },
                      "ip_address": {
                        "type": "string"
                      },
                      "createdAt": {
                        "type": "string",
                        "format": "date-time"
                      },
                      "last_used_at": {
                        "type": "string",
                        "format": "date-time"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/auth/sessions/{id}": {
      "delete": {
        "summary": "Delete session",
        "description": "Invalidate a specific session.",
        "tags": [
          "Authentication"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Session ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Session deleted successfully"
          },
          "404": {
            "description": "Session not found"
          }
        }
      }
    },
    "/auth/change-password": {
      "post": {
        "summary": "Change password",
        "description": "Change the current user's password.",
        "tags": [
          "Authentication"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "currentPassword",
                  "newPassword"
                ],
                "properties": {
                  "currentPassword": {
                    "type": "string"
                  },
                  "newPassword": {
                    "type": "string",
                    "minLength": 8,
                    "maxLength": 64
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Password changed successfully"
          },
          "400": {
            "description": "Invalid password format"
          },
          "401": {
            "description": "Current password is incorrect"
          }
        }
      }
    },
    "/auth/validate": {
      "get": {
        "summary": "Validate authentication",
        "description": "Check if the current session is valid.",
        "tags": [
          "Authentication"
        ],
        "responses": {
          "200": {
            "description": "Session is valid",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "valid": {
                      "type": "boolean"
                    },
                    "username": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Invalid or expired token"
          },
          "403": {
            "description": "No token provided"
          }
        }
      }
    },
    "/api/channels/search": {
      "post": {
        "summary": "Search YouTube for channels by free text",
        "description": "Search YouTube for channels via the YouTube Data API (when a key is configured) with a yt-dlp fallback. Results keep YouTube's relevance order and are ephemeral; nothing is persisted. Each result includes a `subscribed` flag reflecting whether an enabled Channel row with that channel_id exists locally. `videoCount` is only populated on the YouTube API path; the yt-dlp fallback returns null for it.",
        "tags": [
          "Channels"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "query"
                ],
                "properties": {
                  "query": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 200,
                    "description": "Search text. Trimmed; control characters rejected.",
                    "example": "Minecraft"
                  },
                  "count": {
                    "type": "integer",
                    "enum": [
                      10,
                      25,
                      50,
                      100
                    ],
                    "default": 25,
                    "description": "Number of results to fetch from YouTube."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Search results",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "results": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "channelId": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "handle": {
                            "type": "string",
                            "nullable": true,
                            "example": "@minecraft"
                          },
                          "url": {
                            "type": "string",
                            "example": "https://www.youtube.com/channel/UC1sELGmy5jp5fQUugmuYlXQ"
                          },
                          "thumbnailUrl": {
                            "type": "string",
                            "nullable": true
                          },
                          "subscriberCount": {
                            "type": "integer",
                            "nullable": true
                          },
                          "videoCount": {
                            "type": "integer",
                            "nullable": true
                          },
                          "description": {
                            "type": "string",
                            "nullable": true
                          },
                          "subscribed": {
                            "type": "boolean"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid query or count"
          },
          "429": {
            "description": "Rate limit exceeded (max 10 requests per minute)"
          },
          "499": {
            "description": "Client closed the request before search completed"
          },
          "502": {
            "description": "Search failed (yt-dlp or API error)"
          },
          "504": {
            "description": "Search timed out (60s server-side limit)"
          }
        }
      }
    },
    "/getchannels": {
      "get": {
        "summary": "Get channels list",
        "description": "Retrieve a paginated list of YouTube channels.",
        "tags": [
          "Channels"
        ],
        "parameters": [
          {
            "in": "query",
            "name": "page",
            "schema": {
              "type": "integer",
              "default": 1
            },
            "description": "Page number"
          },
          {
            "in": "query",
            "name": "pageSize",
            "schema": {
              "type": "integer",
              "default": 20
            },
            "description": "Number of items per page"
          },
          {
            "in": "query",
            "name": "search",
            "schema": {
              "type": "string"
            },
            "description": "Search term to filter channels"
          },
          {
            "in": "query",
            "name": "sortBy",
            "schema": {
              "type": "string"
            },
            "description": "Field to sort by"
          },
          {
            "in": "query",
            "name": "sortOrder",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ]
            },
            "description": "Sort order"
          },
          {
            "in": "query",
            "name": "subFolder",
            "schema": {
              "type": "string"
            },
            "description": "Filter by subfolder"
          }
        ],
        "responses": {
          "200": {
            "description": "List of channels"
          },
          "500": {
            "description": "Failed to fetch channels"
          }
        }
      }
    },
    "/updatechannels": {
      "post": {
        "summary": "Update channels",
        "description": "Add, remove, or update YouTube channels. Accepts either an array of channels or a delta object with add/remove arrays.",
        "tags": [
          "Channels"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "url": {
                          "type": "string"
                        },
                        "enabled": {
                          "type": "boolean"
                        }
                      }
                    }
                  },
                  {
                    "type": "object",
                    "properties": {
                      "add": {
                        "type": "array",
                        "items": {
                          "oneOf": [
                            {
                              "type": "string"
                            },
                            {
                              "type": "object",
                              "properties": {
                                "url": {
                                  "type": "string"
                                },
                                "channel_id": {
                                  "type": "string"
                                },
                                "settings": {
                                  "type": "object",
                                  "description": "Settings chosen in the Add Channel dialog, applied before the channel is enabled. Unknown keys are rejected.",
                                  "properties": {
                                    "auto_download_enabled_tabs": {
                                      "type": "string",
                                      "description": "Comma-separated media types (video, short, livestream); empty turns auto-download off"
                                    },
                                    "video_quality": {
                                      "type": "string",
                                      "nullable": true
                                    },
                                    "audio_format": {
                                      "type": "string",
                                      "nullable": true,
                                      "enum": [
                                        "video_mp3",
                                        "mp3_only"
                                      ]
                                    },
                                    "sub_folder": {
                                      "type": "string",
                                      "nullable": true
                                    }
                                  }
                                }
                              }
                            }
                          ]
                        },
                        "description": "Channel URLs (or objects with url, channel_id, and settings) to enable"
                      },
                      "remove": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        },
                        "description": "Channel URLs to disable"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Channels updated successfully"
          },
          "400": {
            "description": "Invalid payload, or invalid settings on an add item (no channel is changed)"
          },
          "409": {
            "description": "An add item changes the subfolder of a channel that has downloads in progress"
          },
          "500": {
            "description": "Failed to update channels"
          }
        }
      }
    },
    "/addchannelinfo": {
      "post": {
        "summary": "Add channel info",
        "description": "Fetch and add information about a YouTube channel by URL.",
        "tags": [
          "Channels"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "description": "YouTube channel URL"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Channel info retrieved successfully",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string"
                    },
                    "channelInfo": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "string"
                        },
                        "channel_id": {
                          "type": "string"
                        },
                        "title": {
                          "type": "string"
                        },
                        "description": {
                          "type": "string"
                        },
                        "enabled": {
                          "type": "boolean",
                          "description": "true when the channel is already an active subscription; false for new or soft-deleted (restorable) channels"
                        },
                        "existing": {
                          "type": "boolean",
                          "description": "true when the channel row already existed in the database"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "URL is missing"
          },
          "403": {
            "description": "Cookies required"
          },
          "404": {
            "description": "Channel not found"
          },
          "422": {
            "description": "Channel has no downloadable videos; for Releases-only artist channels the message suggests subscribing to its release playlists instead"
          },
          "503": {
            "description": "Unable to connect to YouTube"
          }
        }
      }
    },
    "/getchannelinfo/{channelId}": {
      "get": {
        "summary": "Get channel info",
        "description": "Retrieve detailed information about a specific channel.",
        "tags": [
          "Channels"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "channelId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube channel ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Channel information"
          }
        }
      }
    },
    "/api/channels/{channelId}/tabs": {
      "get": {
        "summary": "Get channel tabs",
        "description": "Get available tabs (videos, shorts, streams) for a channel.",
        "tags": [
          "Channels"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "channelId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube channel ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Available tabs",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "availableTabs": {
                      "type": "array",
                      "items": {
                        "type": "string",
                        "enum": [
                          "videos",
                          "shorts",
                          "streams"
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Failed to get available tabs"
          }
        }
      }
    },
    "/api/channels/{channelId}/tab-stats": {
      "get": {
        "summary": "Get per-tab download stats",
        "description": "For each visible tab, the number of public videos on YouTube (from the tab's auto-generated playlist, refreshed first when older than 24 hours), how many of them are downloaded (including videos deleted locally), how many are ignored, how many are loaded in Youtarr, and the downloaded percentage. Members-only videos are not counted.\n",
        "tags": [
          "Channels"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "channelId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube channel ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Tab stats",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "channelId": {
                      "type": "string"
                    },
                    "tabs": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "object",
                        "properties": {
                          "total": {
                            "type": "integer",
                            "nullable": true
                          },
                          "fetchedAt": {
                            "type": "string",
                            "nullable": true
                          },
                          "downloaded": {
                            "type": "integer"
                          },
                          "ignored": {
                            "type": "integer"
                          },
                          "loaded": {
                            "type": "integer"
                          },
                          "percent": {
                            "type": "integer",
                            "nullable": true
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Channel not found"
          },
          "500": {
            "description": "Failed to get channel tab stats"
          }
        }
      }
    },
    "/api/channels/{channelId}/tabs/{tabType}/auto-download": {
      "patch": {
        "summary": "Update tab auto-download setting",
        "description": "Enable or disable auto-download for a specific channel tab.",
        "tags": [
          "Channels"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "channelId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube channel ID"
          },
          {
            "in": "path",
            "name": "tabType",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "videos",
                "shorts",
                "streams"
              ]
            },
            "description": "Tab type"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "enabled"
                ],
                "properties": {
                  "enabled": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Setting updated successfully"
          },
          "400": {
            "description": "Invalid request"
          },
          "500": {
            "description": "Failed to update setting"
          }
        }
      }
    },
    "/api/channels/{channelId}/tabs/redetect": {
      "post": {
        "summary": "Force re-detection of a channel's available tabs",
        "description": "Bypasses the cached `available_tabs` value and re-probes the channel's tabs via yt-dlp. Preserves `hidden_tabs` and rewrites `auto_download_enabled_tabs` to drop entries whose tab is no longer detected or is currently hidden. Use this when a channel's cached tab list is known to be wrong.\n",
        "tags": [
          "Channels"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "channelId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube channel ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Tabs re-detected successfully",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "availableTabs": {
                      "type": "array",
                      "description": "Effective tabs (detected minus hidden)",
                      "items": {
                        "type": "string",
                        "enum": [
                          "videos",
                          "shorts",
                          "streams"
                        ]
                      }
                    },
                    "detectedTabs": {
                      "type": "array",
                      "description": "Raw tabs found by yt-dlp",
                      "items": {
                        "type": "string",
                        "enum": [
                          "videos",
                          "shorts",
                          "streams"
                        ]
                      }
                    },
                    "hiddenTabs": {
                      "type": "array",
                      "items": {
                        "type": "string",
                        "enum": [
                          "videos",
                          "shorts",
                          "streams"
                        ]
                      }
                    },
                    "autoDownloadEnabledTabs": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Channel not found"
          },
          "500": {
            "description": "Failed to re-detect tabs"
          }
        }
      }
    },
    "/api/channels/{channelId}/settings": {
      "get": {
        "summary": "Get channel settings",
        "description": "Retrieve settings for a specific channel.",
        "tags": [
          "Channels"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "channelId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube channel ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Channel settings"
          },
          "404": {
            "description": "Channel not found (e.g. not subscribed)"
          },
          "500": {
            "description": "Failed to get settings"
          }
        }
      },
      "put": {
        "summary": "Update channel settings",
        "description": "Update settings for a specific channel.",
        "tags": [
          "Channels"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "channelId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube channel ID"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "subfolder": {
                    "type": "string"
                  },
                  "title_filter_regex": {
                    "type": "string"
                  },
                  "additional_tags": {
                    "type": "string",
                    "description": "Add additional and custom tags to newly downloaded videos from the channel. It is text limited to 1000 characters. The tags are separated by a | character, and is restricted to alphanumeric characters, spaces, dashes, underscores, and (of course) | characters."
                  },
                  "m3u_enabled": {
                    "type": "boolean",
                    "description": "Generate a .m3u playlist file in the channel folder"
                  },
                  "m3u_sort_order": {
                    "type": "string",
                    "enum": [
                      "oldest_first",
                      "newest_first"
                    ]
                  },
                  "auto_removal_protected": {
                    "type": "boolean",
                    "description": "Exclude every video of this channel from auto-removal while the channel is subscribed. Setting true clears auto_removal_keep_recent_count."
                  },
                  "auto_removal_keep_recent_count": {
                    "type": "integer",
                    "nullable": true,
                    "description": "Auto-removal always keeps this many of the channel's most recent downloads. Mutually exclusive with auto_removal_protected; rejected while the channel is protected."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Settings updated successfully"
          },
          "409": {
            "description": "Cannot change subfolder while downloads are in progress"
          },
          "500": {
            "description": "Failed to update settings"
          }
        }
      }
    },
    "/api/channels/subfolders": {
      "get": {
        "summary": "Get all subfolders",
        "description": "Get a list of all subfolders used by channels.",
        "tags": [
          "Channels"
        ],
        "responses": {
          "200": {
            "description": "List of subfolders",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                }
              }
            }
          },
          "500": {
            "description": "Failed to get subfolders"
          }
        }
      }
    },
    "/api/channels/using-default-subfolder": {
      "get": {
        "summary": "Get channels using default subfolder",
        "description": "Get count of channels that are using the default subfolder setting.",
        "tags": [
          "Channels"
        ],
        "responses": {
          "200": {
            "description": "Count of channels using default subfolder",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Failed to get count"
          }
        }
      }
    },
    "/api/channels/using-global-file-structure": {
      "get": {
        "summary": "Get channels using the global file structure setting",
        "description": "Get count and names of enabled channels that inherit the global flat-folder-structure default (no per-channel override).",
        "tags": [
          "Channels"
        ],
        "responses": {
          "200": {
            "description": "Count and names of channels inheriting the global file structure",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "count": {
                      "type": "integer",
                      "description": "Total number of channels inheriting the global setting"
                    },
                    "channelNames": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "description": "Channel names for display, capped at the first 10; count reflects the true total"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Failed to get count"
          }
        }
      }
    },
    "/api/channels/{channelId}/filter-preview": {
      "get": {
        "summary": "Preview title filter",
        "description": "Preview which videos would be matched by a title filter regex.",
        "tags": [
          "Channels"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "channelId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube channel ID"
          },
          {
            "in": "query",
            "name": "title_filter_regex",
            "schema": {
              "type": "string"
            },
            "description": "Regex pattern to test"
          }
        ],
        "responses": {
          "200": {
            "description": "Filter preview results"
          },
          "500": {
            "description": "Failed to preview filter"
          }
        }
      }
    },
    "/getchannelvideos/{channelId}": {
      "get": {
        "summary": "Get channel videos",
        "description": "Retrieve a paginated list of videos for a specific channel.",
        "tags": [
          "Channels"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "channelId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube channel ID"
          },
          {
            "in": "query",
            "name": "page",
            "schema": {
              "type": "integer",
              "default": 1
            }
          },
          {
            "in": "query",
            "name": "pageSize",
            "schema": {
              "type": "integer",
              "default": 50
            }
          },
          {
            "in": "query",
            "name": "downloadedFilter",
            "schema": {
              "type": "string",
              "enum": [
                "off",
                "only",
                "exclude"
              ],
              "default": "off"
            },
            "description": "Tri-state filter on download status. `only` keeps downloaded videos, `exclude` hides them, `off` returns everything."
          },
          {
            "in": "query",
            "name": "searchQuery",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "sortBy",
            "schema": {
              "type": "string",
              "default": "date"
            }
          },
          {
            "in": "query",
            "name": "sortOrder",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ],
              "default": "desc"
            }
          },
          {
            "in": "query",
            "name": "tabType",
            "schema": {
              "type": "string",
              "enum": [
                "videos",
                "shorts",
                "streams"
              ],
              "default": "videos"
            }
          },
          {
            "in": "query",
            "name": "watchedFilter",
            "schema": {
              "type": "string",
              "enum": [
                "off",
                "only",
                "exclude"
              ],
              "default": "off"
            },
            "description": "Tri-state filter on watched status (per the configured watched rule). `only` keeps watched videos, `exclude` hides them."
          }
        ],
        "responses": {
          "200": {
            "description": "List of channel videos"
          }
        }
      }
    },
    "/fetchallchannelvideos/{channelId}": {
      "post": {
        "summary": "Fetch all channel videos",
        "description": "Trigger a full fetch of all videos from a channel's YouTube page.",
        "tags": [
          "Channels"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "channelId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube channel ID"
          },
          {
            "in": "query",
            "name": "page",
            "schema": {
              "type": "integer",
              "default": 1
            }
          },
          {
            "in": "query",
            "name": "pageSize",
            "schema": {
              "type": "integer",
              "default": 50
            }
          },
          {
            "in": "query",
            "name": "downloadedFilter",
            "schema": {
              "type": "string",
              "enum": [
                "off",
                "only",
                "exclude"
              ],
              "default": "off"
            },
            "description": "Tri-state filter on download status. `only` keeps downloaded videos, `exclude` hides them, `off` returns everything."
          },
          {
            "in": "query",
            "name": "tabType",
            "schema": {
              "type": "string",
              "enum": [
                "videos",
                "shorts",
                "streams"
              ],
              "default": "videos"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Fetch completed"
          },
          "409": {
            "description": "Fetch operation already in progress"
          },
          "500": {
            "description": "Failed to fetch videos"
          }
        }
      }
    },
    "/api/channels/{channelId}/fetch-status": {
      "get": {
        "summary": "Check if a fetch operation is in progress for a channel",
        "description": "Returns whether a fetch operation (like Load More) is currently running for this channel/tab.",
        "tags": [
          "Channels"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "channelId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The channel ID"
          },
          {
            "in": "query",
            "name": "tabType",
            "schema": {
              "type": "string",
              "enum": [
                "videos",
                "shorts",
                "streams"
              ]
            },
            "description": "Tab type to check (if not provided, checks any tab)"
          }
        ],
        "responses": {
          "200": {
            "description": "Fetch status retrieved successfully",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "isFetching": {
                      "type": "boolean"
                    },
                    "startTime": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "type": {
                      "type": "string"
                    },
                    "tabType": {
                      "type": "string"
                    },
                    "progress": {
                      "type": "object",
                      "description": "Load More progress, present only on a tab-specific check of a Load More fetch",
                      "properties": {
                        "itemsFetched": {
                          "type": "integer",
                          "description": "Entries read from the tab so far (saving stage - entries read in total)"
                        },
                        "stage": {
                          "type": "string",
                          "enum": [
                            "listing",
                            "saving"
                          ]
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/channels/{channelId}/download-all/preview": {
      "get": {
        "summary": "Preview a channel download-all run",
        "description": "Returns how many known videos on a channel tab would be downloaded by a download-all run, plus their total content duration. Run a full fetch (fetchallchannelvideos) first for an accurate count.",
        "tags": [
          "Channels"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "channelId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube channel ID"
          },
          {
            "in": "query",
            "name": "tabType",
            "schema": {
              "type": "string",
              "enum": [
                "videos",
                "shorts",
                "streams"
              ],
              "default": "videos"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Preview computed",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "count": {
                      "type": "integer"
                    },
                    "totalDurationSeconds": {
                      "type": "integer"
                    },
                    "missingDurations": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid tabType"
          },
          "404": {
            "description": "Channel not found"
          },
          "500": {
            "description": "Failed to compute preview"
          }
        }
      }
    },
    "/api/channels/{channelId}/download-all": {
      "post": {
        "summary": "Download all videos for a channel tab",
        "description": "Queues a single download job covering every known, never-downloaded video on the channel tab (previously downloaded videos are excluded, even if since deleted). The job is exempt from the absolute runtime cap and may run for days; it can be cancelled from the Downloads page and completed videos are kept.",
        "tags": [
          "Channels"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "channelId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube channel ID"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "tabType": {
                    "type": "string",
                    "enum": [
                      "videos",
                      "shorts",
                      "streams"
                    ],
                    "default": "videos"
                  },
                  "overrideSettings": {
                    "type": "object",
                    "description": "Optional per-run overrides. allowRedownload is not supported here and is ignored (previously downloaded videos are always excluded).",
                    "properties": {
                      "resolution": {
                        "type": "string",
                        "enum": [
                          "360",
                          "480",
                          "720",
                          "1080",
                          "1440",
                          "2160"
                        ]
                      },
                      "skipVideoFolder": {
                        "type": "boolean"
                      },
                      "subfolder": {
                        "type": "string",
                        "nullable": true
                      },
                      "audioFormat": {
                        "type": "string",
                        "nullable": true,
                        "enum": [
                          "video_mp3",
                          "mp3_only"
                        ]
                      },
                      "rating": {
                        "type": "string",
                        "nullable": true
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Download job queued",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string"
                    },
                    "queued": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid tabType or overrideSettings"
          },
          "404": {
            "description": "Channel not found"
          },
          "409": {
            "description": "Downloads are paused because a storage limit was reached (Settings > Storage Limits); the error message gives the reason",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "Downloads are paused: downloaded videos use 512.0 GB, over the 500 GB limit"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Failed to start download"
          }
        }
      }
    },
    "/api/channels/{channelId}/videos/{youtubeId}/ignore": {
      "post": {
        "summary": "Ignore a video",
        "description": "Mark a channel video as ignored so it won't be downloaded.",
        "tags": [
          "Channels"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "channelId",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "youtubeId",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Video ignored successfully"
          },
          "404": {
            "description": "Channel video not found"
          },
          "500": {
            "description": "Failed to ignore video"
          }
        }
      }
    },
    "/api/channels/{channelId}/videos/{youtubeId}/unignore": {
      "post": {
        "summary": "Unignore a video",
        "description": "Remove the ignored status from a channel video so it can be downloaded.",
        "tags": [
          "Channels"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "channelId",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "youtubeId",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Video unignored successfully"
          },
          "404": {
            "description": "Channel video not found"
          },
          "500": {
            "description": "Failed to unignore video"
          }
        }
      }
    },
    "/api/channels/{channelId}/videos/bulk-ignore": {
      "post": {
        "summary": "Bulk ignore videos",
        "description": "Mark multiple channel videos as ignored.",
        "tags": [
          "Channels"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "channelId",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "youtubeIds"
                ],
                "properties": {
                  "youtubeIds": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Videos ignored successfully"
          },
          "400": {
            "description": "Invalid request"
          },
          "500": {
            "description": "Failed to bulk ignore videos"
          }
        }
      }
    },
    "/getconfig": {
      "get": {
        "summary": "Get application configuration",
        "description": "Retrieve the current application configuration (sensitive fields are filtered out).",
        "tags": [
          "Configuration"
        ],
        "responses": {
          "200": {
            "description": "Application configuration",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "plexIP": {
                      "type": "string"
                    },
                    "plexPort": {
                      "type": "string"
                    },
                    "plexApiKey": {
                      "type": "string"
                    },
                    "plexLibraryId": {
                      "type": "string"
                    },
                    "downloadResolution": {
                      "type": "string"
                    },
                    "videosToDownload": {
                      "type": "integer"
                    },
                    "channelDownloadFrequency": {
                      "type": "string"
                    },
                    "watchStatusSyncFrequency": {
                      "type": "string"
                    },
                    "autoRemovalFrequency": {
                      "type": "string"
                    },
                    "archiveBackfillFrequency": {
                      "type": "string"
                    },
                    "sessionCleanupFrequency": {
                      "type": "string"
                    },
                    "videoRescanFrequency": {
                      "type": "string"
                    },
                    "ytdlpUpdateFrequency": {
                      "type": "string"
                    },
                    "channelVideoCountsFrequency": {
                      "type": "string"
                    },
                    "logLevel": {
                      "type": "string",
                      "enum": [
                        "",
                        "warn",
                        "info",
                        "debug"
                      ]
                    },
                    "logging": {
                      "type": "object",
                      "description": "LOG_LEVEL value and log file status (read-only)"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/updateconfig": {
      "post": {
        "summary": "Update application configuration",
        "description": "Update the application configuration. Sensitive fields (passwordHash, username) are protected.",
        "tags": [
          "Configuration"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "plexIP": {
                    "type": "string"
                  },
                  "plexPort": {
                    "type": "string"
                  },
                  "plexApiKey": {
                    "type": "string"
                  },
                  "plexLibraryId": {
                    "type": "string"
                  },
                  "downloadResolution": {
                    "type": "string"
                  },
                  "videosToDownload": {
                    "type": "integer"
                  },
                  "channelDownloadFrequency": {
                    "type": "string"
                  },
                  "watchStatusSyncFrequency": {
                    "type": "string"
                  },
                  "autoRemovalFrequency": {
                    "type": "string"
                  },
                  "archiveBackfillFrequency": {
                    "type": "string"
                  },
                  "sessionCleanupFrequency": {
                    "type": "string"
                  },
                  "videoRescanFrequency": {
                    "type": "string"
                  },
                  "ytdlpUpdateFrequency": {
                    "type": "string"
                  },
                  "channelVideoCountsFrequency": {
                    "type": "string"
                  },
                  "logLevel": {
                    "type": "string",
                    "enum": [
                      "",
                      "warn",
                      "info",
                      "debug"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Configuration updated successfully",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "example": "success"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid configuration; schedule errors include a fieldErrors object keyed by config field"
          }
        }
      }
    },
    "/api/config/filename-preview": {
      "post": {
        "summary": "Render a videoFilenamePrefix against a canned sample video",
        "description": "Calls yt-dlp with `--load-info-json` against a bundled sample\nfixture and returns the rendered file and folder names. yt-dlp's\ntemplate engine is the authority on grammar; a syntactically\ninvalid template returns 400 with yt-dlp's own error message.\n",
        "tags": [
          "Configuration"
        ],
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "prefix"
                ],
                "properties": {
                  "prefix": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Rendered preview"
          },
          "400": {
            "description": "Prefix failed safety checks or yt-dlp rejected the template"
          },
          "401": {
            "description": "Missing or invalid auth token"
          },
          "429": {
            "description": "Rate limit exceeded"
          }
        }
      }
    },
    "/api/cookies/status": {
      "get": {
        "summary": "Get cookie file status",
        "description": "Check uploaded cookies and validate the current external file with yt-dlp when configured. Unusable external cookies are omitted from operations. Does not verify YouTube authentication.",
        "tags": [
          "Configuration"
        ],
        "responses": {
          "200": {
            "description": "Cookie status",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "cookiesEnabled": {
                      "type": "boolean"
                    },
                    "customCookiesUploaded": {
                      "type": "boolean"
                    },
                    "customFileExists": {
                      "type": "boolean"
                    },
                    "external": {
                      "type": "object",
                      "description": "Present only when YOUTARR_COOKIES_FILE is configured.",
                      "properties": {
                        "path": {
                          "type": "string"
                        },
                        "ready": {
                          "type": "boolean",
                          "description": "yt-dlp loaded cookies from the current file and a private working copy can be created."
                        },
                        "lastModified": {
                          "type": "string",
                          "format": "date-time",
                          "nullable": true
                        },
                        "warning": {
                          "type": "string",
                          "nullable": true,
                          "description": "Safe summary of parser warnings. Only cookies loaded by yt-dlp are used."
                        },
                        "error": {
                          "type": "string",
                          "nullable": true
                        }
                      }
                    },
                    "details": {
                      "type": "object",
                      "nullable": true,
                      "description": "Summary of YouTube login cookies in the active cookie file. Null when cookies are disabled, no file is active, or the file cannot be read. Never includes cookie values.",
                      "properties": {
                        "loginCookiesFound": {
                          "type": "integer",
                          "description": "Distinct YouTube login cookie names (SID, SAPISID, __Secure-3PSID, LOGIN_INFO, ...) on youtube.com."
                        },
                        "sessionLoginCookies": {
                          "type": "integer",
                          "description": "Login cookies with no expiry (session cookies)."
                        },
                        "expiredLoginCookies": {
                          "type": "integer"
                        },
                        "earliestExpiry": {
                          "type": "string",
                          "format": "date-time",
                          "nullable": true,
                          "description": "Earliest expiry among login cookies that have one, including already-expired ones."
                        },
                        "earliestExpiryName": {
                          "type": "string",
                          "nullable": true
                        },
                        "lastModified": {
                          "type": "string",
                          "format": "date-time"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Failed to get cookie status"
          }
        }
      }
    },
    "/api/cookies/test": {
      "post": {
        "summary": "Test the active cookie file",
        "description": "Requests YouTube's subscriptions feed with the active cookies, using the same proxy, IP family, and cache settings as downloads, to check whether they still belong to a signed-in session. An account with no subscriptions still passes. Rate limited.",
        "tags": [
          "Configuration"
        ],
        "responses": {
          "200": {
            "description": "Test completed. Check `ok` for the result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "message": {
                      "type": "string",
                      "description": "Present when ok is true."
                    },
                    "code": {
                      "type": "string",
                      "enum": [
                        "EXPIRED_COOKIES",
                        "BOT_CHECK",
                        "NETWORK",
                        "TIMEOUT",
                        "INVALID_COOKIE_FILE",
                        "UNKNOWN"
                      ],
                      "description": "Present when ok is false."
                    },
                    "error": {
                      "type": "string",
                      "description": "Present when ok is false."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Cookies are disabled or no cookie file is active."
          },
          "409": {
            "description": "A cookie test is already running."
          },
          "429": {
            "description": "Too many cookie tests."
          },
          "500": {
            "description": "Failed to run the cookie test"
          }
        }
      }
    },
    "/api/cookies/upload": {
      "post": {
        "summary": "Upload cookie file",
        "description": "Upload a Netscape format cookie file for YouTube authentication.",
        "tags": [
          "Configuration"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "cookieFile": {
                    "type": "string",
                    "format": "binary",
                    "description": "Netscape format cookie file"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Cookie file uploaded successfully. `cookieStatus` has the same shape as GET /api/cookies/status, including details."
          },
          "400": {
            "description": "Invalid file or format"
          },
          "409": {
            "description": "Cookies are managed externally via YOUTARR_COOKIES_FILE."
          },
          "500": {
            "description": "Failed to upload cookie file"
          }
        }
      }
    },
    "/api/cookies": {
      "delete": {
        "summary": "Delete cookie file",
        "description": "Remove the custom YouTube cookie file.",
        "tags": [
          "Configuration"
        ],
        "responses": {
          "200": {
            "description": "Cookie file deleted successfully. `cookieStatus` has the same shape as GET /api/cookies/status, including details."
          },
          "409": {
            "description": "Cookies are managed externally via YOUTARR_COOKIES_FILE."
          },
          "500": {
            "description": "Failed to delete cookie file"
          }
        }
      }
    },
    "/api/notifications/test": {
      "post": {
        "summary": "Send test notification",
        "description": "Send a test notification to verify notification settings.",
        "tags": [
          "Configuration"
        ],
        "responses": {
          "200": {
            "description": "Test notification sent successfully"
          },
          "500": {
            "description": "Failed to send test notification"
          }
        }
      }
    },
    "/api/notifications/test-single": {
      "post": {
        "summary": "Send test notification to a single webhook",
        "description": "Send a test notification to a specific webhook URL to verify it works.",
        "tags": [
          "Configuration"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "description": "The notification URL to test"
                  },
                  "name": {
                    "type": "string",
                    "description": "The name of the notification service"
                  },
                  "richFormatting": {
                    "type": "boolean",
                    "description": "Whether to use rich formatting"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Test notification sent successfully"
          },
          "400": {
            "description": "Invalid request (missing URL)"
          },
          "500": {
            "description": "Failed to send test notification"
          }
        }
      }
    },
    "/storage-status": {
      "get": {
        "summary": "Get storage status",
        "description": "Retrieve storage usage information for the downloads directory.",
        "tags": [
          "Configuration"
        ],
        "responses": {
          "200": {
            "description": "Storage status",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "total": {
                      "type": "integer",
                      "description": "Total space in bytes"
                    },
                    "used": {
                      "type": "integer",
                      "description": "Used space in bytes"
                    },
                    "free": {
                      "type": "integer",
                      "description": "Free space in bytes"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Failed to retrieve storage status"
          }
        }
      }
    },
    "/api/health": {
      "get": {
        "summary": "Health check endpoint",
        "description": "Returns the health status of the server. Unauthenticated for Docker health checks.",
        "tags": [
          "Health"
        ],
        "security": [],
        "responses": {
          "200": {
            "description": "Server is healthy",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "example": "healthy"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/db-status": {
      "get": {
        "summary": "Database status endpoint",
        "description": "Returns the database health status including connection and schema validity.",
        "tags": [
          "Health"
        ],
        "security": [],
        "responses": {
          "200": {
            "description": "Database is healthy",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "example": "healthy"
                    },
                    "database": {
                      "type": "object",
                      "properties": {
                        "connected": {
                          "type": "boolean"
                        },
                        "schemaValid": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "503": {
            "description": "Database is unhealthy"
          }
        }
      }
    },
    "/getCurrentReleaseVersion": {
      "get": {
        "summary": "Get current release version",
        "description": "Fetches the latest Youtarr version from Docker Hub and the installed yt-dlp version.",
        "tags": [
          "Health"
        ],
        "security": [],
        "responses": {
          "200": {
            "description": "Version information",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "version": {
                      "type": "string",
                      "description": "Latest Youtarr version from Docker Hub"
                    },
                    "ytDlpVersion": {
                      "type": "string",
                      "description": "Installed yt-dlp version"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Failed to fetch version"
          }
        }
      }
    },
    "/api/ytdlp/latest-version": {
      "get": {
        "summary": "Get yt-dlp version information",
        "description": "Returns the current installed yt-dlp version and the latest available version from GitHub for the configured update channel (stable or nightly).",
        "tags": [
          "Health"
        ],
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Version information",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "currentVersion": {
                      "type": "string",
                      "description": "Currently installed yt-dlp version"
                    },
                    "latestVersion": {
                      "type": "string",
                      "description": "Latest yt-dlp version from GitHub"
                    },
                    "updateAvailable": {
                      "type": "boolean",
                      "description": "Whether an update is available"
                    },
                    "channel": {
                      "type": "string",
                      "enum": [
                        "stable",
                        "nightly"
                      ],
                      "description": "Configured yt-dlp update channel"
                    },
                    "lastChecked": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true,
                      "description": "When an update check (scheduled or manual) last ran"
                    },
                    "lastUpdated": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true,
                      "description": "When a new version was last installed"
                    },
                    "lastResult": {
                      "type": "object",
                      "nullable": true,
                      "description": "Outcome of the most recent check ({ status, message?, version? })"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "500": {
            "description": "Failed to fetch version information"
          }
        }
      }
    },
    "/api/ytdlp/update": {
      "post": {
        "summary": "Update yt-dlp",
        "description": "Updates yt-dlp to the latest release of the configured update channel (stable or nightly) via --update-to.",
        "tags": [
          "Health"
        ],
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Update result",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean",
                      "description": "Whether the update succeeded"
                    },
                    "message": {
                      "type": "string",
                      "description": "Status message"
                    },
                    "newVersion": {
                      "type": "string",
                      "description": "New version after update (if applicable)"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "yt-dlp is managed by the hosting platform"
          },
          "409": {
            "description": "An update is already running"
          },
          "500": {
            "description": "Update failed"
          },
          "503": {
            "description": "The task is not registered yet (server still starting or database unavailable)"
          }
        }
      }
    },
    "/api/jobs/video-activity": {
      "get": {
        "summary": "Get queued and downloading video activity",
        "tags": [
          "Jobs"
        ],
        "responses": {
          "200": {
            "description": "Transient activity snapshot; entries disappear when work ends",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "instanceId": {
                      "type": "string"
                    },
                    "revision": {
                      "type": "integer"
                    },
                    "videos": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "object",
                        "properties": {
                          "jobId": {
                            "type": "string"
                          },
                          "state": {
                            "type": "string",
                            "enum": [
                              "queued",
                              "downloading"
                            ]
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Authentication required"
          }
        }
      }
    },
    "/jobstatus/{jobId}": {
      "get": {
        "summary": "Get job status",
        "description": "Retrieve the status of a specific download job.",
        "tags": [
          "Jobs"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "jobId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Job ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Job status",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "jobId": {
                      "type": "string"
                    },
                    "jobType": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string"
                    },
                    "progress": {
                      "type": "number"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Job not found"
          }
        }
      }
    },
    "/runningjobs": {
      "get": {
        "summary": "Get running jobs",
        "description": "Retrieve a list of currently running download jobs.",
        "tags": [
          "Jobs"
        ],
        "responses": {
          "200": {
            "description": "List of running jobs",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "jobId": {
                        "type": "string"
                      },
                      "jobType": {
                        "type": "string"
                      },
                      "status": {
                        "type": "string"
                      },
                      "progress": {
                        "type": "number"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/jobs/current-activity": {
      "get": {
        "summary": "Get current download activity",
        "description": "Snapshot of the current (or most recent) download run for the activity page. Combines the live progress monitor state with the last stored final-state message so a freshly-opened page can render immediately without waiting for a WebSocket broadcast.\n",
        "tags": [
          "Jobs"
        ],
        "responses": {
          "200": {
            "description": "Current activity snapshot",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "jobId": {
                      "type": "string",
                      "nullable": true
                    },
                    "capturedAt": {
                      "type": "number",
                      "nullable": true,
                      "description": "Epoch ms of the monitor's last activity"
                    },
                    "terminal": {
                      "type": "boolean",
                      "description": "True when yt-dlp is not currently running"
                    },
                    "activity": {
                      "type": "object",
                      "nullable": true,
                      "description": "Structured progress payload (same shape as downloadProgress broadcasts)"
                    },
                    "lastFinalActivity": {
                      "type": "object",
                      "nullable": true,
                      "description": "Last stored final-state downloadProgress payload, if any"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Failed to get current download activity"
          }
        }
      }
    },
    "/api/jobs/download-pause": {
      "get": {
        "summary": "Get the storage download pause state",
        "description": "Whether downloads are paused because a storage limit was reached (downloadPauseUsageLimit or downloadPauseMinFreeSpace), with the reasons and current measurements. Measured fresh on each request. Changes are also broadcast as downloadPauseChanged WebSocket messages.\n",
        "tags": [
          "Jobs"
        ],
        "responses": {
          "200": {
            "description": "Download pause status",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "paused": {
                      "type": "boolean"
                    },
                    "pausedSince": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "reasons": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "type": {
                            "type": "string",
                            "enum": [
                              "usage",
                              "freeSpace"
                            ]
                          },
                          "currentBytes": {
                            "type": "number"
                          },
                          "limitBytes": {
                            "type": "number"
                          },
                          "text": {
                            "type": "string"
                          }
                        }
                      }
                    },
                    "usage": {
                      "type": "object",
                      "description": "Configured usage limit and total size of downloaded videos (always measured for this endpoint; downloadedBytes is null when it could not be measured)"
                    },
                    "freeSpace": {
                      "type": "object",
                      "description": "Configured free-space minimum and available bytes (null when not configured or unavailable)"
                    },
                    "checkedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Failed to get the download pause state"
          }
        }
      }
    },
    "/api/jobs/terminate": {
      "post": {
        "summary": "Terminate current job",
        "description": "Terminate the currently running download job.",
        "tags": [
          "Jobs"
        ],
        "responses": {
          "200": {
            "description": "Job termination initiated",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "jobId": {
                      "type": "string"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "No job is currently running"
          },
          "500": {
            "description": "Failed to terminate job"
          }
        }
      }
    },
    "/api/logs/download": {
      "get": {
        "summary": "Download the log files",
        "description": "Returns every rolling log file in config/logs, oldest first, combined into one plain-text attachment. Configured API keys and tokens, token query parameters, and proxy credentials are replaced with [REDACTED].",
        "tags": [
          "Configuration"
        ],
        "responses": {
          "200": {
            "description": "Combined log files",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid auth token"
          },
          "404": {
            "description": "No log files have been written yet"
          },
          "500": {
            "description": "The log folder could not be read"
          }
        }
      }
    },
    "/api/maintenance/rescan-files": {
      "post": {
        "summary": "Kick off a manual filesystem rescan",
        "tags": [
          "Maintenance"
        ],
        "responses": {
          "202": {
            "description": "Rescan started"
          },
          "409": {
            "description": "A rescan is already in progress, or the task cannot start (reason in the body)"
          },
          "503": {
            "description": "The task is not registered yet (server still starting or database unavailable)"
          }
        }
      }
    },
    "/api/maintenance/rescan-status": {
      "get": {
        "summary": "Get current rescan running state and last-run summary",
        "tags": [
          "Maintenance"
        ],
        "responses": {
          "200": {
            "description": "Status object"
          }
        }
      }
    },
    "/api/mediaservers/watch-status": {
      "get": {
        "summary": "Get watch status sync state",
        "tags": [
          "Media Servers"
        ],
        "responses": {
          "200": {
            "description": "Whether a sync is running and the last run's summary"
          }
        }
      }
    },
    "/api/mediaservers/watch-status/sync": {
      "post": {
        "summary": "Trigger a watch status sync now",
        "tags": [
          "Media Servers"
        ],
        "responses": {
          "202": {
            "description": "Sync started"
          },
          "409": {
            "description": "Sync already running, or it cannot start (reason in the body, for example no-media-server)"
          },
          "503": {
            "description": "The task is not registered yet (server still starting or database unavailable)"
          }
        }
      }
    },
    "/api/playlists": {
      "get": {
        "summary": "List subscribed playlists",
        "tags": [
          "Playlists"
        ],
        "parameters": [
          {
            "in": "query",
            "name": "page",
            "schema": {
              "type": "integer",
              "default": 1
            }
          },
          {
            "in": "query",
            "name": "pageSize",
            "schema": {
              "type": "integer",
              "default": 25,
              "maximum": 100
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated playlists. Each playlist includes downloaded_count, the number of its videos with a file on disk now (downloaded and later deleted videos are not counted)."
          },
          "500": {
            "description": "Internal server error"
          }
        }
      },
      "post": {
        "summary": "Subscribe to a playlist, restoring a soft-deleted one if present",
        "description": "Saves the subscription, fetches its videos, and starts background sync/M3U generation. Restores saved settings. Expected following setup failures return 201 with a warning and turn auto-download off for size/completeness errors; a concurrent refresh keeps the current setting.",
        "tags": [
          "Playlists"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "description": "YouTube playlist URL"
                  },
                  "settings": {
                    "type": "object",
                    "description": "Optional per-playlist settings, applied only when the playlist is new. Accepts auto_download, sync_to_plex, sync_to_jellyfin, sync_to_emby, public_on_servers, default_sub_folder, video_quality, min_duration, max_duration, title_filter_regex, audio_format, default_rating, and sort_order; other keys are ignored."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Saved playlist, restored flag, and optional following setup warning"
          },
          "400": {
            "description": "Missing url or an invalid settings value"
          },
          "500": {
            "description": "Internal server error"
          }
        }
      }
    },
    "/api/playlists/{playlistId}": {
      "get": {
        "summary": "Get a playlist with download and sync counts",
        "description": "Includes downloaded_count (videos with a file on disk now; downloaded and later deleted videos are not counted), not_downloaded_count, unsyncable_count, following_existing_count (older eligible entries needing explicit selection), and following_requested_count (eligible saved selections not yet downloaded) alongside the playlist row.",
        "tags": [
          "Playlists"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "playlistId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube playlist ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Playlist detail"
          },
          "404": {
            "description": "Playlist not found"
          },
          "500": {
            "description": "Internal server error"
          }
        }
      },
      "delete": {
        "summary": "Unsubscribe from a playlist (soft delete)",
        "tags": [
          "Playlists"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "playlistId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube playlist ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Playlist disabled"
          },
          "404": {
            "description": "Playlist not found"
          },
          "500": {
            "description": "Internal server error"
          }
        }
      },
      "patch": {
        "summary": "Update playlist flags",
        "description": "Accepts enabled, auto_download, sync_to_plex, sync_to_jellyfin, sync_to_emby, and public_on_servers; other fields are ignored. First enabling auto_download refreshes the full playlist and saves a starting point before enabling downloads; resuming with a saved starting point or disabling does not refresh.",
        "tags": [
          "Playlists"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "playlistId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube playlist ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Updated playlist"
          },
          "400": {
            "description": "auto_download must be a boolean"
          },
          "404": {
            "description": "Playlist not found"
          },
          "409": {
            "description": "A playlist refresh is already in progress"
          },
          "422": {
            "description": "Playlist exceeds the 5000-entry automatic following limit"
          },
          "500": {
            "description": "Internal server error"
          },
          "503": {
            "description": "A complete playlist snapshot could not be verified"
          }
        }
      }
    },
    "/api/playlists/addplaylistinfo": {
      "post": {
        "summary": "Fetch YouTube playlist info for a URL",
        "tags": [
          "Playlists"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "description": "YouTube playlist URL"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Playlist info. existing_subscription is null for a playlist Youtarr has never saved; otherwise it reports whether the playlist is subscribed (enabled) and its saved auto_download, default_sub_folder, video_quality, and audio_format, which a restore keeps."
          },
          "400": {
            "description": "url is required"
          },
          "403": {
            "description": "Playlist requires authentication (cookies)"
          },
          "404": {
            "description": "Playlist not found"
          },
          "500": {
            "description": "Internal server error"
          },
          "503": {
            "description": "Unable to reach YouTube"
          }
        }
      }
    },
    "/api/playlists/{playlistId}/settings": {
      "get": {
        "summary": "Get per-playlist download settings",
        "tags": [
          "Playlists"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "playlistId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube playlist ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Playlist settings"
          },
          "404": {
            "description": "Playlist not found"
          },
          "500": {
            "description": "Internal server error"
          }
        }
      },
      "put": {
        "summary": "Update per-playlist download settings",
        "description": "Accepts default_sub_folder, video_quality, min_duration, max_duration, title_filter_regex, audio_format, default_rating, and sort_order.",
        "tags": [
          "Playlists"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "playlistId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube playlist ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Applied settings"
          },
          "400": {
            "description": "Invalid default_sub_folder, sort_order, or title_filter_regex (must be a string or null and compile as a JavaScript regex; playlist title filters match case-insensitively)"
          },
          "404": {
            "description": "Playlist not found"
          },
          "500": {
            "description": "Internal server error"
          }
        }
      }
    },
    "/api/playlists/{playlistId}/videos": {
      "get": {
        "summary": "List playlist videos with download and watch overlay",
        "description": "Each row overlays download state, file details, and watched_by (media server types with a played watch-status row for the video).",
        "tags": [
          "Playlists"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "playlistId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube playlist ID"
          },
          {
            "in": "query",
            "name": "page",
            "schema": {
              "type": "integer",
              "default": 1
            }
          },
          {
            "in": "query",
            "name": "pageSize",
            "schema": {
              "type": "integer",
              "default": 50,
              "maximum": 200
            }
          },
          {
            "in": "query",
            "name": "sortOrder",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc",
                "recent",
                "downloaded",
                "published"
              ]
            },
            "description": "Playlist position, discovery time, download time, or publication date; unknown dates sort last"
          },
          {
            "in": "query",
            "name": "downloadState",
            "schema": {
              "type": "string",
              "enum": [
                "all",
                "downloaded",
                "not_downloaded"
              ]
            }
          },
          {
            "in": "query",
            "name": "watchedState",
            "schema": {
              "type": "string",
              "enum": [
                "all",
                "watched",
                "not_watched"
              ]
            },
            "description": "Filter on watched status (per the configured watched rule); not_watched includes videos with no watch data"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated playlist videos"
          },
          "400": {
            "description": "Invalid downloadState or watchedState"
          },
          "404": {
            "description": "Playlist not found"
          },
          "500": {
            "description": "Internal server error"
          }
        }
      }
    },
    "/api/playlists/{playlistId}/refresh": {
      "post": {
        "summary": "Refresh playlist videos from YouTube",
        "description": "Fetches the full playlist listing (can take a minute for very large playlists), then triggers media server sync and M3U regeneration in the background.",
        "tags": [
          "Playlists"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "playlistId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube playlist ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Number of videos fetched"
          },
          "404": {
            "description": "Playlist not found"
          },
          "409": {
            "description": "A fetch is already in progress for this playlist"
          },
          "500": {
            "description": "Internal server error"
          }
        }
      }
    },
    "/api/playlists/{playlistId}/sync": {
      "post": {
        "summary": "Trigger media server playlist sync",
        "description": "Runs in the background; the outcome lands in playlist_sync_state.",
        "tags": [
          "Playlists"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "playlistId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube playlist ID"
          }
        ],
        "responses": {
          "202": {
            "description": "Sync accepted"
          },
          "404": {
            "description": "Playlist not found"
          },
          "500": {
            "description": "Internal server error"
          }
        }
      }
    },
    "/api/playlists/{playlistId}/download-preview": {
      "get": {
        "summary": "Preview an explicit batch of existing playlist videos",
        "description": "Returns all eligible tracked candidates, selectedIds, and missingDates. Publication ordering selects nothing when any eligible date is unknown. Does not refresh or queue downloads.",
        "tags": [
          "Playlists"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "playlistId",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "order",
            "schema": {
              "type": "string",
              "enum": [
                "published",
                "asc",
                "desc"
              ],
              "default": "published"
            }
          },
          {
            "in": "query",
            "name": "count",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000,
              "default": 5
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Eligible candidates and suggested selection"
          },
          "400": {
            "description": "Invalid order or count"
          },
          "404": {
            "description": "Playlist not found"
          },
          "500": {
            "description": "Preview failed"
          }
        }
      }
    },
    "/api/playlists/{playlistId}/following": {
      "post": {
        "summary": "Start or resume following new playlist entries",
        "description": "First setup refreshes before saving the starting point. Resuming preserves it. An explicit restart skips the current backlog and saved batch requests and preserves whether downloads are paused; queued jobs and files are kept.",
        "tags": [
          "Playlists"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "playlistId",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "restart": {
                    "type": "boolean",
                    "default": false
                  },
                  "videoIds": {
                    "type": "array",
                    "maxItems": 1000,
                    "items": {
                      "type": "string"
                    },
                    "description": "Existing videos to queue and retry automatically; cannot be combined with restart"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Playlist, queued count, and optional queue failure warning"
          },
          "400": {
            "description": "Invalid following options"
          },
          "404": {
            "description": "Playlist not found"
          },
          "409": {
            "description": "A playlist refresh is already in progress, or downloads are paused because a storage limit was reached (Settings > Storage Limits) and the selected videos could not be saved for retry; the error message says which",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "422": {
            "description": "Playlist exceeds the 5000-entry automatic following limit"
          },
          "500": {
            "description": "Following setup failed"
          },
          "503": {
            "description": "A complete playlist snapshot could not be verified"
          }
        }
      }
    },
    "/api/playlists/{playlistId}/download-batch": {
      "post": {
        "summary": "Queue selected eligible existing videos without resetting following",
        "description": "Applies saved download settings. Previously downloaded, unavailable, and ignored videos are excluded. When auto-download is enabled and a starting point exists, requests are saved for retry until downloaded or the starting point is reset. The original selection is queued in full. Scheduled runs allow up to the configured limit of discoveries plus the same number of older saved retries, rotating least-recently-attempted requests first. Requested discoveries use only the discovery allowance. Retry jobs are labelled separately; active downloads are excluded before selection.",
        "tags": [
          "Playlists"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "playlistId",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "videoIds"
                ],
                "properties": {
                  "videoIds": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 1000,
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Queued count and optional queue failure warning. When downloads are paused by a storage limit but the selection can be saved for scheduled retry (auto-download enabled with a starting point), this returns queued 0 with a warning giving the pause reason instead of 409."
          },
          "400": {
            "description": "Invalid video IDs"
          },
          "404": {
            "description": "Playlist not found"
          },
          "409": {
            "description": "Downloads are paused because a storage limit was reached (Settings > Storage Limits); and the selection could not be saved for retry; the error message gives the reason",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "Downloads are paused: downloaded videos use 512.0 GB, over the 500 GB limit"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Queueing failed"
          }
        }
      }
    },
    "/api/playlists/{playlistId}/download": {
      "post": {
        "summary": "Queue downloads for playlist videos",
        "description": "Downloads all not-yet-downloaded videos, or only the ids in videoIds when provided. Fire-and-forget; the post-download hook handles playlist sync and M3U regeneration.",
        "tags": [
          "Playlists"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "playlistId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube playlist ID"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "videoIds": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Specific YouTube video IDs to download (max 1000)"
                  },
                  "overrideSettings": {
                    "type": "object",
                    "description": "One-off download setting overrides"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Download started"
          },
          "400": {
            "description": "Invalid videoIds or overrideSettings"
          },
          "404": {
            "description": "Playlist not found"
          },
          "409": {
            "description": "Downloads are paused because a storage limit was reached (Settings > Storage Limits); the error message gives the reason",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "Downloads are paused: downloaded videos use 512.0 GB, over the 500 GB limit"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Internal server error"
          }
        }
      }
    },
    "/api/playlists/{playlistId}/regenerate-m3u": {
      "post": {
        "summary": "Regenerate the playlist M3U file",
        "tags": [
          "Playlists"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "playlistId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube playlist ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Regeneration result"
          },
          "404": {
            "description": "Playlist not found"
          },
          "500": {
            "description": "Internal server error"
          }
        }
      }
    },
    "/api/playlists/{playlistId}/videos/{ytId}/ignore": {
      "post": {
        "summary": "Ignore a playlist video",
        "description": "Ignored videos are skipped by auto and bulk downloads but remain individually downloadable.",
        "tags": [
          "Playlists"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "playlistId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube playlist ID"
          },
          {
            "in": "path",
            "name": "ytId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube video ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Video ignored"
          },
          "404": {
            "description": "Playlist not found"
          },
          "500": {
            "description": "Internal server error"
          }
        }
      }
    },
    "/api/playlists/{playlistId}/videos/{ytId}/unignore": {
      "post": {
        "summary": "Un-ignore a playlist video",
        "tags": [
          "Playlists"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "playlistId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube playlist ID"
          },
          {
            "in": "path",
            "name": "ytId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube video ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Video no longer ignored"
          },
          "404": {
            "description": "Playlist not found"
          },
          "500": {
            "description": "Internal server error"
          }
        }
      }
    },
    "/getplexlibraries": {
      "get": {
        "summary": "Get Plex libraries",
        "description": "Retrieve available Plex libraries. Can test with provided credentials or use saved config.",
        "tags": [
          "Plex"
        ],
        "parameters": [
          {
            "in": "query",
            "name": "testIP",
            "schema": {
              "type": "string"
            },
            "description": "Test Plex server IP"
          },
          {
            "in": "query",
            "name": "testApiKey",
            "schema": {
              "type": "string"
            },
            "description": "Test Plex API key"
          },
          {
            "in": "query",
            "name": "testPort",
            "schema": {
              "type": "string"
            },
            "description": "Test Plex server port"
          },
          {
            "in": "query",
            "name": "testUseHttps",
            "schema": {
              "type": "boolean"
            },
            "description": "Use HTTPS for test connection"
          }
        ],
        "responses": {
          "200": {
            "description": "List of Plex libraries",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "key": {
                        "type": "string"
                      },
                      "title": {
                        "type": "string"
                      },
                      "type": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/plex/server-identity": {
      "get": {
        "summary": "Get Plex server identity",
        "description": "Reads the Plex server /identity endpoint to report whether the server is claimed by a Plex account. Used by the UI to advise on the correct playlist visibility scope. Accepts the same test credentials as /getplexlibraries, otherwise uses the saved config.\n",
        "tags": [
          "Plex"
        ],
        "parameters": [
          {
            "in": "query",
            "name": "testIP",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "testApiKey",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "testPort",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "testUseHttps",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Plex server identity",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "claimed": {
                      "type": "boolean",
                      "nullable": true
                    },
                    "machineIdentifier": {
                      "type": "string",
                      "nullable": true
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/refreshlibrary": {
      "get": {
        "summary": "Refresh Plex library",
        "description": "Trigger a refresh of the configured Plex library.",
        "tags": [
          "Plex"
        ],
        "responses": {
          "200": {
            "description": "Library refresh initiated"
          },
          "500": {
            "description": "Failed to refresh library"
          }
        }
      }
    },
    "/plex/auth-url": {
      "get": {
        "summary": "Get Plex auth URL",
        "description": "Get the Plex OAuth authentication URL for linking a Plex account.",
        "tags": [
          "Plex"
        ],
        "security": [],
        "responses": {
          "200": {
            "description": "Plex auth URL",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "authUrl": {
                      "type": "string"
                    },
                    "pinId": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Failed to get auth URL"
          },
          "503": {
            "description": "Authentication not configured"
          }
        }
      }
    },
    "/plex/check-pin/{pinId}": {
      "get": {
        "summary": "Check Plex PIN status",
        "description": "Check if a Plex OAuth PIN has been authorized.",
        "tags": [
          "Plex"
        ],
        "security": [],
        "parameters": [
          {
            "in": "path",
            "name": "pinId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Plex PIN ID"
          }
        ],
        "responses": {
          "200": {
            "description": "PIN status",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "authorized": {
                      "type": "boolean"
                    },
                    "authToken": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Failed to check PIN"
          },
          "503": {
            "description": "Authentication not configured"
          }
        }
      }
    },
    "/api/schedules": {
      "get": {
        "summary": "Get the live state and last run of every scheduled task",
        "tags": [
          "Schedules"
        ],
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "One entry per configurable schedule",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "tasks": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "key": {
                            "type": "string",
                            "description": "Config key of the schedule"
                          },
                          "label": {
                            "type": "string"
                          },
                          "enabled": {
                            "type": "boolean",
                            "description": "Whether the owning feature asked for the task to run"
                          },
                          "active": {
                            "type": "boolean",
                            "description": "Whether a timer is armed"
                          },
                          "expression": {
                            "type": "string",
                            "nullable": true,
                            "description": "The cron expression the armed timer uses"
                          },
                          "error": {
                            "type": "string",
                            "nullable": true,
                            "description": "Why the saved expression could not be scheduled"
                          },
                          "running": {
                            "type": "boolean"
                          },
                          "nextRunAt": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true
                          },
                          "lastRun": {
                            "type": "object",
                            "nullable": true,
                            "properties": {
                              "trigger": {
                                "type": "string",
                                "enum": [
                                  "scheduled",
                                  "manual",
                                  "startup"
                                ]
                              },
                              "status": {
                                "type": "string",
                                "enum": [
                                  "running",
                                  "success",
                                  "error",
                                  "skipped",
                                  "interrupted"
                                ]
                              },
                              "outcome": {
                                "type": "string",
                                "nullable": true
                              },
                              "message": {
                                "type": "string",
                                "nullable": true
                              },
                              "startedAt": {
                                "type": "string",
                                "format": "date-time"
                              },
                              "finishedAt": {
                                "type": "string",
                                "format": "date-time",
                                "nullable": true
                              }
                            }
                          },
                          "lastFinishedRun": {
                            "type": "object",
                            "nullable": true,
                            "description": "The newest run that ran to an end (success or error), with the same fields as lastRun; skipped and interrupted runs are left out, so this is the run to time"
                          },
                          "runNow": {
                            "type": "object",
                            "properties": {
                              "available": {
                                "type": "boolean"
                              },
                              "reason": {
                                "type": "string",
                                "nullable": true
                              },
                              "message": {
                                "type": "string",
                                "nullable": true
                              },
                              "availableAt": {
                                "type": "string",
                                "format": "date-time",
                                "nullable": true
                              }
                            }
                          }
                        }
                      }
                    },
                    "serverTime": {
                      "type": "string",
                      "format": "date-time",
                      "description": "Server clock when the response was built; clients time availableAt against it"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Status could not be read"
          }
        }
      }
    },
    "/api/schedules/{key}/run": {
      "post": {
        "summary": "Run a scheduled task now",
        "description": "Starts the task in the background with the manual trigger, using the saved settings. The run appears in the task's history.",
        "tags": [
          "Schedules"
        ],
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "key",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Config key of the schedule, for example videoRescanFrequency"
          }
        ],
        "responses": {
          "202": {
            "description": "The run started"
          },
          "404": {
            "description": "Unknown task"
          },
          "409": {
            "description": "The task cannot run now; reason is running, disabled, cooldown, managed, downloads-paused, no-media-server, youtube-throttled or downloads-active, and availableAt says when it can run again when known"
          },
          "503": {
            "description": "The task is not registered yet (server still starting or database unavailable)"
          }
        }
      }
    },
    "/setup/status": {
      "get": {
        "summary": "Get setup status",
        "description": "Check if initial authentication setup is required.",
        "tags": [
          "Setup"
        ],
        "security": [],
        "responses": {
          "200": {
            "description": "Setup status",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "requiresSetup": {
                      "type": "boolean"
                    },
                    "platformManaged": {
                      "type": "boolean"
                    },
                    "message": {
                      "type": "string",
                      "nullable": true
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/setup/create-auth": {
      "post": {
        "summary": "Create initial authentication",
        "description": "Set up the initial admin username and password. Requires the one-time setup token printed to the container logs and stored in the data volume at config/setup-token.",
        "tags": [
          "Setup"
        ],
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token",
                  "username",
                  "password"
                ],
                "properties": {
                  "token": {
                    "type": "string",
                    "description": "One-time setup token"
                  },
                  "username": {
                    "type": "string",
                    "maxLength": 32
                  },
                  "password": {
                    "type": "string",
                    "minLength": 8,
                    "maxLength": 64
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Setup complete"
          },
          "400": {
            "description": "Invalid input or already configured"
          },
          "401": {
            "description": "Invalid setup token"
          }
        }
      }
    },
    "/api/subfolders": {
      "get": {
        "summary": "List subfolders with usage",
        "description": "Returns every known subfolder with where it is used (channels, playlists, global default, Plex mapping, downloaded files) and whether it can be deleted.",
        "tags": [
          "Subfolders"
        ],
        "responses": {
          "200": {
            "description": "List of subfolders with usage metadata"
          }
        }
      },
      "post": {
        "summary": "Create (register) a subfolder",
        "description": "Persists a subfolder name so it is available in every picker.",
        "tags": [
          "Subfolders"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Registered"
          },
          "400": {
            "description": "Invalid name"
          }
        }
      }
    },
    "/api/subfolders/{name}": {
      "delete": {
        "summary": "Delete a subfolder",
        "description": "Deletes a subfolder only when it is empty on disk and unused by any channel, playlist, the global default, or a Plex mapping.",
        "tags": [
          "Subfolders"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted"
          },
          "400": {
            "description": "Invalid name"
          },
          "404": {
            "description": "Not found"
          },
          "409": {
            "description": "In use or not empty"
          }
        }
      }
    },
    "/api/subscriptions/preview/takeout": {
      "post": {
        "summary": "Preview channels from a Google Takeout CSV",
        "description": "Upload a Google Takeout subscriptions.csv file and receive a preview of channels found, with subscription status.",
        "tags": [
          "Subscriptions"
        ],
        "consumes": [
          "multipart/form-data"
        ],
        "parameters": [
          {
            "in": "formData",
            "name": "file",
            "type": "file",
            "required": true,
            "description": "Google Takeout subscriptions.csv file"
          }
        ],
        "responses": {
          "200": {
            "description": "Preview of channels found in the CSV"
          },
          "400": {
            "description": "Missing file or invalid CSV format"
          },
          "413": {
            "description": "File too large"
          },
          "500": {
            "description": "Server error (e.g. database failure during cross-reference)"
          }
        }
      }
    },
    "/api/subscriptions/preview/cookies": {
      "post": {
        "summary": "Preview channels by fetching subscriptions with cookies",
        "description": "Upload a Netscape cookies.txt file. The server uses yt-dlp to fetch your YouTube subscriptions and returns a preview.",
        "tags": [
          "Subscriptions"
        ],
        "consumes": [
          "multipart/form-data"
        ],
        "parameters": [
          {
            "in": "formData",
            "name": "file",
            "type": "file",
            "required": true,
            "description": "Netscape-format cookies.txt file"
          }
        ],
        "responses": {
          "200": {
            "description": "Preview of channels fetched from YouTube"
          },
          "400": {
            "description": "Missing file or invalid cookies format"
          },
          "422": {
            "description": "No channels found for this account"
          },
          "429": {
            "description": "Rate limited"
          },
          "500": {
            "description": "Server error"
          },
          "502": {
            "description": "yt-dlp error (expired cookies, bot check, network)"
          },
          "504": {
            "description": "yt-dlp timed out"
          }
        }
      }
    },
    "/api/subscriptions/imports": {
      "post": {
        "summary": "Start importing selected channels",
        "description": "Accepts a list of channels to import. Returns immediately with a job ID; the import runs in the background.",
        "tags": [
          "Subscriptions"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "channels": {
                    "type": "array",
                    "items": {
                      "type": "object"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Import started"
          },
          "400": {
            "description": "Missing or empty channels array"
          },
          "409": {
            "description": "An import is already in progress"
          },
          "500": {
            "description": "Server error"
          }
        }
      },
      "get": {
        "summary": "List recent import jobs",
        "description": "Returns a list of recent import job summaries, sorted by most recent first.",
        "tags": [
          "Subscriptions"
        ],
        "responses": {
          "200": {
            "description": "List of import job summaries"
          },
          "500": {
            "description": "Server error"
          }
        }
      }
    },
    "/api/subscriptions/imports/active": {
      "get": {
        "summary": "Get the currently active import",
        "description": "Returns a summary of the active import job, or 204 if no import is running.",
        "tags": [
          "Subscriptions"
        ],
        "responses": {
          "200": {
            "description": "Active import summary"
          },
          "204": {
            "description": "No active import"
          }
        }
      }
    },
    "/api/subscriptions/imports/{jobId}": {
      "get": {
        "summary": "Get details of a specific import job",
        "description": "Returns detailed state for an import job (active or historical).",
        "tags": [
          "Subscriptions"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "jobId",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Import job details"
          },
          "404": {
            "description": "Import job not found"
          },
          "500": {
            "description": "Server error"
          }
        }
      }
    },
    "/api/subscriptions/imports/{jobId}/cancel": {
      "post": {
        "summary": "Cancel an active import",
        "description": "Requests cancellation of the active import. The import will stop after the current channel finishes processing.",
        "tags": [
          "Subscriptions"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "jobId",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Cancellation requested"
          },
          "404": {
            "description": "No active import with this job ID"
          },
          "500": {
            "description": "Server error"
          }
        }
      }
    },
    "/api/videos/{youtubeId}/metadata": {
      "get": {
        "summary": "Get extended video metadata",
        "description": "Returns curated metadata from the cached .info.json file, or fetches it via yt-dlp if not cached.",
        "tags": [
          "Videos"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "youtubeId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube video ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Video metadata"
          },
          "400": {
            "description": "Invalid YouTube ID"
          },
          "500": {
            "description": "Internal server error"
          }
        }
      }
    },
    "/api/videos/{youtubeId}/watch-status": {
      "get": {
        "summary": "Get per-media-server watch status for a video",
        "tags": [
          "Videos"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "youtubeId",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Watch status rows, one per (media server, user) that has synced this video, with resolved user names"
          },
          "400": {
            "description": "Invalid YouTube ID"
          }
        }
      }
    },
    "/api/videos/{youtubeId}/stream": {
      "get": {
        "summary": "Stream a downloaded video file",
        "description": "Serves the downloaded video or audio file with HTTP Range support for seeking. Auth token must be passed as a query parameter since <video> elements cannot set custom headers.",
        "tags": [
          "Videos"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "youtubeId",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "YouTube video ID"
          },
          {
            "in": "query",
            "name": "type",
            "schema": {
              "type": "string",
              "enum": [
                "video",
                "audio"
              ],
              "default": "video"
            },
            "description": "Whether to stream the video or audio file"
          },
          {
            "in": "query",
            "name": "token",
            "schema": {
              "type": "string"
            },
            "description": "Authentication token (required for video element src)"
          }
        ],
        "responses": {
          "200": {
            "description": "Full file response"
          },
          "206": {
            "description": "Partial content (range request)"
          },
          "400": {
            "description": "Invalid YouTube ID"
          },
          "404": {
            "description": "Video or file not found"
          },
          "416": {
            "description": "Range not satisfiable"
          },
          "500": {
            "description": "Internal server error"
          }
        }
      }
    },
    "/api/videos/search": {
      "post": {
        "summary": "Search YouTube by free text",
        "description": "Search YouTube via yt-dlp and return a list of matching videos, sorted newest-to-oldest by publishedAt (entries without a timestamp sort last). Results are ephemeral; nothing is persisted. Each result includes a `status` field (`downloaded`, `missing`, or `never_downloaded`) describing the video's state in the local Video table.",
        "tags": [
          "Videos"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "query"
                ],
                "properties": {
                  "query": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 200,
                    "description": "Search text. Trimmed; control characters rejected.",
                    "example": "Minecraft"
                  },
                  "count": {
                    "type": "integer",
                    "enum": [
                      10,
                      25,
                      50,
                      100
                    ],
                    "default": 25,
                    "description": "Number of results to fetch from YouTube."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Search results",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "results": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "youtubeId": {
                            "type": "string"
                          },
                          "title": {
                            "type": "string"
                          },
                          "channelName": {
                            "type": "string"
                          },
                          "channelId": {
                            "type": "string",
                            "nullable": true
                          },
                          "duration": {
                            "type": "integer",
                            "nullable": true
                          },
                          "thumbnailUrl": {
                            "type": "string",
                            "nullable": true
                          },
                          "publishedAt": {
                            "type": "string",
                            "nullable": true,
                            "format": "date-time"
                          },
                          "viewCount": {
                            "type": "integer",
                            "nullable": true
                          },
                          "status": {
                            "type": "string",
                            "enum": [
                              "downloaded",
                              "missing",
                              "never_downloaded"
                            ]
                          },
                          "databaseId": {
                            "type": "integer",
                            "nullable": true,
                            "description": "Video row id when status is downloaded or missing."
                          },
                          "filePath": {
                            "type": "string",
                            "nullable": true
                          },
                          "fileSize": {
                            "type": "integer",
                            "nullable": true
                          },
                          "audioFilePath": {
                            "type": "string",
                            "nullable": true
                          },
                          "audioFileSize": {
                            "type": "integer",
                            "nullable": true
                          },
                          "addedAt": {
                            "type": "string",
                            "nullable": true,
                            "format": "date-time"
                          },
                          "isProtected": {
                            "type": "boolean",
                            "nullable": true
                          },
                          "normalizedRating": {
                            "type": "string",
                            "nullable": true
                          },
                          "ratingSource": {
                            "type": "string",
                            "nullable": true
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid query or count"
          },
          "429": {
            "description": "Rate limit exceeded (max 10 requests per minute)"
          },
          "499": {
            "description": "Client closed the request before search completed"
          },
          "502": {
            "description": "Search failed (yt-dlp error)"
          },
          "504": {
            "description": "Search timed out (60s server-side limit)"
          }
        }
      }
    },
    "/api/videos/local-status": {
      "post": {
        "summary": "Read local metadata for up to 500 YouTube video IDs",
        "tags": [
          "Videos"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "youtubeIds"
                ],
                "properties": {
                  "youtubeIds": {
                    "type": "array",
                    "maxItems": 500,
                    "items": {
                      "type": "string",
                      "pattern": "^[a-zA-Z0-9_-]{11}$"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Deduplicated results with local status and available file metadata; no YouTube requests",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "results": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "youtubeId": {
                            "type": "string"
                          },
                          "status": {
                            "type": "string",
                            "enum": [
                              "never_downloaded",
                              "missing",
                              "downloaded"
                            ]
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid video IDs or more than 500 IDs"
          },
          "401": {
            "description": "Authentication required"
          },
          "500": {
            "description": "Local metadata lookup failed"
          }
        }
      }
    },
    "/getVideos": {
      "get": {
        "summary": "Get downloaded videos",
        "description": "Retrieve a paginated list of downloaded videos.",
        "tags": [
          "Videos"
        ],
        "parameters": [
          {
            "in": "query",
            "name": "page",
            "schema": {
              "type": "integer",
              "default": 1
            },
            "description": "Page number"
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer",
              "default": 12
            },
            "description": "Number of items per page"
          },
          {
            "in": "query",
            "name": "search",
            "schema": {
              "type": "string"
            },
            "description": "Search term"
          },
          {
            "in": "query",
            "name": "dateFrom",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "Filter videos from this date"
          },
          {
            "in": "query",
            "name": "dateTo",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "Filter videos up to this date"
          },
          {
            "in": "query",
            "name": "sortBy",
            "schema": {
              "type": "string",
              "enum": [
                "added",
                "title",
                "date"
              ],
              "default": "added"
            },
            "description": "Field to sort by"
          },
          {
            "in": "query",
            "name": "sortOrder",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ],
              "default": "desc"
            },
            "description": "Sort order"
          },
          {
            "in": "query",
            "name": "channelFilter",
            "schema": {
              "type": "string"
            },
            "description": "Filter by channel"
          },
          {
            "in": "query",
            "name": "protectedFilter",
            "schema": {
              "type": "string",
              "enum": [
                "off",
                "only",
                "exclude"
              ],
              "default": "off"
            },
            "description": "Tri-state filter on protected videos"
          },
          {
            "in": "query",
            "name": "missingFilter",
            "schema": {
              "type": "string",
              "enum": [
                "off",
                "only",
                "exclude"
              ],
              "default": "off"
            },
            "description": "Tri-state filter on missing (file removed) videos"
          },
          {
            "in": "query",
            "name": "watchedFilter",
            "schema": {
              "type": "string",
              "enum": [
                "off",
                "only",
                "exclude"
              ],
              "default": "off"
            },
            "description": "Tri-state filter on watched videos (per the configured watched rule)"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of videos"
          },
          "500": {
            "description": "Failed to get videos"
          }
        }
      }
    },
    "/api/videos/rating": {
      "post": {
        "summary": "Update video ratings",
        "description": "Update the content rating for multiple videos.",
        "tags": [
          "Videos"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "videoIds": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    }
                  },
                  "rating": {
                    "type": "string",
                    "nullable": true,
                    "description": "Content rating or null to clear"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Ratings updated"
          },
          "400": {
            "description": "Invalid request"
          },
          "500": {
            "description": "Failed to update ratings"
          }
        }
      }
    },
    "/api/videos/{id}/protected": {
      "patch": {
        "summary": "Toggle video protection",
        "description": "Set whether a video is protected from automatic deletion.",
        "tags": [
          "Videos"
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Video database ID"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "protected"
                ],
                "properties": {
                  "protected": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Protection status updated"
          },
          "400": {
            "description": "Invalid request"
          },
          "404": {
            "description": "Video not found"
          },
          "500": {
            "description": "Failed to update protection status"
          }
        }
      }
    },
    "/api/videos": {
      "delete": {
        "summary": "Delete videos",
        "description": "Delete downloaded videos by database IDs or YouTube IDs.",
        "tags": [
          "Videos"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "videoIds": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    },
                    "description": "Database video IDs"
                  },
                  "youtubeIds": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "YouTube video IDs"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Videos deleted successfully"
          },
          "400": {
            "description": "Invalid request"
          },
          "500": {
            "description": "Failed to delete videos"
          }
        }
      }
    },
    "/api/auto-removal/dry-run": {
      "post": {
        "summary": "Auto-removal dry run",
        "description": "Preview which videos would be removed by automatic cleanup without actually deleting them.",
        "tags": [
          "Videos"
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "autoRemovalEnabled": {
                    "type": "boolean"
                  },
                  "autoRemovalVideoAgeThreshold": {
                    "type": "integer",
                    "description": "Age threshold in days"
                  },
                  "autoRemovalFreeSpaceThreshold": {
                    "type": "integer",
                    "description": "Free space threshold in GB"
                  },
                  "autoRemovalWatchedEnabled": {
                    "type": "boolean",
                    "description": "Enable watched-based removal"
                  },
                  "autoRemovalWatchedMinDaysSinceWatched": {
                    "type": "integer",
                    "description": "Only remove videos whose latest watch is at least this many days old"
                  },
                  "autoRemovalWatchedMinVideoAgeDays": {
                    "type": "integer",
                    "description": "Only remove watched videos downloaded at least this many days ago"
                  },
                  "autoRemovalKeepRecentCount": {
                    "type": "integer",
                    "description": "Never remove the N most recently downloaded videos"
                  },
                  "autoRemovalUsageLimit": {
                    "type": "string",
                    "description": "Remove oldest videos while downloaded videos total more than this (e.g. \"500GB\", \"2TB\")"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Dry run results"
          },
          "500": {
            "description": "Failed to perform dry run"
          }
        }
      }
    },
    "/api/checkYoutubeVideoURL": {
      "post": {
        "summary": "Validate YouTube video URL",
        "description": "Validate a YouTube video URL and fetch its metadata.",
        "tags": [
          "Videos"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "description": "YouTube video URL"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Validation result",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "isValidUrl": {
                      "type": "boolean"
                    },
                    "title": {
                      "type": "string"
                    },
                    "duration": {
                      "type": "integer"
                    },
                    "thumbnail": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "URL is required"
          },
          "429": {
            "description": "Too many validation requests"
          },
          "500": {
            "description": "Internal server error"
          }
        }
      }
    },
    "/api/bulkEnrichVideos": {
      "post": {
        "summary": "Enrich a batch of YouTube video IDs with title + channel name",
        "description": "Fetches lightweight metadata (title, channel name) for a batch of YouTube video IDs via YouTube's public oEmbed endpoint. Used by the Manual Download bulk-import flow to turn raw IDs into readable chips without the cost of a full yt-dlp metadata fetch. Outbound requests are rate-limited to protect the server's IP.\n",
        "tags": [
          "Videos"
        ],
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "ids"
                ],
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "YouTube video IDs (11 chars)"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Map of id to metadata; failed lookups are omitted."
          },
          "400": {
            "description": "Invalid body."
          }
        }
      }
    },
    "/api/videos/download": {
      "options": {
        "summary": "CORS preflight for download endpoint",
        "description": "Handle CORS preflight requests for the download endpoint.",
        "tags": [
          "Videos"
        ],
        "security": [],
        "responses": {
          "204": {
            "description": "CORS preflight successful"
          }
        }
      },
      "post": {
        "summary": "Download a YouTube video",
        "description": "Add a YouTube video URL to the download queue. Designed for external integrations (bookmarklets, shortcuts, automations).",
        "tags": [
          "Videos"
        ],
        "security": [
          {
            "ApiKeyAuth": []
          },
          {
            "BearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "description": "YouTube video URL",
                    "example": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
                  },
                  "resolution": {
                    "type": "string",
                    "enum": [
                      "360",
                      "480",
                      "720",
                      "1080",
                      "1440",
                      "2160"
                    ],
                    "description": "Preferred resolution (defaults to server config)"
                  },
                  "subfolder": {
                    "type": "string",
                    "description": "Override subfolder for download"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Video queued for download",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "message": {
                      "type": "string"
                    },
                    "video": {
                      "type": "object",
                      "properties": {
                        "title": {
                          "type": "string"
                        },
                        "thumbnail": {
                          "type": "string"
                        },
                        "duration": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid URL or parameters"
          },
          "401": {
            "description": "Invalid or missing authentication"
          },
          "409": {
            "description": "Downloads are paused because a storage limit was reached (Settings > Storage Limits); the error message gives the reason",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean",
                      "example": false
                    },
                    "error": {
                      "type": "string",
                      "example": "Downloads are paused: downloaded videos use 512.0 GB, over the 500 GB limit"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded"
          }
        }
      }
    },
    "/triggerspecificdownloads": {
      "post": {
        "summary": "Download specific videos",
        "description": "Trigger download of specific YouTube video URLs.",
        "tags": [
          "Videos"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "urls"
                ],
                "properties": {
                  "urls": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Array of YouTube video URLs to download"
                  },
                  "overrideSettings": {
                    "type": "object",
                    "properties": {
                      "resolution": {
                        "type": "string",
                        "enum": [
                          "360",
                          "480",
                          "720",
                          "1080",
                          "1440",
                          "2160"
                        ],
                        "description": "Override download resolution"
                      }
                    }
                  },
                  "videoChannelMap": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "string"
                    },
                    "description": "Optional map of YouTube video ID (11 characters) to owning YouTube channel ID (UC-prefixed), captured from URL validation metadata. Lets tracked channels' per-channel download settings apply to each pasted video.\n"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Download job started"
          },
          "400": {
            "description": "Invalid resolution"
          },
          "409": {
            "description": "Downloads are paused because a storage limit was reached (Settings > Storage Limits); the error message gives the reason",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "Downloads are paused: downloaded videos use 512.0 GB, over the 500 GB limit"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/triggerchanneldownloads": {
      "post": {
        "summary": "Trigger channel downloads",
        "description": "Manually trigger the download of new videos from all enabled channels.",
        "tags": [
          "Videos"
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "overrideSettings": {
                    "type": "object",
                    "properties": {
                      "resolution": {
                        "type": "string",
                        "enum": [
                          "360",
                          "480",
                          "720",
                          "1080",
                          "1440",
                          "2160"
                        ],
                        "description": "Override download resolution"
                      },
                      "videoCount": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 50,
                        "description": "Override number of videos to download per channel"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Channel and playlist update started"
          },
          "400": {
            "description": "Invalid override settings"
          },
          "409": {
            "description": "A channel and playlist update is already running, or downloads are paused because a storage limit was reached (Settings > Storage Limits); the error message gives the reason",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "example": "Downloads are paused: downloaded videos use 512.0 GB, over the 500 GB limit"
                    },
                    "reason": {
                      "type": "string",
                      "enum": [
                        "running",
                        "downloads-paused"
                      ]
                    },
                    "availableAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "The update could not be started"
          },
          "503": {
            "description": "The server has not finished starting"
          }
        }
      }
    },
    "/testYoutubeApiKey": {
      "post": {
        "summary": "Validate a YouTube Data API v3 key",
        "tags": [
          "Configuration"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "apiKey"
                ],
                "properties": {
                  "apiKey": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Validation result (ok=true or ok=false with code+reason)"
          },
          "400": {
            "description": "apiKey missing or empty"
          }
        }
      }
    },
    "/api/ytdlp/validate-args": {
      "post": {
        "summary": "Validate custom yt-dlp arguments",
        "description": "Tokenize, denylist-check, and dry-run user-supplied yt-dlp args via `yt-dlp ... --help`. Argparse runs first; help text is discarded and no network calls are made.",
        "tags": [
          "Configuration"
        ],
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "args"
                ],
                "properties": {
                  "args": {
                    "type": "string",
                    "description": "Raw command-line args, max 2000 characters"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Validation result. `ok=true` means args parsed cleanly; `ok=false` means yt-dlp rejected them and `stderr` contains the message."
          },
          "400": {
            "description": "Input failed local checks (not a string, too long, parse error, or denylisted flag)."
          },
          "401": {
            "description": "Missing or invalid auth token."
          },
          "429": {
            "description": "Rate limit exceeded."
          }
        }
      }
    }
  }
}