This commit is contained in:
4DBug
2026-08-26 13:22:49 -05:00
commit 53b3c1da6c
96 changed files with 6210 additions and 0 deletions
+494
View File
@@ -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;
+284
View File
@@ -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>
+524
View File
@@ -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
View File
@@ -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(', ')}`);
});