maxkueng/eztvapi

View on GitHub
src/index.js

Summary

Maintainability
B
4 hrs
Test Coverage
/* @flow */

import axios from 'axios';
import wyt from 'wyt';

export const SHOW_STATUS_RETURNING_SERIES = 'returning_series';
export const SHOW_STATUS_IN_PRODUCTION = 'in_production';
export const SHOW_STATUS_PLANNED = 'planned';
export const SHOW_STATUS_CANCELED = 'canceled';
export const SHOW_STATUS_ENDED = 'ended';
export const SHOW_STATUS_UNKNOWN = 'unknown';

type ShowStatus =
  | 'returning_series'
  | 'in_production'
  | 'planned'
  | 'canceled'
  | 'ended'
  | 'unknown'
  ;

type ShowRating = {
  percentage: number;
  watching: number;
  votes: number;
  loved: number;
  hated: number;
};

type ShowImageSet = {
  poster: ?string;
  fanart: ?string;
  banner: ?string;
};

type Torrent = {
  provider: ?string;
  peers: number;
  seeds: number;
  url: ?string;
};

type Torrents = { [key: string]: Torrent };

type Episode = {
  tvdbId: ?string;
  title: ?string;
  episode: number;
  season: number;
  firstAired: ?Date;
  dateBased: boolean;
  overview: ?string;
  torrents: ?Torrents;
};

type ShowStub = {
  id: string;
  imdbId: ?string;
  tvdbId: ?string;
  title: string;
  slug: string;
  year: ?number;
  seasons: ?number;
  images: ShowImageSet;
  rating: ?ShowRating;
};

type Show = {
  id: string;
  imdbId: ?string;
  tvdbId: ?string;
  title: string;
  slug: string;
  year: ?number;
  synopsis: ?string;
  runtime: ?number;
  country: ?string;
  network: ?string;
  airDay: ?string;
  airTime: ?string;
  status: ShowStatus;
  seasons: ?number;
  lastUpdated: ?Date;
  episodes: Array<Episode>;
  genres: Array<string>;
  images: ShowImageSet;
  rating: ?ShowRating;
};

type EztvApiClient = {
  getShows: (pageNumber?: number) => Promise<Array<ShowStub>>;
  getShow: (id: string) => Promise<?Show>;
};

type EztvApiClientOptions = {
  endpoint?: string;
  rateLimitRequests?: number;
  rateLimitInterval?: number;
};

function toArray(value: ?any | ?Array<any>): Array<any> {
  if (!value) {
    return [];
  }
  if (Array.isArray(value)) {
    return value;
  }
  return [value];
}

function asMaybeString(value: mixed): ?string {
  if (typeof value === 'string' && value.length) {
    return value;
  }
  return null;
}

function asString(value: mixed): string {
  if (typeof value === 'string') {
    return String(value);
  }
  throw new Error('Expected value to be string');
}

function asMaybeInteger(value: ?mixed): ?number {
  const int = Number.parseInt(String(value), 10);
  if (!Number.isNaN(int)) {
    return int;
  }
  return null;
}

function asInteger(value: mixed): number {
  const int = asMaybeInteger(value);
  if (typeof int !== 'undefined' && int !== null) {
    return int;
  }
  throw new Error('Expected value to be integer');
}

function asMaybeDate(value: mixed): ?Date {
  if (typeof value === 'string' || typeof value === 'number') {
    const d = new Date(value);
    if (Number.isNaN(d.getMonth())) {
      throw new Error('Expected value to be a date');
    }
    return d;
  }
  return null;
}

function unmarshalShowStatus(status): ShowStatus {
  switch (status) {
    case 'returning series':
      return SHOW_STATUS_RETURNING_SERIES;
    case 'in production':
      return SHOW_STATUS_IN_PRODUCTION;
    case 'planned':
      return SHOW_STATUS_PLANNED;
    case 'canceled':
      return SHOW_STATUS_CANCELED;
    case 'ended':
      return SHOW_STATUS_ENDED;
    default:
      return SHOW_STATUS_UNKNOWN;
  }
}

function unmarshalImageSet(data: Object): ShowImageSet {
  const images: ShowImageSet = {
    poster: asMaybeString(data && data.poster),
    fanart: asMaybeString(data && data.fanart),
    banner: asMaybeString(data && data.banner),
  };
  return images;
}

function unmarshalMaybeShowRating(data: Object): ?ShowRating {
  const rating: ?ShowRating = (data && {
    percentage: asInteger(data.percentage),
    watching: asInteger(data.watching),
    votes: asInteger(data.votes),
    loved: asInteger(data.loved),
    hated: asInteger(data.hated),
  }) || null;
  return rating;
}

function unmarshalShowStub(data: Object): ShowStub {
  return {
    // eslint-disable-next-line no-underscore-dangle
    id: asString(data._id),
    imdbId: asMaybeString(data.imdb_id),
    tvdbId: asMaybeString(data.tvdb_id),
    title: asString(data.title),
    slug: asString(data.slug),
    year: asMaybeInteger(data.year),
    seasons: asMaybeInteger(data.num_seasons),
    images: unmarshalImageSet(data.images),
    rating: unmarshalMaybeShowRating(data.rating),
  };
}

function unmarshalTorrent(data: Object): Torrent {
  return {
    provider: asMaybeString(data.provider),
    peers: asMaybeInteger(data.peers) || 0,
    seeds: asMaybeInteger(data.seeds) || 0,
    url: asMaybeString(data.url),
  };
}

function unmarshalMaybeTorrents(data: Object): ?Torrents {
  if (typeof data !== 'object') {
    return null;
  }

  // eslint-disable-next-line arrow-body-style
  return Object.keys(data).reduce((torrents, resolution) => {
    const torrent = data[resolution];
    if (!torrent) {
      return torrents;
    }
    return {
      ...torrents,
      [resolution]: unmarshalTorrent(torrent),
    };
  }, {});
}

function unmarshalEpisode(data: Object): Episode {
  return {
    tvdbId: asMaybeString(String(data.tvdb_id)),
    firstAired: asMaybeDate(data.first_aired * 1000),
    dateBased: !!data.date_based,
    overview: asMaybeString(data.overview),
    title: asMaybeString(data.title),
    episode: asInteger(data.episode),
    season: asInteger(data.season),
    torrents: unmarshalMaybeTorrents(data.torrents),
  };
}

function unmarshalShow(data: Object): Show {
  return {
    // eslint-disable-next-line no-underscore-dangle
    id: asString(data._id),
    imdbId: asMaybeString(data.imdb_id),
    tvdbId: asMaybeString(data.tvdb_id),
    title: asString(data.title),
    slug: asString(data.slug),
    year: asMaybeInteger(data.year),
    synopsis: asMaybeString(data.synopsis),
    runtime: asMaybeInteger(Number(data.runtime)),
    country: asMaybeString(data.country),
    network: asMaybeString(data.network),
    airDay: asMaybeString(data.air_day),
    airTime: asMaybeString(data.air_time),
    status: unmarshalShowStatus(data.status),
    seasons: asMaybeInteger(data.num_seasons),
    lastUpdated: asMaybeDate(Number(data.last_updated)),
    episodes: toArray(data.episodes).map(unmarshalEpisode),
    genres: toArray(data.genres).map(genre => String(genre)),
    images: unmarshalImageSet(data.images),
    rating: unmarshalMaybeShowRating(data.rating),
  };
}

const defaultOptions: EztvApiClientOptions = {
  endpoint: 'https://api-fetch.website/tv',
  rateLimitRequests: 1,
  rateLimitInterval: 1000,
};

export function createClient(options?: EztvApiClientOptions = {}): EztvApiClient {
  const opts = {
    ...defaultOptions,
    ...options,
  };

  const rateLimit = wyt(opts.rateLimitRequests, opts.rateLimitInterval);

  async function request(pathname): Promise<any> {
    await rateLimit();
    const response = await axios({
      validateStatus: () => true,
      headers: {
        'User-Agent': 'Gozilla/13.37 (Linux x86_64) Lazerfox/42',
      },
      method: 'GET',
      url: `${opts.endpoint}${pathname}`,
    });

    return response.data;
  }

  async function getShows(pageNumber?: number = 1): Promise<Array<ShowStub>> {
    const data = await request(`/shows/${pageNumber}`);
    if (!Array.isArray(data)) {
      throw new Error('Invalid response');
    }

    return data.map(unmarshalShowStub);
  }

  async function getShow(id: string): Promise<?Show> {
    const data = await request(`/show/${id}`);
    if (typeof data !== 'object') {
      return null;
    }

    return unmarshalShow(data);
  }

  const client: EztvApiClient = {
    getShows,
    getShow,
  };

  return client;
}