177 lines
6.0 KiB
JavaScript
177 lines
6.0 KiB
JavaScript
/*
|
|
API — two upstreams:
|
|
1. the bug-tv REST server (SR.config.getBase()): provider catalog,
|
|
/streams lookup, and the /proxy/media tunnel the player needs (CORS,
|
|
headers, m3u8 rewrite).
|
|
2. TMDB, called straight from the browser (it is CORS-enabled): search,
|
|
trending, details, seasons, episodes. Each returns the same shape the
|
|
server's endpoints used to, so the pages are unchanged.
|
|
|
|
Keeping catalog traffic off the server means it only does provider scraping
|
|
and stream proxying.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const TMDB_BASE = 'https://api.themoviedb.org/3';
|
|
const TMDB_IMG = 'https://image.tmdb.org/t/p/';
|
|
|
|
async function request(path, params) {
|
|
const url = new URL(SR.config.getBase() + path);
|
|
if (params) {
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value === undefined || value === null || value === '') continue;
|
|
url.searchParams.set(key, String(value));
|
|
}
|
|
}
|
|
let res;
|
|
try {
|
|
res = await fetch(url.toString());
|
|
} catch (e) {
|
|
throw new Error(`cannot reach the API at ${SR.config.getBase()} — check the base URL in Settings`);
|
|
}
|
|
let data = null;
|
|
try {
|
|
data = await res.json();
|
|
} catch (e) {
|
|
/* non-JSON body */
|
|
}
|
|
if (!res.ok) {
|
|
throw new Error((data && data.error) || `API error: HTTP ${res.status}`);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
// Direct TMDB call. `key` overrides the configured key (Settings "Test").
|
|
async function tmdb(pathname, params, key) {
|
|
const url = new URL(TMDB_BASE + pathname);
|
|
url.searchParams.set('api_key', key || SR.config.getTmdbKey());
|
|
url.searchParams.set('language', 'en-US');
|
|
if (params) {
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v === undefined || v === null || v === '') continue;
|
|
url.searchParams.set(k, String(v));
|
|
}
|
|
}
|
|
let res;
|
|
try {
|
|
res = await fetch(url.toString());
|
|
} catch (e) {
|
|
throw new Error('cannot reach the TMDB API — check your connection or the TMDB API key in Settings');
|
|
}
|
|
let data = null;
|
|
try {
|
|
data = await res.json();
|
|
} catch (e) {
|
|
/* non-JSON body */
|
|
}
|
|
if (!res.ok) {
|
|
if (res.status === 401) {
|
|
throw new Error('TMDB rejected the API key — check "TMDB API key" in Settings');
|
|
}
|
|
throw new Error((data && (data.status_message || data.error)) || `TMDB error: HTTP ${res.status}`);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
// route a media/subtitle URL through the API proxy (CORS, headers, m3u8 rewrite)
|
|
function proxyUrl(target, headers) {
|
|
const q = new URLSearchParams();
|
|
q.set('url', target);
|
|
q.set('h', JSON.stringify(headers || {}));
|
|
return SR.config.getBase() + '/proxy/media?' + q.toString();
|
|
}
|
|
|
|
// Same shapes the server endpoints used to return.
|
|
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 ? `${TMDB_IMG}w342${m.poster_path}` : '',
|
|
backdrop: m.backdrop_path ? `${TMDB_IMG}w1280${m.backdrop_path}` : '',
|
|
rating: m.vote_average ? Number(m.vote_average).toFixed(1) : '',
|
|
overview: m.overview || '',
|
|
};
|
|
}
|
|
|
|
function fullImage(pathname) {
|
|
return pathname ? `${TMDB_IMG}original${pathname}` : '';
|
|
}
|
|
|
|
window.SR = window.SR || {};
|
|
SR.api = {
|
|
request,
|
|
tmdb,
|
|
proxyUrl,
|
|
|
|
// — TMDB, straight from the browser —
|
|
search: (q) =>
|
|
tmdb('/search/multi', { query: q, include_adult: 'false', page: 1 }).then((r) => {
|
|
const results = (r.results || [])
|
|
.filter((m) => m.media_type === 'movie' || m.media_type === 'tv')
|
|
.slice(0, 15)
|
|
.map(searchResult);
|
|
return { query: q, count: results.length, results };
|
|
}),
|
|
trending: (type, timeWindow) => {
|
|
const t = ['movie', 'tv', 'all'].includes(type) ? type : 'all';
|
|
const w = timeWindow === 'day' ? 'day' : 'week';
|
|
return tmdb(`/trending/${t}/${w}`).then((r) => {
|
|
const results = (r.results || []).slice(0, 20).map(searchResult);
|
|
return { type: t, window: w, count: results.length, results };
|
|
});
|
|
},
|
|
details: (type, id) =>
|
|
tmdb(`/${type}/${id}`, { append_to_response: 'images' }).then((d) => {
|
|
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 d;
|
|
}),
|
|
seasons: (id) =>
|
|
tmdb(`/tv/${id}`).then((r) => {
|
|
const seasons = (r.seasons || [])
|
|
.filter((s) => s.season_number > 0 && (s.episode_count || 0) > 0)
|
|
.map((s) => ({
|
|
season_number: s.season_number,
|
|
name: s.name,
|
|
episode_count: s.episode_count,
|
|
air_date: s.air_date || '',
|
|
}));
|
|
return { tmdb_id: id, count: seasons.length, seasons };
|
|
}),
|
|
episodes: (id, season) =>
|
|
tmdb(`/tv/${id}/season/${season}`).then((r) => {
|
|
const episodes = (r.episodes || [])
|
|
.slice()
|
|
.sort((a, b) => a.episode_number - b.episode_number)
|
|
.map((e) => ({
|
|
episode_number: e.episode_number,
|
|
name: e.name,
|
|
air_date: e.air_date || '',
|
|
still: e.still_path ? `${TMDB_IMG}w300${e.still_path}` : '',
|
|
}));
|
|
return { tmdb_id: id, season: parseInt(season, 10), count: episodes.length, episodes };
|
|
}),
|
|
|
|
// — bug-tv server (providers, streams, proxy) —
|
|
streams: ({ tmdb, type, season, episode, providers }) =>
|
|
request('/streams', {
|
|
tmdb,
|
|
type,
|
|
season,
|
|
episode,
|
|
providers: Array.isArray(providers) && providers.length ? providers.join(',') : '',
|
|
}),
|
|
providers: () => request('/providers'),
|
|
};
|
|
})();
|