Refactored dev-cli
This commit is contained in:
parent
af00bcf7c1
commit
feddf9c215
|
@ -35,8 +35,8 @@
|
||||||
"homepage": "https://providers.docs.movie-web.app/",
|
"homepage": "https://providers.docs.movie-web.app/",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build && tsc --noEmit",
|
"build": "vite build && tsc --noEmit",
|
||||||
|
"cli": "ts-node ./src/dev-cli/index.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:dev": "ts-node ./src/dev-cli.ts",
|
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:integration": "node ./tests/cjs && node ./tests/esm && node ./tests/browser",
|
"test:integration": "node ./tests/cjs && node ./tests/esm && node ./tests/browser",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
|
|
423
src/dev-cli.ts
423
src/dev-cli.ts
|
@ -1,423 +0,0 @@
|
||||||
/* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}] */
|
|
||||||
|
|
||||||
import util from 'node:util';
|
|
||||||
|
|
||||||
import { program } from 'commander';
|
|
||||||
import dotenv from 'dotenv';
|
|
||||||
import { prompt } from 'enquirer';
|
|
||||||
import nodeFetch from 'node-fetch';
|
|
||||||
import Spinnies from 'spinnies';
|
|
||||||
|
|
||||||
import { MetaOutput, MovieMedia, ProviderControls, ShowMedia, makeProviders, makeStandardFetcher, targets } from '.';
|
|
||||||
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
type ProviderSourceAnswers = {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type EmbedSourceAnswers = {
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CommonAnswers = {
|
|
||||||
fetcher: string;
|
|
||||||
source: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ShowAnswers = {
|
|
||||||
season: string;
|
|
||||||
episode: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CommandLineArguments = {
|
|
||||||
fetcher: string;
|
|
||||||
sourceId: string;
|
|
||||||
tmdbId: string;
|
|
||||||
type: string;
|
|
||||||
season: string;
|
|
||||||
episode: string;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TMDB_API_KEY = process.env.MOVIE_WEB_TMDB_API_KEY ?? '';
|
|
||||||
|
|
||||||
if (!TMDB_API_KEY?.trim()) {
|
|
||||||
throw new Error('Missing MOVIE_WEB_TMDB_API_KEY environment variable');
|
|
||||||
}
|
|
||||||
|
|
||||||
function logDeepObject(object: Record<any, any>) {
|
|
||||||
console.log(util.inspect(object, { showHidden: false, depth: null, colors: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAllSources() {
|
|
||||||
// * The only way to get a list of all sources is to
|
|
||||||
// * create all these things. Maybe this should change
|
|
||||||
const providers = makeProviders({
|
|
||||||
fetcher: makeStandardFetcher(nodeFetch),
|
|
||||||
target: targets.NATIVE,
|
|
||||||
});
|
|
||||||
|
|
||||||
const combined = [...providers.listSources(), ...providers.listEmbeds()];
|
|
||||||
|
|
||||||
// * Remove dupes
|
|
||||||
const map = new Map(combined.map((source) => [source.id, source]));
|
|
||||||
|
|
||||||
return [...map.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
// * Defined here cuz ESLint didn't like the order these were defined in
|
|
||||||
const sources = getAllSources();
|
|
||||||
|
|
||||||
async function makeTMDBRequest(url: string): Promise<Response> {
|
|
||||||
const headers: {
|
|
||||||
accept: 'application/json';
|
|
||||||
authorization?: string;
|
|
||||||
} = {
|
|
||||||
accept: 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
// * Used to get around ESLint
|
|
||||||
// * Assignment to function parameter 'url'. eslint (no-param-reassign)
|
|
||||||
let requestURL = url;
|
|
||||||
|
|
||||||
// * JWT keys always start with ey and are ONLY valid as a header.
|
|
||||||
// * All other keys are ONLY valid as a query param.
|
|
||||||
// * Thanks TMDB.
|
|
||||||
if (TMDB_API_KEY.startsWith('ey')) {
|
|
||||||
headers.authorization = `Bearer ${TMDB_API_KEY}`;
|
|
||||||
} else {
|
|
||||||
requestURL += `?api_key=${TMDB_API_KEY}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetch(requestURL, {
|
|
||||||
method: 'GET',
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMovieMediaDetails(id: string): Promise<MovieMedia> {
|
|
||||||
const response = await makeTMDBRequest(`https://api.themoviedb.org/3/movie/${id}`);
|
|
||||||
const movie = await response.json();
|
|
||||||
|
|
||||||
if (movie.success === false) {
|
|
||||||
throw new Error(movie.status_message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!movie.release_date) {
|
|
||||||
throw new Error(`${movie.title} has no release_date. Assuming unreleased`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'movie',
|
|
||||||
title: movie.title,
|
|
||||||
releaseYear: Number(movie.release_date.split('-')[0]),
|
|
||||||
tmdbId: id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getShowMediaDetails(id: string, seasonNumber: string, episodeNumber: string): Promise<ShowMedia> {
|
|
||||||
// * TV shows require the TMDB ID for the series, season, and episode
|
|
||||||
// * and the name of the series. Needs multiple requests
|
|
||||||
let response = await makeTMDBRequest(`https://api.themoviedb.org/3/tv/${id}`);
|
|
||||||
const series = await response.json();
|
|
||||||
|
|
||||||
if (series.success === false) {
|
|
||||||
throw new Error(series.status_message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!series.first_air_date) {
|
|
||||||
throw new Error(`${series.name} has no first_air_date. Assuming unaired`);
|
|
||||||
}
|
|
||||||
|
|
||||||
response = await makeTMDBRequest(`https://api.themoviedb.org/3/tv/${id}/season/${seasonNumber}`);
|
|
||||||
const season = await response.json();
|
|
||||||
|
|
||||||
if (season.success === false) {
|
|
||||||
throw new Error(season.status_message);
|
|
||||||
}
|
|
||||||
|
|
||||||
response = await makeTMDBRequest(
|
|
||||||
`https://api.themoviedb.org/3/tv/${id}/season/${seasonNumber}/episode/${episodeNumber}`,
|
|
||||||
);
|
|
||||||
const episode = await response.json();
|
|
||||||
|
|
||||||
if (episode.success === false) {
|
|
||||||
throw new Error(episode.status_message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'show',
|
|
||||||
title: series.name,
|
|
||||||
releaseYear: Number(series.first_air_date.split('-')[0]),
|
|
||||||
tmdbId: id,
|
|
||||||
episode: {
|
|
||||||
number: episode.episode_number,
|
|
||||||
tmdbId: episode.id,
|
|
||||||
},
|
|
||||||
season: {
|
|
||||||
number: season.season_number,
|
|
||||||
tmdbId: season.id,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function joinMediaTypes(mediaTypes: string[] | undefined) {
|
|
||||||
if (mediaTypes) {
|
|
||||||
const formatted = mediaTypes
|
|
||||||
.map((type: string) => {
|
|
||||||
return `${type[0].toUpperCase() + type.substring(1).toLowerCase()}s`;
|
|
||||||
})
|
|
||||||
.join(' / ');
|
|
||||||
|
|
||||||
return `(${formatted})`;
|
|
||||||
}
|
|
||||||
return ''; // * Embed sources pass through here too
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runScraper(providers: ProviderControls, source: MetaOutput, options: CommandLineArguments) {
|
|
||||||
const spinnies = new Spinnies();
|
|
||||||
|
|
||||||
if (source.type === 'embed') {
|
|
||||||
spinnies.add('scrape', { text: `Running ${source.name} scraper on ${options.url}` });
|
|
||||||
try {
|
|
||||||
const result = await providers.runEmbedScraper({
|
|
||||||
url: options.url,
|
|
||||||
id: source.id,
|
|
||||||
});
|
|
||||||
spinnies.succeed('scrape', { text: 'Done!' });
|
|
||||||
logDeepObject(result);
|
|
||||||
} catch (error) {
|
|
||||||
let message = 'Unknown error';
|
|
||||||
if (error instanceof Error) {
|
|
||||||
message = error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
spinnies.fail('scrape', { text: `ERROR: ${message}` });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let media;
|
|
||||||
|
|
||||||
if (options.type === 'movie') {
|
|
||||||
media = await getMovieMediaDetails(options.tmdbId);
|
|
||||||
} else {
|
|
||||||
media = await getShowMediaDetails(options.tmdbId, options.season, options.episode);
|
|
||||||
}
|
|
||||||
|
|
||||||
spinnies.add('scrape', { text: `Running ${source.name} scraper on ${media.title}` });
|
|
||||||
try {
|
|
||||||
const result = await providers.runSourceScraper({
|
|
||||||
media,
|
|
||||||
id: source.id,
|
|
||||||
});
|
|
||||||
spinnies.succeed('scrape', { text: 'Done!' });
|
|
||||||
logDeepObject(result);
|
|
||||||
} catch (error) {
|
|
||||||
let message = 'Unknown error';
|
|
||||||
if (error instanceof Error) {
|
|
||||||
message = error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
spinnies.fail('scrape', { text: `ERROR: ${message}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processOptions(options: CommandLineArguments) {
|
|
||||||
if (options.fetcher !== 'node-fetch' && options.fetcher !== 'native') {
|
|
||||||
throw new Error("Fetcher must be either 'native' or 'node-fetch'");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.sourceId.trim()) {
|
|
||||||
throw new Error('Source ID must be provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = sources.find(({ id }) => id === options.sourceId);
|
|
||||||
|
|
||||||
if (!source) {
|
|
||||||
throw new Error('Invalid source ID. No source found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source.type === 'embed' && !options.url.trim()) {
|
|
||||||
throw new Error('Must provide an embed URL for embed sources');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source.type === 'source') {
|
|
||||||
if (!options.tmdbId.trim()) {
|
|
||||||
throw new Error('Must provide a TMDB ID for provider sources');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Number.isNaN(Number(options.tmdbId)) || Number(options.tmdbId) < 0) {
|
|
||||||
throw new Error('TMDB ID must be a number greater than 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.type.trim()) {
|
|
||||||
throw new Error('Must provide a type for provider sources');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.type !== 'movie' && options.type !== 'show') {
|
|
||||||
throw new Error("Invalid media type. Must be either 'movie' or 'show'");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.type === 'show') {
|
|
||||||
if (!options.season.trim()) {
|
|
||||||
throw new Error('Must provide a season number for TV shows');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.episode.trim()) {
|
|
||||||
throw new Error('Must provide an episode number for TV shows');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Number.isNaN(Number(options.season)) || Number(options.season) <= 0) {
|
|
||||||
throw new Error('Season number must be a number greater than 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Number.isNaN(Number(options.episode)) || Number(options.episode) <= 0) {
|
|
||||||
throw new Error('Episode number must be a number greater than 0');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let fetcher;
|
|
||||||
|
|
||||||
if (options.fetcher === 'native') {
|
|
||||||
fetcher = makeStandardFetcher(fetch);
|
|
||||||
} else {
|
|
||||||
fetcher = makeStandardFetcher(nodeFetch);
|
|
||||||
}
|
|
||||||
|
|
||||||
const providers = makeProviders({
|
|
||||||
fetcher,
|
|
||||||
target: targets.NATIVE,
|
|
||||||
});
|
|
||||||
|
|
||||||
await runScraper(providers, source, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runQuestions() {
|
|
||||||
const options = {
|
|
||||||
fetcher: 'node-fetch',
|
|
||||||
sourceId: '',
|
|
||||||
tmdbId: '',
|
|
||||||
type: 'movie',
|
|
||||||
season: '0',
|
|
||||||
episode: '0',
|
|
||||||
url: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const answers = await prompt<CommonAnswers>([
|
|
||||||
{
|
|
||||||
type: 'select',
|
|
||||||
name: 'fetcher',
|
|
||||||
message: 'Select a fetcher',
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
message: 'Native',
|
|
||||||
name: 'native',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: 'Node fetch',
|
|
||||||
name: 'node-fetch',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'select',
|
|
||||||
name: 'source',
|
|
||||||
message: 'Select a source',
|
|
||||||
choices: sources.map((source) => ({
|
|
||||||
message: `[${source.type.toLocaleUpperCase()}] ${source.name} ${joinMediaTypes(source.mediaTypes)}`.trim(),
|
|
||||||
name: source.id,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
options.fetcher = answers.fetcher;
|
|
||||||
options.sourceId = answers.source;
|
|
||||||
|
|
||||||
const source = sources.find(({ id }) => id === answers.source);
|
|
||||||
|
|
||||||
if (!source) {
|
|
||||||
throw new Error(`No source with ID ${answers.source} found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source.type === 'embed') {
|
|
||||||
const sourceAnswers = await prompt<EmbedSourceAnswers>([
|
|
||||||
{
|
|
||||||
type: 'input',
|
|
||||||
name: 'url',
|
|
||||||
message: 'Embed URL',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
options.url = sourceAnswers.url;
|
|
||||||
} else {
|
|
||||||
const sourceAnswers = await prompt<ProviderSourceAnswers>([
|
|
||||||
{
|
|
||||||
type: 'input',
|
|
||||||
name: 'id',
|
|
||||||
message: 'TMDB ID',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'select',
|
|
||||||
name: 'type',
|
|
||||||
message: 'Media type',
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
message: 'Movie',
|
|
||||||
name: 'movie',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: 'TV Show',
|
|
||||||
name: 'show',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
options.tmdbId = sourceAnswers.id;
|
|
||||||
options.type = sourceAnswers.type;
|
|
||||||
|
|
||||||
if (sourceAnswers.type === 'show') {
|
|
||||||
const seriesAnswers = await prompt<ShowAnswers>([
|
|
||||||
{
|
|
||||||
type: 'input',
|
|
||||||
name: 'season',
|
|
||||||
message: 'Season',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'input',
|
|
||||||
name: 'episode',
|
|
||||||
message: 'Episode',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
options.season = seriesAnswers.season;
|
|
||||||
options.episode = seriesAnswers.episode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await processOptions(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runCommandLine() {
|
|
||||||
program
|
|
||||||
.option('-f, --fetcher <fetcher>', "Fetcher to use. Either 'native' or 'node-fetch'", 'node-fetch')
|
|
||||||
.option('-sid, --source-id <id>', 'ID for the source to use. Either an embed or provider', '')
|
|
||||||
.option('-tid, --tmdb-id <id>', 'TMDB ID for the media to scrape. Only used if source is a provider', '')
|
|
||||||
.option('-t, --type <type>', "Media type. Either 'movie' or 'show'. Only used if source is a provider", 'movie')
|
|
||||||
.option('-s, --season <number>', "Season number. Only used if type is 'show'", '0')
|
|
||||||
.option('-e, --episode <number>', "Episode number. Only used if type is 'show'", '0')
|
|
||||||
.option('-u, --url <embed URL>', 'URL to a video embed. Only used if source is an embed', '');
|
|
||||||
|
|
||||||
program.parse();
|
|
||||||
|
|
||||||
await processOptions(program.opts());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.argv.length === 2) {
|
|
||||||
runQuestions();
|
|
||||||
} else {
|
|
||||||
runCommandLine();
|
|
||||||
}
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
export function getConfig() {
|
||||||
|
let tmdbApiKey = process.env.MOVIE_WEB_TMDB_API_KEY ?? '';
|
||||||
|
tmdbApiKey = tmdbApiKey.trim();
|
||||||
|
|
||||||
|
if (!tmdbApiKey) {
|
||||||
|
throw new Error('Missing MOVIE_WEB_TMDB_API_KEY environment variable');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tmdbApiKey,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,227 @@
|
||||||
|
/* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}] */
|
||||||
|
|
||||||
|
import { program } from 'commander';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { prompt } from 'enquirer';
|
||||||
|
import Spinnies from 'spinnies';
|
||||||
|
|
||||||
|
import { logDeepObject } from '@/dev-cli/logging';
|
||||||
|
import { getMovieMediaDetails, getShowMediaDetails } from '@/dev-cli/tmdb';
|
||||||
|
import { CommandLineArguments, processOptions } from '@/dev-cli/validate';
|
||||||
|
|
||||||
|
import { MetaOutput, ProviderControls, getBuiltinEmbeds, getBuiltinSources } from '..';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
type ProviderSourceAnswers = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmbedSourceAnswers = {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CommonAnswers = {
|
||||||
|
fetcher: string;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShowAnswers = {
|
||||||
|
season: string;
|
||||||
|
episode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceScrapers = getBuiltinSources().sort((a, b) => b.rank - a.rank);
|
||||||
|
const embedScrapers = getBuiltinEmbeds().sort((a, b) => b.rank - a.rank);
|
||||||
|
const sources = [...sourceScrapers, ...embedScrapers];
|
||||||
|
|
||||||
|
function joinMediaTypes(mediaTypes: string[] | undefined) {
|
||||||
|
if (mediaTypes) {
|
||||||
|
const formatted = mediaTypes
|
||||||
|
.map((type: string) => {
|
||||||
|
return `${type[0].toUpperCase() + type.substring(1).toLowerCase()}s`;
|
||||||
|
})
|
||||||
|
.join(' / ');
|
||||||
|
|
||||||
|
return `(${formatted})`;
|
||||||
|
}
|
||||||
|
return ''; // * Embed sources pass through here too
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runScraper(providers: ProviderControls, source: MetaOutput, options: CommandLineArguments) {
|
||||||
|
const spinnies = new Spinnies();
|
||||||
|
|
||||||
|
if (source.type === 'embed') {
|
||||||
|
spinnies.add('scrape', { text: `Running ${source.name} scraper on ${options.url}` });
|
||||||
|
try {
|
||||||
|
const result = await providers.runEmbedScraper({
|
||||||
|
url: options.url,
|
||||||
|
id: source.id,
|
||||||
|
});
|
||||||
|
spinnies.succeed('scrape', { text: 'Done!' });
|
||||||
|
logDeepObject(result);
|
||||||
|
} catch (error) {
|
||||||
|
let message = 'Unknown error';
|
||||||
|
if (error instanceof Error) {
|
||||||
|
message = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
spinnies.fail('scrape', { text: `ERROR: ${message}` });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let media;
|
||||||
|
|
||||||
|
if (options.type === 'movie') {
|
||||||
|
media = await getMovieMediaDetails(options.tmdbId);
|
||||||
|
} else {
|
||||||
|
media = await getShowMediaDetails(options.tmdbId, options.season, options.episode);
|
||||||
|
}
|
||||||
|
|
||||||
|
spinnies.add('scrape', { text: `Running ${source.name} scraper on ${media.title}` });
|
||||||
|
try {
|
||||||
|
const result = await providers.runSourceScraper({
|
||||||
|
media,
|
||||||
|
id: source.id,
|
||||||
|
});
|
||||||
|
spinnies.succeed('scrape', { text: 'Done!' });
|
||||||
|
logDeepObject(result);
|
||||||
|
} catch (error) {
|
||||||
|
let message = 'Unknown error';
|
||||||
|
if (error instanceof Error) {
|
||||||
|
message = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
spinnies.fail('scrape', { text: `ERROR: ${message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runQuestions() {
|
||||||
|
const options = {
|
||||||
|
fetcher: 'node-fetch',
|
||||||
|
sourceId: '',
|
||||||
|
tmdbId: '',
|
||||||
|
type: 'movie',
|
||||||
|
season: '0',
|
||||||
|
episode: '0',
|
||||||
|
url: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const answers = await prompt<CommonAnswers>([
|
||||||
|
{
|
||||||
|
type: 'select',
|
||||||
|
name: 'fetcher',
|
||||||
|
message: 'Select a fetcher',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: 'Native',
|
||||||
|
name: 'native',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Node fetch',
|
||||||
|
name: 'node-fetch',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'select',
|
||||||
|
name: 'source',
|
||||||
|
message: 'Select a source',
|
||||||
|
choices: sources.map((source) => ({
|
||||||
|
message: `[${source.type.toLocaleUpperCase()}] ${source.name} ${joinMediaTypes(source.mediaTypes)}`.trim(),
|
||||||
|
name: source.id,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
options.fetcher = answers.fetcher;
|
||||||
|
options.sourceId = answers.source;
|
||||||
|
|
||||||
|
const source = sources.find(({ id }) => id === answers.source);
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
throw new Error(`No source with ID ${answers.source} found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.type === 'embed') {
|
||||||
|
const sourceAnswers = await prompt<EmbedSourceAnswers>([
|
||||||
|
{
|
||||||
|
type: 'input',
|
||||||
|
name: 'url',
|
||||||
|
message: 'Embed URL',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
options.url = sourceAnswers.url;
|
||||||
|
} else {
|
||||||
|
const sourceAnswers = await prompt<ProviderSourceAnswers>([
|
||||||
|
{
|
||||||
|
type: 'input',
|
||||||
|
name: 'id',
|
||||||
|
message: 'TMDB ID',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'select',
|
||||||
|
name: 'type',
|
||||||
|
message: 'Media type',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: 'Movie',
|
||||||
|
name: 'movie',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'TV Show',
|
||||||
|
name: 'show',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
options.tmdbId = sourceAnswers.id;
|
||||||
|
options.type = sourceAnswers.type;
|
||||||
|
|
||||||
|
if (sourceAnswers.type === 'show') {
|
||||||
|
const seriesAnswers = await prompt<ShowAnswers>([
|
||||||
|
{
|
||||||
|
type: 'input',
|
||||||
|
name: 'season',
|
||||||
|
message: 'Season',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'input',
|
||||||
|
name: 'episode',
|
||||||
|
message: 'Episode',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
options.season = seriesAnswers.season;
|
||||||
|
options.episode = seriesAnswers.episode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { providers, source: validatedSource, options: validatedOps } = await processOptions(sources, options);
|
||||||
|
await runScraper(providers, validatedSource, validatedOps);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCommandLine() {
|
||||||
|
program
|
||||||
|
.option('-f, --fetcher <fetcher>', "Fetcher to use. Either 'native' or 'node-fetch'", 'node-fetch')
|
||||||
|
.option('-sid, --source-id <id>', 'ID for the source to use. Either an embed or provider', '')
|
||||||
|
.option('-tid, --tmdb-id <id>', 'TMDB ID for the media to scrape. Only used if source is a provider', '')
|
||||||
|
.option('-t, --type <type>', "Media type. Either 'movie' or 'show'. Only used if source is a provider", 'movie')
|
||||||
|
.option('-s, --season <number>', "Season number. Only used if type is 'show'", '0')
|
||||||
|
.option('-e, --episode <number>', "Episode number. Only used if type is 'show'", '0')
|
||||||
|
.option('-u, --url <embed URL>', 'URL to a video embed. Only used if source is an embed', '');
|
||||||
|
|
||||||
|
program.parse();
|
||||||
|
|
||||||
|
const { providers, source: validatedSource, options: validatedOps } = await processOptions(sources, program.opts());
|
||||||
|
await runScraper(providers, validatedSource, validatedOps);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv.length === 2) {
|
||||||
|
runQuestions().catch(() => console.error('Exited.'));
|
||||||
|
} else {
|
||||||
|
runCommandLine().catch(() => console.error('Exited.'));
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { inspect } from 'node:util';
|
||||||
|
|
||||||
|
export function logDeepObject(object: Record<any, any>) {
|
||||||
|
console.log(inspect(object, { showHidden: false, depth: null, colors: true }));
|
||||||
|
}
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { getConfig } from '@/dev-cli/config';
|
||||||
|
|
||||||
|
import { MovieMedia, ShowMedia } from '..';
|
||||||
|
|
||||||
|
export async function makeTMDBRequest(url: string): Promise<Response> {
|
||||||
|
const headers: {
|
||||||
|
accept: 'application/json';
|
||||||
|
authorization?: string;
|
||||||
|
} = {
|
||||||
|
accept: 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
let requestURL = url;
|
||||||
|
const key = getConfig().tmdbApiKey;
|
||||||
|
|
||||||
|
// * JWT keys always start with ey and are ONLY valid as a header.
|
||||||
|
// * All other keys are ONLY valid as a query param.
|
||||||
|
// * Thanks TMDB.
|
||||||
|
if (key.startsWith('ey')) {
|
||||||
|
headers.authorization = `Bearer ${key}`;
|
||||||
|
} else {
|
||||||
|
requestURL += `?api_key=${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(requestURL, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMovieMediaDetails(id: string): Promise<MovieMedia> {
|
||||||
|
const response = await makeTMDBRequest(`https://api.themoviedb.org/3/movie/${id}`);
|
||||||
|
const movie = await response.json();
|
||||||
|
|
||||||
|
if (movie.success === false) {
|
||||||
|
throw new Error(movie.status_message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!movie.release_date) {
|
||||||
|
throw new Error(`${movie.title} has no release_date. Assuming unreleased`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'movie',
|
||||||
|
title: movie.title,
|
||||||
|
releaseYear: Number(movie.release_date.split('-')[0]),
|
||||||
|
tmdbId: id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getShowMediaDetails(id: string, seasonNumber: string, episodeNumber: string): Promise<ShowMedia> {
|
||||||
|
// * TV shows require the TMDB ID for the series, season, and episode
|
||||||
|
// * and the name of the series. Needs multiple requests
|
||||||
|
let response = await makeTMDBRequest(`https://api.themoviedb.org/3/tv/${id}`);
|
||||||
|
const series = await response.json();
|
||||||
|
|
||||||
|
if (series.success === false) {
|
||||||
|
throw new Error(series.status_message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!series.first_air_date) {
|
||||||
|
throw new Error(`${series.name} has no first_air_date. Assuming unaired`);
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await makeTMDBRequest(`https://api.themoviedb.org/3/tv/${id}/season/${seasonNumber}`);
|
||||||
|
const season = await response.json();
|
||||||
|
|
||||||
|
if (season.success === false) {
|
||||||
|
throw new Error(season.status_message);
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await makeTMDBRequest(
|
||||||
|
`https://api.themoviedb.org/3/tv/${id}/season/${seasonNumber}/episode/${episodeNumber}`,
|
||||||
|
);
|
||||||
|
const episode = await response.json();
|
||||||
|
|
||||||
|
if (episode.success === false) {
|
||||||
|
throw new Error(episode.status_message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'show',
|
||||||
|
title: series.name,
|
||||||
|
releaseYear: Number(series.first_air_date.split('-')[0]),
|
||||||
|
tmdbId: id,
|
||||||
|
episode: {
|
||||||
|
number: episode.episode_number,
|
||||||
|
tmdbId: episode.id,
|
||||||
|
},
|
||||||
|
season: {
|
||||||
|
number: season.season_number,
|
||||||
|
tmdbId: season.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,90 @@
|
||||||
|
import nodeFetch from 'node-fetch';
|
||||||
|
|
||||||
|
import { Embed, Sourcerer } from '@/providers/base';
|
||||||
|
|
||||||
|
import { makeProviders, makeStandardFetcher, targets } from '..';
|
||||||
|
|
||||||
|
export type CommandLineArguments = {
|
||||||
|
fetcher: string;
|
||||||
|
sourceId: string;
|
||||||
|
tmdbId: string;
|
||||||
|
type: string;
|
||||||
|
season: string;
|
||||||
|
episode: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function processOptions(sources: Array<Embed | Sourcerer>, options: CommandLineArguments) {
|
||||||
|
if (options.fetcher !== 'node-fetch' && options.fetcher !== 'native') {
|
||||||
|
throw new Error("Fetcher must be either 'native' or 'node-fetch'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.sourceId.trim()) {
|
||||||
|
throw new Error('Source ID must be provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = sources.find(({ id }) => id === options.sourceId);
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
throw new Error('Invalid source ID. No source found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.type === 'embed' && !options.url.trim()) {
|
||||||
|
throw new Error('Must provide an embed URL for embed sources');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.type === 'source') {
|
||||||
|
if (!options.tmdbId.trim()) {
|
||||||
|
throw new Error('Must provide a TMDB ID for provider sources');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isNaN(Number(options.tmdbId)) || Number(options.tmdbId) < 0) {
|
||||||
|
throw new Error('TMDB ID must be a number greater than 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.type.trim()) {
|
||||||
|
throw new Error('Must provide a type for provider sources');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.type !== 'movie' && options.type !== 'show') {
|
||||||
|
throw new Error("Invalid media type. Must be either 'movie' or 'show'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.type === 'show') {
|
||||||
|
if (!options.season.trim()) {
|
||||||
|
throw new Error('Must provide a season number for TV shows');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.episode.trim()) {
|
||||||
|
throw new Error('Must provide an episode number for TV shows');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isNaN(Number(options.season)) || Number(options.season) <= 0) {
|
||||||
|
throw new Error('Season number must be a number greater than 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isNaN(Number(options.episode)) || Number(options.episode) <= 0) {
|
||||||
|
throw new Error('Episode number must be a number greater than 0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fetcher;
|
||||||
|
|
||||||
|
if (options.fetcher === 'native') {
|
||||||
|
fetcher = makeStandardFetcher(fetch);
|
||||||
|
} else {
|
||||||
|
fetcher = makeStandardFetcher(nodeFetch);
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = makeProviders({
|
||||||
|
fetcher,
|
||||||
|
target: targets.NATIVE,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
providers,
|
||||||
|
options,
|
||||||
|
source,
|
||||||
|
};
|
||||||
|
}
|
|
@ -2,6 +2,8 @@ import { Flags } from '@/entrypoint/utils/targets';
|
||||||
import { Stream } from '@/providers/streams';
|
import { Stream } from '@/providers/streams';
|
||||||
import { EmbedScrapeContext, MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
|
import { EmbedScrapeContext, MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
|
||||||
|
|
||||||
|
export type MediaScraperTypes = 'show' | 'movie';
|
||||||
|
|
||||||
export type SourcererEmbed = {
|
export type SourcererEmbed = {
|
||||||
embedId: string;
|
embedId: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
@ -12,7 +14,7 @@ export type SourcererOutput = {
|
||||||
stream?: Stream[];
|
stream?: Stream[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Sourcerer = {
|
export type SourcererOptions = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string; // displayed in the UI
|
name: string; // displayed in the UI
|
||||||
rank: number; // the higher the number, the earlier it gets put on the queue
|
rank: number; // the higher the number, the earlier it gets put on the queue
|
||||||
|
@ -22,15 +24,29 @@ export type Sourcerer = {
|
||||||
scrapeShow?: (input: ShowScrapeContext) => Promise<SourcererOutput>;
|
scrapeShow?: (input: ShowScrapeContext) => Promise<SourcererOutput>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function makeSourcerer(state: Sourcerer): Sourcerer {
|
export type Sourcerer = SourcererOptions & {
|
||||||
return state;
|
type: 'source';
|
||||||
|
disabled: boolean;
|
||||||
|
mediaTypes: MediaScraperTypes[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function makeSourcerer(state: SourcererOptions): Sourcerer {
|
||||||
|
const mediaTypes: MediaScraperTypes[] = [];
|
||||||
|
if (state.scrapeMovie) mediaTypes.push('movie');
|
||||||
|
if (state.scrapeShow) mediaTypes.push('show');
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
type: 'source',
|
||||||
|
disabled: state.disabled ?? false,
|
||||||
|
mediaTypes,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EmbedOutput = {
|
export type EmbedOutput = {
|
||||||
stream: Stream[];
|
stream: Stream[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Embed = {
|
export type EmbedOptions = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string; // displayed in the UI
|
name: string; // displayed in the UI
|
||||||
rank: number; // the higher the number, the earlier it gets put on the queue
|
rank: number; // the higher the number, the earlier it gets put on the queue
|
||||||
|
@ -38,6 +54,17 @@ export type Embed = {
|
||||||
scrape: (input: EmbedScrapeContext) => Promise<EmbedOutput>;
|
scrape: (input: EmbedScrapeContext) => Promise<EmbedOutput>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function makeEmbed(state: Embed): Embed {
|
export type Embed = EmbedOptions & {
|
||||||
return state;
|
type: 'embed';
|
||||||
|
disabled: boolean;
|
||||||
|
mediaTypes: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function makeEmbed(state: EmbedOptions): Embed {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
type: 'embed',
|
||||||
|
disabled: state.disabled ?? false,
|
||||||
|
mediaTypes: undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue