push
This commit is contained in:
+494
@@ -0,0 +1,494 @@
|
||||
// OpenAPI 3.0 spec for the streamreverse REST API.
|
||||
// Served at GET /api.json and rendered by Swagger UI at GET /.
|
||||
|
||||
const spec = {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: 'bug.tv api',
|
||||
version: '1.0.0'
|
||||
},
|
||||
tags: [
|
||||
{ name: 'meta', description: 'service and provider metadata' },
|
||||
{ name: 'search', description: 'TMDB search and episode browsing' },
|
||||
{ name: 'details', description: 'TMDB media details' },
|
||||
{ name: 'streams', description: 'm3u8 stream lookup from providers' },
|
||||
],
|
||||
paths: {
|
||||
'/providers': {
|
||||
get: {
|
||||
tags: ['meta'],
|
||||
summary: 'List all providers and which are enabled',
|
||||
operationId: 'listProviders',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'provider list',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
all: { type: 'array', items: { type: 'string' } },
|
||||
enabled: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/proxy/media': {
|
||||
get: {
|
||||
tags: ['meta'],
|
||||
summary: 'Proxy upstream media or m3u8 playlists with server-side headers',
|
||||
operationId: 'proxyMedia',
|
||||
parameters: [
|
||||
{
|
||||
name: 'url',
|
||||
in: 'query',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'h',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'JSON-encoded headers to send to the upstream URL',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Proxied upstream response. m3u8 playlist URIs are rewritten to use the proxy.',
|
||||
},
|
||||
400: { $ref: '#/components/responses/BadRequest' },
|
||||
502: { $ref: '#/components/responses/Upstream' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/search': {
|
||||
get: {
|
||||
tags: ['search'],
|
||||
summary: 'Search movies + TV via TMDB',
|
||||
operationId: 'search',
|
||||
parameters: [
|
||||
{
|
||||
name: 'q',
|
||||
in: 'query',
|
||||
required: true,
|
||||
description: 'search text',
|
||||
schema: { type: 'string' },
|
||||
example: 'dune',
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'up to 15 results',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
count: { type: 'integer' },
|
||||
results: { type: 'array', items: { $ref: '#/components/schemas/SearchResult' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { $ref: '#/components/responses/BadRequest' },
|
||||
502: { $ref: '#/components/responses/Upstream' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/trending': {
|
||||
get: {
|
||||
tags: ['search'],
|
||||
summary: 'Trending movies and TV via TMDB',
|
||||
operationId: 'getTrending',
|
||||
parameters: [
|
||||
{
|
||||
name: 'type',
|
||||
in: 'query',
|
||||
required: false,
|
||||
schema: { type: 'string', enum: ['all', 'movie', 'tv'], default: 'all' },
|
||||
},
|
||||
{
|
||||
name: 'window',
|
||||
in: 'query',
|
||||
required: false,
|
||||
schema: { type: 'string', enum: ['day', 'week'], default: 'week' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'up to 20 trending items',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
window: { type: 'string' },
|
||||
count: { type: 'integer' },
|
||||
results: { type: 'array', items: { $ref: '#/components/schemas/SearchResult' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { $ref: '#/components/responses/BadRequest' },
|
||||
502: { $ref: '#/components/responses/Upstream' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/movie/{id}': {
|
||||
get: {
|
||||
tags: ['details'],
|
||||
summary: 'Movie details (genres, rating, images, description)',
|
||||
description: 'Passes through the raw TMDB movie object, plus `poster_url`/`backdrop_url` convenience fields.',
|
||||
operationId: 'getMovieDetails',
|
||||
parameters: [{ $ref: '#/components/parameters/MovieId' }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'TMDB movie object',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/MediaDetails' } } },
|
||||
},
|
||||
404: { $ref: '#/components/responses/NotFound' },
|
||||
502: { $ref: '#/components/responses/Upstream' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/tv/{id}': {
|
||||
get: {
|
||||
tags: ['details'],
|
||||
summary: 'TV show details (genres, rating, images, description)',
|
||||
description: 'Passes through the raw TMDB tv object, plus `poster_url`/`backdrop_url` convenience fields.',
|
||||
operationId: 'getTvDetails',
|
||||
parameters: [{ $ref: '#/components/parameters/TvId' }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'TMDB tv object',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/MediaDetails' } } },
|
||||
},
|
||||
404: { $ref: '#/components/responses/NotFound' },
|
||||
502: { $ref: '#/components/responses/Upstream' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/tv/{id}/seasons': {
|
||||
get: {
|
||||
tags: ['search'],
|
||||
summary: 'List seasons for a TV show',
|
||||
operationId: 'getSeasons',
|
||||
parameters: [{ $ref: '#/components/parameters/TvId' }],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'season list',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tmdb_id: { type: 'string' },
|
||||
count: { type: 'integer' },
|
||||
seasons: { type: 'array', items: { $ref: '#/components/schemas/Season' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { $ref: '#/components/responses/BadRequest' },
|
||||
502: { $ref: '#/components/responses/Upstream' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/tv/{id}/seasons/{season}': {
|
||||
get: {
|
||||
tags: ['search'],
|
||||
summary: 'List episodes for a season',
|
||||
operationId: 'getEpisodes',
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/TvId' },
|
||||
{
|
||||
name: 'season',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'season number',
|
||||
schema: { type: 'integer' },
|
||||
example: 1,
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'episode list',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tmdb_id: { type: 'string' },
|
||||
season: { type: 'integer' },
|
||||
count: { type: 'integer' },
|
||||
episodes: { type: 'array', items: { $ref: '#/components/schemas/Episode' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { $ref: '#/components/responses/BadRequest' },
|
||||
502: { $ref: '#/components/responses/Upstream' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/streams': {
|
||||
get: {
|
||||
tags: ['streams'],
|
||||
summary: 'Fetch m3u8 streams for a movie or TV episode',
|
||||
operationId: 'getStreams',
|
||||
description:
|
||||
'Scrapes every enabled provider in parallel (override with `providers`) and returns deduped, normalized, quality-ranked streams. ' +
|
||||
'For `type=tv` you must pass `season` and `episode`. Optional filters are applied before ranking.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'tmdb',
|
||||
in: 'query',
|
||||
required: true,
|
||||
description: 'TMDB numeric id',
|
||||
schema: { type: 'integer' },
|
||||
example: 438631,
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'movie (default) or tv',
|
||||
schema: { type: 'string', enum: ['movie', 'tv'], default: 'movie' },
|
||||
},
|
||||
{
|
||||
name: 'season',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'season number (required when type=tv)',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
{
|
||||
name: 'episode',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'episode number (required when type=tv)',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
{
|
||||
name: 'providers',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'comma-separated provider names; defaults to the enabled set',
|
||||
schema: { type: 'string' },
|
||||
example: 'videasy,vidfast',
|
||||
},
|
||||
{
|
||||
name: 'minQuality',
|
||||
in: 'query',
|
||||
required: false,
|
||||
schema: { type: 'string', enum: ['any', '480', '720', '1080', '2160'], default: 'any' },
|
||||
},
|
||||
{
|
||||
name: 'audioCodec',
|
||||
in: 'query',
|
||||
required: false,
|
||||
schema: { type: 'string', enum: ['any', 'aac', 'ac3', 'dts', 'opus', 'mp3', 'flac'], default: 'any' },
|
||||
},
|
||||
{
|
||||
name: 'subtitles',
|
||||
in: 'query',
|
||||
required: false,
|
||||
schema: { type: 'string', enum: ['any', 'required', 'lang'], default: 'any' },
|
||||
},
|
||||
{
|
||||
name: 'subtitleLang',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'two-letter lang code, used when subtitles=lang',
|
||||
schema: { type: 'string' },
|
||||
example: 'en',
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'stream list (best quality first)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/StreamList' },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { $ref: '#/components/responses/BadRequest' },
|
||||
500: {
|
||||
description: 'no providers available / internal error',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
parameters: {
|
||||
MovieId: {
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'TMDB movie id',
|
||||
schema: { type: 'integer' },
|
||||
example: 438631,
|
||||
},
|
||||
TvId: {
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'TMDB tv id',
|
||||
schema: { type: 'integer' },
|
||||
example: 1396,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
BadRequest: {
|
||||
description: 'missing or invalid parameters',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
NotFound: {
|
||||
description: 'media id not found on TMDB',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
Upstream: {
|
||||
description: 'TMDB request failed',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: { error: { type: 'string' } },
|
||||
},
|
||||
SearchResult: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tmdb_id: { type: 'integer', example: 438631 },
|
||||
type: { type: 'string', enum: ['movie', 'tv'] },
|
||||
name: { type: 'string', example: 'Dune' },
|
||||
year: { type: 'string', example: '2021' },
|
||||
poster: { type: 'string', format: 'uri' },
|
||||
overview: { type: 'string' },
|
||||
},
|
||||
},
|
||||
MediaDetails: {
|
||||
type: 'object',
|
||||
description: 'raw TMDB media object (movie or tv) with added poster_url/backdrop_url',
|
||||
properties: {
|
||||
id: { type: 'integer', example: 438631 },
|
||||
title: { type: 'string', description: 'movie title' },
|
||||
name: { type: 'string', description: 'tv show name' },
|
||||
overview: { type: 'string', description: 'description' },
|
||||
tagline: { type: 'string' },
|
||||
genres: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
name: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
vote_average: { type: 'number', description: 'rating, 0-10' },
|
||||
vote_count: { type: 'integer' },
|
||||
popularity: { type: 'number' },
|
||||
status: { type: 'string' },
|
||||
runtime: { type: 'integer', description: 'movie runtime in minutes' },
|
||||
number_of_seasons: { type: 'integer', description: 'tv' },
|
||||
number_of_episodes: { type: 'integer', description: 'tv' },
|
||||
release_date: { type: 'string', format: 'date', description: 'movie' },
|
||||
first_air_date: { type: 'string', format: 'date', description: 'tv' },
|
||||
poster_path: { type: 'string' },
|
||||
backdrop_path: { type: 'string' },
|
||||
poster_url: { type: 'string', format: 'uri', description: 'full url (original size)' },
|
||||
backdrop_url: { type: 'string', format: 'uri', description: 'full url (original size)' },
|
||||
images: {
|
||||
type: 'object',
|
||||
description:
|
||||
'all available images from TMDB (posters, backdrops, logos, profiles); every item includes `url` with the full image url',
|
||||
},
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
Season: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
season_number: { type: 'integer' },
|
||||
name: { type: 'string' },
|
||||
episode_count: { type: 'integer' },
|
||||
air_date: { type: 'string', format: 'date' },
|
||||
},
|
||||
},
|
||||
Episode: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
episode_number: { type: 'integer' },
|
||||
name: { type: 'string' },
|
||||
air_date: { type: 'string', format: 'date' },
|
||||
},
|
||||
},
|
||||
Subtitle: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', format: 'uri' },
|
||||
language: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
},
|
||||
},
|
||||
Stream: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
provider: { type: 'string', example: 'videasy' },
|
||||
name: { type: 'string' },
|
||||
url: { type: 'string', format: 'uri', description: 'the m3u8 (or direct) url' },
|
||||
quality: { type: 'string', example: '2160p' },
|
||||
pixels: { type: ['integer', 'null'], example: 2160 },
|
||||
audio: { type: ['string', 'null'], example: 'aac' },
|
||||
type: { type: 'string', example: 'm3u8' },
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'http headers the url needs (e.g. Referer)',
|
||||
additionalProperties: { type: 'string' },
|
||||
},
|
||||
subtitles: { type: 'array', items: { $ref: '#/components/schemas/Subtitle' } },
|
||||
},
|
||||
},
|
||||
StreamList: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tmdb_id: { type: 'integer' },
|
||||
type: { type: 'string', enum: ['movie', 'tv'] },
|
||||
season: { type: ['integer', 'null'] },
|
||||
episode: { type: ['integer', 'null'] },
|
||||
total: { type: 'integer', description: 'streams before filters' },
|
||||
matched: { type: 'integer', description: 'streams after filters' },
|
||||
streams: { type: 'array', items: { $ref: '#/components/schemas/Stream' } },
|
||||
providerErrors: {
|
||||
type: 'object',
|
||||
description: 'per-provider scrape errors',
|
||||
additionalProperties: { type: 'string' },
|
||||
},
|
||||
loadErrors: {
|
||||
type: 'object',
|
||||
description: 'per-provider load errors',
|
||||
additionalProperties: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = spec;
|
||||
@@ -0,0 +1,284 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>tv.bug.tools api</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css" />
|
||||
<style>
|
||||
html { box-sizing: border-box; }
|
||||
*, *:before, *:after { box-sizing: inherit; }
|
||||
body { margin: 0; background: #fafafa; }
|
||||
#fallback { font-family: monospace; padding: 2rem; white-space: pre-wrap; }
|
||||
.swagger-ui .topbar,
|
||||
.swagger-ui .search { display: none !important; }
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.swagger-ui {
|
||||
color: #cdd6f4;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.swagger-ui .topbar {
|
||||
background: #181825;
|
||||
border-bottom: 1px solid #585b70;
|
||||
}
|
||||
|
||||
.swagger-ui .topbar .download-url-wrapper .select-label {
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
.swagger-ui .topbar .download-url-wrapper input[type="text"] {
|
||||
background: #45475a;
|
||||
border: 1px solid #585b70;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.swagger-ui .info,
|
||||
.swagger-ui .info .title,
|
||||
.swagger-ui .info h1,
|
||||
.swagger-ui .info h2,
|
||||
.swagger-ui .info h3,
|
||||
.swagger-ui .info h4,
|
||||
.swagger-ui .info h5 {
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.swagger-ui .info .description,
|
||||
.swagger-ui .info p,
|
||||
.swagger-ui .info li,
|
||||
.swagger-ui .info .base-url {
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
.swagger-ui .scheme-container {
|
||||
background: #313244;
|
||||
box-shadow: none;
|
||||
border: 1px solid #585b70;
|
||||
}
|
||||
|
||||
.swagger-ui .filter .operation-filter-input {
|
||||
border: 1px solid #585b70;
|
||||
background: #45475a;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock-tag {
|
||||
color: #cdd6f4;
|
||||
border-bottom: 1px solid #585b70;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock-tag small {
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock {
|
||||
border: 1px solid #585b70;
|
||||
background: #313244;
|
||||
border-radius: 6px;
|
||||
margin: 0 0 12px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock .opblock-summary {
|
||||
border-color: #585b70;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock .opblock-summary-method {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-weight: 600;
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-get .opblock-summary-method {
|
||||
background: #89b4fa;
|
||||
color: #11111b;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-post .opblock-summary-method {
|
||||
background: #a6e3a1;
|
||||
color: #11111b;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-put .opblock-summary-method {
|
||||
background: #fab387;
|
||||
color: #11111b;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-patch .opblock-summary-method {
|
||||
background: #cba6f7;
|
||||
color: #11111b;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-delete .opblock-summary-method {
|
||||
background: #f38ba8;
|
||||
color: #11111b;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock .opblock-summary-path,
|
||||
.swagger-ui .opblock .opblock-summary-description {
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock-body {
|
||||
background: #181825;
|
||||
border-top: 1px solid #585b70;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock-description-wrapper p,
|
||||
.swagger-ui .opblock-description-wrapper,
|
||||
.swagger-ui .parameter__name,
|
||||
.swagger-ui .parameter__type,
|
||||
.swagger-ui label {
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.swagger-ui .parameter__extension,
|
||||
.swagger-ui .parameter__in {
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
.swagger-ui input[type="text"],
|
||||
.swagger-ui input[type="password"],
|
||||
.swagger-ui input[type="search"],
|
||||
.swagger-ui input[type="email"],
|
||||
.swagger-ui textarea,
|
||||
.swagger-ui select {
|
||||
background: #45475a;
|
||||
border: 1px solid #585b70;
|
||||
color: #cdd6f4;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.swagger-ui input[type="text"]:focus,
|
||||
.swagger-ui textarea:focus,
|
||||
.swagger-ui select:focus {
|
||||
outline: 2px solid #89b4fa;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.swagger-ui .btn {
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.swagger-ui .btn.cancel {
|
||||
background: #45475a;
|
||||
border-color: #585b70;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.swagger-ui .btn.execute {
|
||||
background: #89b4fa;
|
||||
border-color: #89b4fa;
|
||||
color: #11111b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.swagger-ui .btn.execute:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.swagger-ui .responses-inner h4,
|
||||
.swagger-ui .responses-inner h5,
|
||||
.swagger-ui .tab li {
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.swagger-ui .response-col_status,
|
||||
.swagger-ui .response-col_links {
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
.swagger-ui table thead tr th,
|
||||
.swagger-ui table thead tr td {
|
||||
background: #45475a;
|
||||
border-bottom: 1px solid #585b70;
|
||||
color: #cdd6f4;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.swagger-ui table tbody tr td {
|
||||
border-bottom: 1px solid #585b70;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.swagger-ui .model-box,
|
||||
.swagger-ui .model,
|
||||
.swagger-ui .renderedMarkdown code,
|
||||
.swagger-ui .microlight,
|
||||
.swagger-ui .highlight-code pre {
|
||||
background: #11111b;
|
||||
color: #cdd6f4;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
border: 1px solid #585b70;
|
||||
}
|
||||
|
||||
.swagger-ui .prop-type,
|
||||
.swagger-ui .prop-format {
|
||||
color: #94e2d5;
|
||||
}
|
||||
|
||||
.swagger-ui .model-title,
|
||||
.swagger-ui .model-hint {
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
.swagger-ui section.models {
|
||||
border: 1px solid #585b70;
|
||||
background: #313244;
|
||||
}
|
||||
|
||||
.swagger-ui section.models.is-open h4 {
|
||||
border-bottom: 1px solid #585b70;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.swagger-ui .dialog-ux .modal-ux {
|
||||
background: #181825;
|
||||
border: 1px solid #585b70;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.swagger-ui .dialog-ux .modal-ux-header h3 {
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.swagger-ui .errors-wrapper {
|
||||
background: rgba(243, 139, 168, 0.12);
|
||||
border: 1px solid #f38ba8;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.swagger-ui .loading-container .loading::after {
|
||||
color: #94e2d5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
window.addEventListener('load', function () {
|
||||
var ui = SwaggerUIBundle({
|
||||
url: '/api.json',
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
defaultModelsExpandDepth: -1,
|
||||
presets: [SwaggerUIBundle.presets.apis],
|
||||
layout: 'BaseLayout',
|
||||
requestInterceptor: function (req) { return req; },
|
||||
});
|
||||
window.ui = ui;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,524 @@
|
||||
/* https://github.com/oqo0/swagger-themes */
|
||||
@import url('https://unpkg.com/@catppuccin/palette/css/catppuccin.css');
|
||||
|
||||
:root {
|
||||
--block-border-radius: 8px;
|
||||
--innner-block-border-radius: 4px;
|
||||
--icons-opacity: .32;
|
||||
--secondary-icon-opacity: .6;
|
||||
--black-icons-filter: invert(1);
|
||||
}
|
||||
|
||||
[data-md-color-scheme="catppuccin-mocha"] {
|
||||
/* primary colors */
|
||||
--swagger-color: var(--ctp-latte-green);
|
||||
--link-color: var(--ctp-latte-blue);
|
||||
--accept-header-color: var(--ctp-latte-green);
|
||||
|
||||
/* methods colors */
|
||||
--post-method-color: var(--ctp-latte-green);
|
||||
--post-method-background-color: color-mix(in srgb, var(--ctp-latte-green), transparent 90%);
|
||||
--get-method-color: var(--ctp-latte-blue);
|
||||
--get-method-background-color: color-mix(in srgb, var(--ctp-latte-blue), transparent 90%);
|
||||
--head-method-color: var(--ctp-latte-mauve);
|
||||
--head-method-background-color: color-mix(in srgb, var(--ctp-latte-mauve), transparent 90%);
|
||||
--put-method-color: var(--ctp-latte-peach);
|
||||
--put-method-background-color: color-mix(in srgb, var(--ctp-latte-peach), transparent 90%);
|
||||
--delete-method-color: var(--ctp-latte-red);
|
||||
--delete-method-background-color: color-mix(in srgb, var(--ctp-latte-red), transparent 90%);
|
||||
--options-method-color: var(--ctp-latte-sky);
|
||||
--options-method-background-color: color-mix(in srgb, var(--ctp-latte-sky), transparent 90%);
|
||||
--patch-method-color: var(--ctp-latte-teal);
|
||||
--patch-method-background-color: color-mix(in srgb, var(--ctp-latte-teal), transparent 90%);
|
||||
|
||||
/* background */
|
||||
--all-bg-color: var(--ctp-latte-base);
|
||||
--secondary-bg-color: var(--ctp-latte-mantle);
|
||||
--header-bg-color: var(--ctp-latte-mantle);
|
||||
--block-bg-color: var(--ctp-latte-mantle);
|
||||
--selecter-bg-color: var(--ctp-latte-mantle);
|
||||
--teteriary-bg-color: var(--ctp-latte-crust);
|
||||
|
||||
/* text */
|
||||
--primary-text-color: var(--ctp-latte-text);
|
||||
--secondary-text-color: var(--ctp-latte-subtext0);
|
||||
|
||||
/* border */
|
||||
--block-border-color: var(--ctp-latte-surface0);
|
||||
|
||||
/* icons */
|
||||
--primary-icon-color: var(--ctp-latte-text);
|
||||
}
|
||||
|
||||
[data-md-color-scheme="catppuccin-latte"] {
|
||||
/* primary colors */
|
||||
--swagger-color: var(--ctp-mocha-green);
|
||||
--link-color: var(--ctp-mocha-blue);
|
||||
--accept-header-color: var(--ctp-mocha-green);
|
||||
|
||||
/* methods colors */
|
||||
--post-method-color: var(--ctp-mocha-green);
|
||||
--post-method-background-color: color-mix(in srgb, var(--ctp-mocha-green), transparent 90%);
|
||||
--get-method-color: var(--ctp-mocha-blue);
|
||||
--get-method-background-color: color-mix(in srgb, var(--ctp-mocha-blue), transparent 90%);
|
||||
--head-method-color: var(--ctp-mocha-mauve);
|
||||
--head-method-background-color: color-mix(in srgb, var(--ctp-mocha-mauve), transparent 90%);
|
||||
--put-method-color: var(--ctp-mocha-peach);
|
||||
--put-method-background-color: color-mix(in srgb, var(--ctp-mocha-peach), transparent 90%);
|
||||
--delete-method-color: var(--ctp-mocha-red);
|
||||
--delete-method-background-color: color-mix(in srgb, var(--ctp-mocha-red), transparent 90%);
|
||||
--options-method-color: var(--ctp-mocha-sky);
|
||||
--options-method-background-color: color-mix(in srgb, var(--ctp-mocha-sky), transparent 90%);
|
||||
--patch-method-color: var(--ctp-mocha-teal);
|
||||
--patch-method-background-color: color-mix(in srgb, var(--ctp-mocha-teal), transparent 90%);
|
||||
|
||||
/* background */
|
||||
--all-bg-color: var(--ctp-mocha-base);
|
||||
--secondary-bg-color: var(--ctp-mocha-mantle);
|
||||
--header-bg-color: var(--ctp-mocha-mantle);
|
||||
--block-bg-color: var(--ctp-mocha-mantle);
|
||||
--selecter-bg-color: var(--ctp-mocha-mantle);
|
||||
--teteriary-bg-color: var(--ctp-mocha-crust);
|
||||
|
||||
/* text */
|
||||
--primary-text-color: var(--ctp-mocha-text);
|
||||
--secondary-text-color: var(--ctp-mocha-subtext0);
|
||||
|
||||
/* border */
|
||||
--block-border-color: var(--ctp-mocha-surface0);
|
||||
|
||||
/* icons */
|
||||
--primary-icon-color: var(--ctp-mocha-text);
|
||||
}
|
||||
|
||||
/* primary colors */
|
||||
|
||||
.swagger-ui .topbar .download-url-wrapper .select-label select {
|
||||
border: 2px solid var(--swagger-color);
|
||||
}
|
||||
|
||||
.swagger-ui .info .title small.version-stamp {
|
||||
background-color: var(--swagger-color);
|
||||
}
|
||||
|
||||
.swagger-ui .info a {
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
.swagger-ui .response-control-media-type--accept-controller select {
|
||||
border-color: var(--accept-header-color);
|
||||
}
|
||||
|
||||
.swagger-ui .response-control-media-type__accept-message {
|
||||
color: var(--accept-header-color);
|
||||
}
|
||||
|
||||
.swagger-ui .btn.authorize {
|
||||
color: var(--post-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .btn.authorize {
|
||||
border-color: var(--post-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .btn.authorize svg {
|
||||
fill: var(--post-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .json-schema-2020-12__attribute--primary {
|
||||
color: var(--head-method-color);
|
||||
}
|
||||
|
||||
/* methods colors */
|
||||
/* http post */
|
||||
|
||||
.swagger-ui .opblock.opblock-post .opblock-summary-method {
|
||||
background: var(--post-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-post .opblock-summary {
|
||||
border-color: var(--post-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-post {
|
||||
background: var(--post-method-background-color);
|
||||
border-color: var(--post-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span::after {
|
||||
background: var(--post-method-color);
|
||||
}
|
||||
|
||||
/* http get */
|
||||
|
||||
.swagger-ui .opblock.opblock-get .opblock-summary-method {
|
||||
background: var(--get-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-get .opblock-summary {
|
||||
border-color: var(--get-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-get {
|
||||
background: var(--get-method-background-color);
|
||||
border-color: var(--get-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span::after {
|
||||
background: var(--get-method-color);
|
||||
}
|
||||
|
||||
/* http head */
|
||||
|
||||
.swagger-ui .opblock.opblock-head .opblock-summary-method {
|
||||
background: var(--head-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-head .opblock-summary {
|
||||
border-color: var(--head-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-head {
|
||||
background: var(--head-method-background-color);
|
||||
border-color: var(--head-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-head .tab-header .tab-item.active h4 span::after {
|
||||
background: var(--head-method-color);
|
||||
}
|
||||
|
||||
/* http put */
|
||||
|
||||
.swagger-ui .opblock.opblock-put .opblock-summary-method {
|
||||
background: var(--put-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-put .opblock-summary {
|
||||
border-color: var(--put-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-put {
|
||||
background: var(--put-method-background-color);
|
||||
border-color: var(--put-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span::after {
|
||||
background: var(--put-method-color);
|
||||
}
|
||||
|
||||
/* http delete */
|
||||
|
||||
.swagger-ui .opblock.opblock-delete .opblock-summary-method {
|
||||
background: var(--delete-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-delete .opblock-summary {
|
||||
border-color: var(--delete-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-delete {
|
||||
background: var(--delete-method-background-color);
|
||||
border-color: var(--delete-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span::after {
|
||||
background: var(--delete-method-color);
|
||||
}
|
||||
|
||||
/* http options */
|
||||
|
||||
.swagger-ui .opblock.opblock-options .opblock-summary-method {
|
||||
background: var(--options-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-options .opblock-summary {
|
||||
border-color: var(--options-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-options {
|
||||
background: var(--options-method-background-color);
|
||||
border-color: var(--options-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-options .tab-header .tab-item.active h4 span::after {
|
||||
background: var(--options-method-color);
|
||||
}
|
||||
|
||||
/* http patch */
|
||||
|
||||
.swagger-ui .opblock.opblock-patch .opblock-summary-method {
|
||||
background: var(--patch-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-patchs .opblock-summary {
|
||||
border-color: var(--patch-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-patch {
|
||||
background: var(--patch-method-background-color);
|
||||
border-color: var(--patch-method-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock.opblock-patch .tab-header .tab-item.active h4 span::after {
|
||||
background: var(--patch-method-color);
|
||||
}
|
||||
|
||||
/* blocks */
|
||||
body {
|
||||
background-color: var(--all-bg-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.swagger-ui .topbar {
|
||||
background-color: var(--header-bg-color);
|
||||
}
|
||||
|
||||
.swagger-ui .scheme-container {
|
||||
background: var(--secondary-bg-color);
|
||||
}
|
||||
|
||||
.swagger-ui section.models .model-container {
|
||||
background: var(--secondary-bg-color);
|
||||
border-radius: var(--innner-block-border-radius);
|
||||
}
|
||||
|
||||
.swagger-ui select {
|
||||
background: var(--selecter-bg-color);
|
||||
border-radius: var(--block-border-radius);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui section.models {
|
||||
border: 1px solid var(--block-border-color);
|
||||
background-color: var(--block-bg-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock .opblock-section-header {
|
||||
background: var(--secondary-bg-color);
|
||||
}
|
||||
|
||||
.swagger-ui .body-param__example {
|
||||
background-color: var(--secondary-bg-color) !important;
|
||||
border-radius: var(--block-border-radius) !important;
|
||||
}
|
||||
|
||||
.swagger-ui .example {
|
||||
background-color: var(--secondary-bg-color) !important;
|
||||
border-radius: var(--block-border-radius) !important;
|
||||
}
|
||||
|
||||
.swagger-ui .copy-to-clipboard {
|
||||
background: rgba(255, 255, 255, var(--icons-opacity));
|
||||
border-radius: var(--block-border-radius);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock .opblock-summary-method {
|
||||
border-radius: var(--innner-block-border-radius);
|
||||
}
|
||||
|
||||
.swagger-ui input[type="email"],
|
||||
.swagger-ui input[type="file"],
|
||||
.swagger-ui input[type="password"],
|
||||
.swagger-ui input[type="search"],
|
||||
.swagger-ui input[type="text"],
|
||||
.swagger-ui textarea {
|
||||
background: var(--secondary-bg-color);
|
||||
border: 1px solid var(--block-border-color);
|
||||
border-radius: var(--block-border-radius);
|
||||
color: var(--primary-text-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.swagger-ui .dialog-ux .modal-ux-header {
|
||||
border-bottom: 1px solid var(--block-border-color);
|
||||
}
|
||||
|
||||
.swagger-ui .btn {
|
||||
border: 2px solid var(--block-border-color);
|
||||
border-radius: var(--block-border-radius);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .dialog-ux .modal-ux {
|
||||
background: var(--block-bg-color);
|
||||
border: 1px solid var(--block-border-color);
|
||||
border-radius: var(--block-border-radius);
|
||||
}
|
||||
|
||||
.swagger-ui .auth-btn-wrapper {
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock-tag {
|
||||
border-bottom: 1px solid var(--block-border-color);
|
||||
}
|
||||
|
||||
.swagger-ui section.models.is-open h4 {
|
||||
border-bottom: 1px solid var(--block-border-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock {
|
||||
border-radius: var(--block-border-radius);
|
||||
}
|
||||
|
||||
.swagger-ui section.models {
|
||||
border-radius: var(--block-border-radius);
|
||||
}
|
||||
|
||||
/* button white outline fix */
|
||||
|
||||
.swagger-ui .model-box-control:focus,
|
||||
.swagger-ui .models-control:focus,
|
||||
.swagger-ui .opblock-summary-control:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* icons */
|
||||
|
||||
.swagger-ui .model-toggle::after {
|
||||
opacity: var(--icons-opacity);
|
||||
filter: var(--black-icons-filter);
|
||||
}
|
||||
|
||||
.swagger-ui svg:not(:root) {
|
||||
fill: var(--primary-icon-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock-summary-control svg:not(:root) {
|
||||
opacity: var(--secondary-icon-opacity);
|
||||
}
|
||||
|
||||
/* text */
|
||||
|
||||
.swagger-ui {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .info .title {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui a.nostyle {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .model-title {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .models-control {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .dialog-ux .modal-ux-header h3 {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .dialog-ux .modal-ux-content h4 {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .dialog-ux .modal-ux-content p {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui ol {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui h3 {
|
||||
color: var(--primary-text-color) !important;
|
||||
}
|
||||
|
||||
.swagger-ui h4 {
|
||||
color: var(--primary-text-color) !important;
|
||||
}
|
||||
|
||||
.swagger-ui h5 {
|
||||
color: var(--secondary-text-color) !important;
|
||||
}
|
||||
|
||||
.swagger-ui label {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock .opblock-section-header h4 {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .tab li button.tablinks {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock-description-wrapper p,
|
||||
.swagger-ui .opblock-external-docs-wrapper p,
|
||||
.swagger-ui .opblock-title_normal p {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.opblock-summary-description {
|
||||
color: var(--secondary-text-color) !important;
|
||||
}
|
||||
|
||||
.swagger-ui table thead tr td, .swagger-ui table thead tr th {
|
||||
border-bottom: 1px solid var(--block-border-color);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .response-col_status {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .response-col_links {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .parameter__name {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .parameter__type {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .prop-format {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .info li,
|
||||
.swagger-ui .info p,
|
||||
.swagger-ui .info table {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .model {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock-tag small {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .json-schema-2020-12__title,
|
||||
.swagger-ui .json-schema-2020-12-keyword__name--primary,
|
||||
.swagger-ui .json-schema-2020-12-property .json-schema-2020-12__title {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.swagger-ui .json-schema-2020-12-expand-deep-button {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
/* json */
|
||||
.swagger-ui .json-schema-2020-12-accordion,
|
||||
.swagger-ui .json-schema-2020-12-expand-deep-button {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.swagger-ui .json-schema-2020-12 {
|
||||
background-color: var(--teteriary-bg-color);
|
||||
}
|
||||
|
||||
.swagger-ui .json-schema-2020-12__constraint--string {
|
||||
background-color: var(--put-method-color);
|
||||
}
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Readable } = require('stream');
|
||||
|
||||
const { loadConfig } = require('../mod/config');
|
||||
const tmdb = require('../mod/tmdb');
|
||||
const { listAll, loadProviders } = require('../mod/loadProviders');
|
||||
const { scrapeAll } = require('../mod/scrape');
|
||||
const { normalize } = require('../mod/normalize');
|
||||
const { applyFilters, rankStreams } = require('../mod/filter');
|
||||
const spec = require('./openapi');
|
||||
|
||||
const cfg = loadConfig();
|
||||
const PORT = process.env.STREAM_API_PORT ? parseInt(process.env.STREAM_API_PORT, 10) : cfg.apiPort;
|
||||
const HOST = process.env.STREAM_API_HOST || cfg.apiHost;
|
||||
const SWAGGER_HTML = path.join(__dirname, 'public', 'index.html');
|
||||
|
||||
function json(res, status, obj) {
|
||||
const body = JSON.stringify(obj);
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function html(res, status, text, type = 'text/html; charset=utf-8') {
|
||||
res.writeHead(status, { 'Content-Type': type, 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-store' });
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
const PROXY_DISALLOWED = new Set([
|
||||
'host',
|
||||
'connection',
|
||||
'content-length',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
'keep-alive',
|
||||
'proxy-authorization',
|
||||
'proxy-authentication',
|
||||
'te',
|
||||
'trailer',
|
||||
'via',
|
||||
'warning',
|
||||
'expect',
|
||||
'date',
|
||||
'dnt',
|
||||
'accept-encoding',
|
||||
]);
|
||||
|
||||
function parseProxyHeaders(raw) {
|
||||
if (!raw) return {};
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
||||
const out = {};
|
||||
for (const [name, value] of Object.entries(parsed)) {
|
||||
if (typeof value !== 'string' || !value) continue;
|
||||
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) continue;
|
||||
if (PROXY_DISALLOWED.has(name.toLowerCase())) continue;
|
||||
out[name] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function proxyTarget(target, headers) {
|
||||
const query = new URLSearchParams({ url: target, h: JSON.stringify(headers || {}) });
|
||||
return `/proxy/media?${query.toString()}`;
|
||||
}
|
||||
|
||||
function rewriteM3u8(text, baseUrl, headers) {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return line;
|
||||
if (trimmed.startsWith('#')) {
|
||||
return line.replace(/(URI\s*=\s*)(?:"([^"]*)"|'([^']*)')/g, (match, prefix, double, single) => {
|
||||
const uri = double !== undefined ? double : single;
|
||||
try {
|
||||
return `${prefix}"${proxyTarget(new URL(uri, baseUrl).toString(), headers)}"`;
|
||||
} catch {
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
return proxyTarget(new URL(trimmed, baseUrl).toString(), headers);
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function handleProxyMedia(req, res, url) {
|
||||
const target = (url.searchParams.get('url') || '').trim();
|
||||
const headers = parseProxyHeaders(url.searchParams.get('h'));
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(target);
|
||||
} catch {
|
||||
return json(res, 400, { error: 'missing or invalid ?url=' });
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return json(res, 400, { error: 'proxy only supports http/https' });
|
||||
}
|
||||
|
||||
const upstreamHeaders = { ...headers };
|
||||
if (req.headers.range) upstreamHeaders.range = req.headers.range;
|
||||
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await fetch(parsed.toString(), { headers: upstreamHeaders, redirect: 'follow' });
|
||||
} catch (e) {
|
||||
return json(res, 502, { error: `proxy fetch failed: ${e.message}` });
|
||||
}
|
||||
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
const contentType = upstream.headers.get('content-type') || '';
|
||||
const looksLikeM3u8 = /mpegurl/i.test(contentType) || /\.m3u8(?:[?#].*)?$/i.test(parsed.pathname + parsed.search);
|
||||
if (looksLikeM3u8 && upstream.status < 400) {
|
||||
try {
|
||||
const text = await upstream.text();
|
||||
if (/mpegurl/i.test(contentType) || text.trimStart().startsWith('#EXTM3U')) {
|
||||
const body = rewriteM3u8(text, parsed.toString(), headers);
|
||||
res.writeHead(upstream.status, {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
});
|
||||
return res.end(body);
|
||||
}
|
||||
res.writeHead(upstream.status, {
|
||||
'Content-Type': contentType || 'text/plain; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(text),
|
||||
});
|
||||
return res.end(text);
|
||||
} catch (e) {
|
||||
if (!res.headersSent) return json(res, 502, { error: `proxy playlist failed: ${e.message}` });
|
||||
return res.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
res.statusCode = upstream.status;
|
||||
const copyHeaders = ['content-type', 'content-range', 'accept-ranges'];
|
||||
if (upstream.status === 206) copyHeaders.push('content-length');
|
||||
for (const name of copyHeaders) {
|
||||
const value = upstream.headers.get(name);
|
||||
if (value) res.setHeader(name, value);
|
||||
}
|
||||
|
||||
if (!upstream.body) return res.end();
|
||||
const body = Readable.fromWeb(upstream.body);
|
||||
body.on('error', () => {
|
||||
try {
|
||||
upstream.body.cancel();
|
||||
} catch {}
|
||||
});
|
||||
body.pipe(res).on('error', () => {});
|
||||
}
|
||||
|
||||
function searchResult(m) {
|
||||
const isTv = m.media_type === 'tv';
|
||||
const date = isTv ? m.first_air_date : m.release_date;
|
||||
return {
|
||||
tmdb_id: m.id,
|
||||
type: isTv ? 'tv' : 'movie',
|
||||
name: m.title || m.name,
|
||||
year: date ? String(date).slice(0, 4) : '',
|
||||
poster: m.poster_path ? `https://image.tmdb.org/t/p/w342${m.poster_path}` : '',
|
||||
overview: m.overview || '',
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSearch(req, res, url) {
|
||||
const q = (url.searchParams.get('q') || '').trim();
|
||||
if (!q) return json(res, 400, { error: 'missing ?q=' });
|
||||
try {
|
||||
const results = await tmdb.search(q, cfg.tmdbKey);
|
||||
return json(res, 200, { query: q, count: results.length, results: results.map(searchResult) });
|
||||
} catch (e) {
|
||||
return json(res, 502, { error: `tmdb search failed: ${e.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTrending(req, res, url) {
|
||||
const type = url.searchParams.get('type') || 'all';
|
||||
const timeWindow = url.searchParams.get('window') || 'week';
|
||||
if (!['all', 'movie', 'tv'].includes(type)) {
|
||||
return json(res, 400, { error: '?type= must be all, movie or tv' });
|
||||
}
|
||||
if (!['day', 'week'].includes(timeWindow)) {
|
||||
return json(res, 400, { error: '?window= must be day or week' });
|
||||
}
|
||||
try {
|
||||
const results = await tmdb.trending(type, timeWindow, cfg.tmdbKey);
|
||||
return json(res, 200, { type, window: timeWindow, count: results.length, results: results.map(searchResult) });
|
||||
} catch (e) {
|
||||
return json(res, 502, { error: `tmdb trending failed: ${e.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSeasons(req, res, url, id) {
|
||||
try {
|
||||
const seasons = await tmdb.getSeasons(id, cfg.tmdbKey);
|
||||
return json(res, 200, {
|
||||
tmdb_id: id,
|
||||
count: seasons.length,
|
||||
seasons: seasons.map((s) => ({
|
||||
season_number: s.season_number,
|
||||
name: s.name,
|
||||
episode_count: s.episode_count,
|
||||
air_date: s.air_date || '',
|
||||
})),
|
||||
});
|
||||
} catch (e) {
|
||||
return json(res, 502, { error: `tmdb seasons failed: ${e.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEpisodes(req, res, url, id, season) {
|
||||
try {
|
||||
const episodes = await tmdb.getEpisodes(id, season, cfg.tmdbKey);
|
||||
return json(res, 200, {
|
||||
tmdb_id: id,
|
||||
season: parseInt(season, 10),
|
||||
count: episodes.length,
|
||||
episodes: episodes.map((e) => ({
|
||||
episode_number: e.episode_number,
|
||||
name: e.name,
|
||||
air_date: e.air_date || '',
|
||||
})),
|
||||
});
|
||||
} catch (e) {
|
||||
return json(res, 502, { error: `tmdb episodes failed: ${e.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
const TMDB_IMG = 'https://image.tmdb.org/t/p/';
|
||||
|
||||
function fullImage(pathname) {
|
||||
return pathname ? `${TMDB_IMG}original${pathname}` : '';
|
||||
}
|
||||
|
||||
async function handleDetails(req, res, type, id) {
|
||||
try {
|
||||
const d = await tmdb.details(type, id, cfg.tmdbKey);
|
||||
d.poster_url = fullImage(d.poster_path);
|
||||
d.backdrop_url = fullImage(d.backdrop_path);
|
||||
if (d.images) {
|
||||
for (const key of Object.keys(d.images)) {
|
||||
d.images[key] = (d.images[key] || []).map((im) => ({ ...im, url: fullImage(im.file_path) }));
|
||||
}
|
||||
}
|
||||
return json(res, 200, d);
|
||||
} catch (e) {
|
||||
const status = /HTTP 404/.test(e.message) ? 404 : 502;
|
||||
return json(res, status, { error: `tmdb details failed: ${e.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStreams(req, res, url) {
|
||||
const id = (url.searchParams.get('tmdb') || '').trim();
|
||||
if (!/^\d+$/.test(id)) return json(res, 400, { error: 'missing or invalid ?tmdb=<id>' });
|
||||
|
||||
const type = url.searchParams.get('type') === 'tv' ? 'tv' : 'movie';
|
||||
const season = url.searchParams.get('season');
|
||||
const episode = url.searchParams.get('episode');
|
||||
if (type === 'tv' && (!season || !episode)) {
|
||||
return json(res, 400, { error: 'tv streams need ?season=<n>&episode=<n> (see /tv/<id>/seasons)' });
|
||||
}
|
||||
|
||||
const providersParam = url.searchParams.get('providers');
|
||||
const names = providersParam
|
||||
? providersParam.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: cfg.enabledProviders;
|
||||
const { providers, loadErrors } = loadProviders(names);
|
||||
if (!providers.length) return json(res, 500, { error: 'no providers available', loadErrors });
|
||||
|
||||
const media = {
|
||||
id,
|
||||
type,
|
||||
seasonId: season ? parseInt(season, 10) : null,
|
||||
episodeId: episode ? parseInt(episode, 10) : null,
|
||||
};
|
||||
|
||||
const { streams, errors } = await scrapeAll(providers, media, cfg.timeoutMs);
|
||||
const all = streams.map((s) => normalize(s, s._provider));
|
||||
const filters = {
|
||||
minQuality: url.searchParams.get('minQuality') || 'any',
|
||||
audioCodec: url.searchParams.get('audioCodec') || 'any',
|
||||
subtitles: url.searchParams.get('subtitles') || 'any',
|
||||
subtitleLang: url.searchParams.get('subtitleLang') || '',
|
||||
};
|
||||
const ranked = rankStreams(applyFilters(all, filters));
|
||||
|
||||
return json(res, 200, {
|
||||
tmdb_id: id,
|
||||
type,
|
||||
season: media.seasonId,
|
||||
episode: media.episodeId,
|
||||
total: all.length,
|
||||
matched: ranked.length,
|
||||
streams: ranked.map((s) => ({
|
||||
provider: s.provider,
|
||||
name: s.name,
|
||||
url: s.url,
|
||||
quality: s.quality,
|
||||
pixels: s.pixels,
|
||||
audio: s.audio,
|
||||
type: s.type,
|
||||
headers: s.headers,
|
||||
subtitles: s.subtitles,
|
||||
})),
|
||||
providerErrors: errors,
|
||||
loadErrors,
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||
const p = url.pathname.replace(/\/+$/, '') || '/';
|
||||
try {
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
});
|
||||
return res.end();
|
||||
}
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed, use GET' });
|
||||
|
||||
// web docs
|
||||
if (p === '/' || p === '/index.html') {
|
||||
const data = fs.readFileSync(SWAGGER_HTML);
|
||||
return html(res, 200, data);
|
||||
}
|
||||
if (p === '/api.json') {
|
||||
return json(res, 200, spec);
|
||||
}
|
||||
if (p === '/proxy/media') return await handleProxyMedia(req, res, url);
|
||||
|
||||
// api
|
||||
if (p === '/providers') return json(res, 200, { all: listAll(), enabled: cfg.enabledProviders });
|
||||
if (p === '/search') return await handleSearch(req, res, url);
|
||||
if (p === '/trending') return await handleTrending(req, res, url);
|
||||
|
||||
let m = p.match(/^\/movie\/(\d+)$/);
|
||||
if (m) return await handleDetails(req, res, 'movie', m[1]);
|
||||
|
||||
m = p.match(/^\/tv\/(\d+)$/);
|
||||
if (m) return await handleDetails(req, res, 'tv', m[1]);
|
||||
|
||||
m = p.match(/^\/tv\/(\d+)\/seasons$/);
|
||||
if (m) return await handleSeasons(req, res, url, m[1]);
|
||||
|
||||
m = p.match(/^\/tv\/(\d+)\/seasons\/(\d+)$/);
|
||||
if (m) return await handleEpisodes(req, res, url, m[1], m[2]);
|
||||
|
||||
if (p === '/streams') return await handleStreams(req, res, url);
|
||||
|
||||
return json(res, 404, { error: 'not found', hint: 'see GET / (Swagger UI) or GET /api.json' });
|
||||
} catch (e) {
|
||||
if (!res.headersSent) return json(res, 500, { error: e.message });
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`bug.tv api: http://${HOST}:${PORT} (docs at /)`);
|
||||
console.log(`providers: ${cfg.enabledProviders.join(', ')}`);
|
||||
});
|
||||
Reference in New Issue
Block a user