diff --git a/app/api/Butter.js b/app/api/Butter.js index e4f33c99..2c276c56 100644 --- a/app/api/Butter.js +++ b/app/api/Butter.js @@ -2,45 +2,47 @@ * The highest level abstraction layer for querying torrents and metadata * @flow */ -import TorrentAdapter from './torrents/TorrentAdapter' -import MetadataAdapter from './metadata/MetadataAdapter' -import PctMetadataProvider from './metadata/PctMetadataProvider' -import TraktMetadataProvider from './metadata/TraktMetadataProvider' +import MetadataAdapter from './Metadata/TraktMetadataProvider' +import PctTorrentProvider from './Torrents/PctTorrentProvider' +import TorrentAdapter from './Torrents' export class Butter { - metadata: MetadataAdapter + metadataAdapter: MetadataAdapter - trakt: TraktMetadataProvider - pctAdapter: PctMetadataProvider + pctAdapter: PctTorrentProvider + + torrentAdapter: TorrentAdapter constructor() { - this.metadata = MetadataAdapter - this.pctAdapter = new PctMetadataProvider() - this.trakt = new TraktMetadataProvider() + this.pctAdapter = new PctTorrentProvider() + this.metadataAdapter = new MetadataAdapter() + this.torrentAdapter = new TorrentAdapter() } - getMovies = (page: number = 1, limit: number = 50) => this.pctAdapter.getMovies(page, limit) + getMovies = (page: number = 1) => this.pctAdapter.getMovies(page) getMovie = (itemId: string) => this.pctAdapter.getMovie(itemId) - getShows = (page: number = 1, limit: number = 50) => this.pctAdapter.getShows(page, limit) + getShows = (page: number = 1) => this.pctAdapter.getShows(page) - getShow = (itemId: string) => { - return this.pctAdapter.getShow(itemId).then(pctShow => { + getShow = (itemId: string) => this.pctAdapter.getShow(itemId) + .then(pctShow => this.metadataAdapter + .getSeasons(pctShow.id, pctShow.seasons) + .then(seasons => ({ + ...pctShow, + seasons, + }))) - // Deze wordt leidend! Episode info van pctShow hier in mergen - this.trakt.getSeasons(pctShow.id).then(show => { - console.log('trakt', show) - }) + searchEpisode = (...args) => this.torrentAdapter.searchEpisode(...args) - return pctShow - }) - } + search = (...args) => this.torrentAdapter.search(...args) - searchTorrent = (itemId: string, type: string) => { - return TorrentAdapter(itemId, type, {}, false) - } + /* + searchTorrent = (itemId: string, type: string) => { + return TorrentAdapter(itemId, type, {}, false) + } + */ /* getSeasons(itemId: string) { return MetadataAdapter.getSeasons(itemId); @@ -58,15 +60,17 @@ export class Butter { return MetadataAdapter.getSimilar(type, itemId, 5); }*/ - /** + /* + /!** * @param {string} itemId * @param {string} type | Type of torrent: movie or show * @param {object} extendedDetails | Additional details provided for heuristics * @param {boolean} returnAll + *!/ + getTorrent(itemId: string, type: string, extendedDetails: { [option: string]: string | number } = {}, returnAll: boolean = false) { + return TorrentAdapter(itemId, type, extendedDetails, returnAll) + } */ - getTorrent(itemId: string, type: string, extendedDetails: { [option: string]: string | number } = {}, returnAll: boolean = false) { - return TorrentAdapter(itemId, type, extendedDetails, returnAll) - } /* getSubtitles(itemId: string, filename: string, length: number, metadata: Object) { return MetadataAdapter.getSubtitles(itemId, filename, length, metadata); diff --git a/app/api/Metadata/MetadataAdapter.js b/app/api/Metadata/MetadataAdapter.js new file mode 100644 index 00000000..e832236c --- /dev/null +++ b/app/api/Metadata/MetadataAdapter.js @@ -0,0 +1,16 @@ +import { MetadataProviderInterface } from './MetadataProviderInterface' +import TraktMetadataProvider from './TraktMetadataProvider' + +export class MetadataAdapter implements MetadataProviderInterface { + + providers = [ + new TraktMetadataProvider(), + ] + + getSeasons = (itemId: string, pctSeasons) => Promise.all( + this.providers.map(provider => provider.getSeasons(itemId, pctSeasons)), + ) + +} + +export default MetadataAdapter diff --git a/app/api/Metadata/MetadataProviderInterface.js b/app/api/Metadata/MetadataProviderInterface.js new file mode 100644 index 00000000..3dbb488f --- /dev/null +++ b/app/api/Metadata/MetadataProviderInterface.js @@ -0,0 +1,18 @@ +// @flow +import type { MovieType, ShowType } from './MetadataTypes' + +export interface MetadataProviderInterface { + + getMovies: (page: number, limit: number, options: optionsType) => Promise, + + getMovie: (itemId: string) => MovieType, + + getShows: (page: number, limit: number) => Promise, + + getShow: (itemId: string) => ShowType, + + getSeasons: (itemId: string, pctSeasons: Array) => Promise, + + getStatus: () => Promise, + +} diff --git a/app/api/metadata/MetadataTypes.js b/app/api/Metadata/MetadataTypes.js similarity index 51% rename from app/api/metadata/MetadataTypes.js rename to app/api/Metadata/MetadataTypes.js index c181afcd..1dec064e 100644 --- a/app/api/metadata/MetadataTypes.js +++ b/app/api/Metadata/MetadataTypes.js @@ -1,4 +1,6 @@ // @flow +import type { TorrentType } from 'api/Torrents/TorrentsTypes' + export type ContentType = { id: string, title: string, @@ -8,36 +10,44 @@ export type ContentType = { fanart: ImageType, }, type: string, - rating: RatingType + rating: RatingType, + summary: string, + genres: string, + runtime: RuntimeType, } export type MovieType = ContentType & { certification: string, - summary: string, - runtime: string, trailer: string, - genres: string, - rating: string, torrents: { '1080p': TorrentType, '720p': TorrentType, }, } -export type ShowType = ContentType & {} +export type ShowType = ContentType & { + genres: string, + seasons: Array, +} -export type TorrentType = { - url: string, - seeds: number, - peers: number, - size: number, - filesize: string, - provider: string, - health: { - text: string, - number: number, - color: string, - } +export type SeasonType = { + title: string, + summary: string, + number: number, + episodes: Array, +} + +export type EpisodeType = { + id: string, + title: string, + number: number, + summary: string, + aired: number, + torrents: { + '1080p': TorrentType, + '720p': TorrentType, + '420p': TorrentType, + }, } export type ImageType = { @@ -58,13 +68,6 @@ export type RuntimeType = { minutes: number } -type seasonType = {} - -type episodeType = seasonType & {} - -export type certificationType = 'G' | 'PG' | 'PG-13' | 'R' | 'n/a' - -export type imagesType = {} - -// sort?: 'ratings' | 'popular' | 'trending', -export type optionsType = {} +export type filterType = { + sort?: 'populaity' | 'trending', +} diff --git a/app/api/Metadata/TraktMetadataProvider/TraktMetadataProvider.js b/app/api/Metadata/TraktMetadataProvider/TraktMetadataProvider.js new file mode 100644 index 00000000..78ba21a6 --- /dev/null +++ b/app/api/Metadata/TraktMetadataProvider/TraktMetadataProvider.js @@ -0,0 +1,56 @@ +// @flow +import Trakt from 'trakt.tv' +import hasOwnProperty from 'has-own-property' + +import type { EpisodeType } from './TraktMetadataTypes' +import type { MetadataProviderInterface } from '../MetadataProviderInterface' + +export default class TraktMetadataAdapter implements MetadataProviderInterface { + + clientId = '647c69e4ed1ad13393bf6edd9d8f9fb6fe9faf405b44320a6b71ab960b4540a2' + clientSecret = 'f55b0a53c63af683588b47f6de94226b7572a6f83f40bd44c58a7c83fe1f2cb1' + + trakt: Trakt + + constructor() { + this.trakt = new Trakt({ + client_id : this.clientId, + client_secret: this.clientSecret, + }) + } + + getSeasons(itemId: string, pctSeasons) { + return this.trakt.seasons + .summary({ id: itemId, extended: 'episodes,full' }) + .then(response => response.filter(season => season.aired_episodes !== 0)) + .then(seasons => this.formatSeasons(seasons, pctSeasons)) + } + + formatSeasons = (seasons, pctSeasons) => seasons.map(season => ({ + title : season.title, + summary : season.overview, + number : season.number, + episodes: this.formatEpisodes(season.episodes, pctSeasons[season.number]), + })) + + formatEpisodes = (episodes, pctSeason) => episodes.map((episode: EpisodeType) => ({ + id : episode.ids.imdb, + title : episode.title, + summary : episode.overview, + number : episode.number, + aired : new Date(episode.first_aired).getTime(), + torrents: this.getEpisodeTorrents(episode, pctSeason), + })) + + getEpisodeTorrents = (episode, pctSeason) => { + if (!pctSeason || !hasOwnProperty(pctSeason, episode.number)) { + return { + '1080p': null, + '720p' : null, + '480p' : null, + } + } + + return pctSeason[episode.number].torrents + } +} diff --git a/app/api/Metadata/TraktMetadataProvider/TraktMetadataTypes.js b/app/api/Metadata/TraktMetadataProvider/TraktMetadataTypes.js new file mode 100644 index 00000000..9e15b90d --- /dev/null +++ b/app/api/Metadata/TraktMetadataProvider/TraktMetadataTypes.js @@ -0,0 +1,24 @@ +/** + * Created by tycho on 10/07/2017. + */ + +export type EpisodeType = { + available_translations: Array, + first_aired: string, + ids: { + imbdb: string, + tmdb: number, + trakt: number, + tvdb: number, + tvrage: number, + } + number: number, + number_abs: number, + overview: string, + rating: number, + runtime: number, + season: number, + title: string, + updated_at: string, + votes: number, +} diff --git a/app/api/metadata/TraktMetadataProvider/index.js b/app/api/Metadata/TraktMetadataProvider/index.js similarity index 100% rename from app/api/metadata/TraktMetadataProvider/index.js rename to app/api/Metadata/TraktMetadataProvider/index.js diff --git a/app/api/Metadata/index.js b/app/api/Metadata/index.js new file mode 100644 index 00000000..66f7ce13 --- /dev/null +++ b/app/api/Metadata/index.js @@ -0,0 +1 @@ +export default from './MetadataAdapter' diff --git a/app/api/Player/PlayerInterface.js b/app/api/Player/PlayerInterface.js new file mode 100644 index 00000000..2a0ec6c0 --- /dev/null +++ b/app/api/Player/PlayerInterface.js @@ -0,0 +1,36 @@ +// @flow +import type { MetadataType } from './PlayerProviderTypes' + +export interface PlayerProviderInterface { + + load: (uri: string, metadata: MetadataType) => Promise, + + play: (uri: ?string, metadata: ?MetadataType) => Promise, + + pause: () => Promise, + + stop: () => Promise, + + registerEvent: (event: string, callback: () => void) => void, + + isPlaying: () => boolean, + + /*provider: string, + + supportedFormats: Array, + + supportsSubtitles: boolean, + + svgIconFilename: string, + + contentUrl: string, + + port: number, + + constructor: () => void, + + restart: () => Promise, + + destroy: () => Promise,*/ + +} diff --git a/app/api/Player/PlayerTypes.js b/app/api/Player/PlayerTypes.js new file mode 100644 index 00000000..8ac79868 --- /dev/null +++ b/app/api/Player/PlayerTypes.js @@ -0,0 +1,16 @@ +// @flow +export type DeviceType = { + id: string, + name: string, + address: string, + port: number +} + +export type MetadataType = { + title: string, + type: ?string, + image: { + poster: string + }, + autoPlay: ?boolean, +} diff --git a/app/api/Player/PlyrProvider/PlyrPlayerProvider.js b/app/api/Player/PlyrProvider/PlyrPlayerProvider.js new file mode 100644 index 00000000..059cacf2 --- /dev/null +++ b/app/api/Player/PlyrProvider/PlyrPlayerProvider.js @@ -0,0 +1,125 @@ +// @flow +import { remote } from 'electron' +import debug from 'debug' +import plyr from 'plyr' + +import Events from 'api/Events' +import * as PlayerEvents from 'api/Player/PlayerEvents' +import * as PlayerStatuses from 'api/Player/PlayerStatuses' +import { PlayerProviderInterface } from '../PlayerProviderInterface' +import type { MetadataType } from '../PlayerProviderTypes' + +const { powerSaveBlocker } = remote +const log = debug('api:players:plyr') + +class PlyrPlayerProvider implements PlayerProviderInterface { + + provider = 'Plyr' + + player: plyr + + powerSaveBlockerId: number + + status: string = PlayerStatuses.NONE + + supportedFormats = [ + 'mp4', + 'ogg', + 'mov', + 'webmv', + 'mkv', + 'wmv', + 'avi', + ] + + constructor() { + this.powerSaveBlockerId = powerSaveBlocker.start('prevent-app-suspension') + } + + getPlayer = () => { + if (!this.player) { + this.player = plyr.setup({ + volume : 10, + autoplay : true, + showPosterOnEnd: true, + })[0] + + if (this.player) { + this.setEventListeners() + } + } + + return this.player + } + + load = (uri: string, metadata: MetadataType) => { + const player = this.getPlayer() + + player.source({ + title : metadata.title, + type : 'video', + sources: [ + { + src : uri, + type: metadata.type || 'video/mp4', + }, + ], + }) + } + + play = (uri: ?string, metadata: ?MetadataType) => { + if (uri && metadata) { + this.load(uri, metadata) + } + + this.player.play() + } + + pause = () => { + this.player.pause() + } + + stop = () => { + this.player.stop() + } + + isPlaying = () => this.status === PlayerStatuses.PLAYING + + setEventListeners = () => { + this.player.on('play', this.onPlay) + this.player.on('pause', this.onPause) + this.player.on('ended', this.onEnded) + } + + onPlay = () => this.updateStatus(PlayerStatuses.PLAYING) + + onPause = () => this.updateStatus(PlayerStatuses.PAUSED) + + onEnded = () => this.updateStatus(PlayerStatuses.ENDED) + + updateStatus = (newStatus) => { + log(`Update status to ${newStatus}`) + + Events.emit(PlayerEvents.STATUS_CHANGE, { + oldState: this.status, + newStatus, + }) + this.status = newStatus + } + + destroy = () => { + if (this.powerSaveBlockerId) { + powerSaveBlocker.stop(this.powerSaveBlockerId) + } + + if (this.player) { + this.player.destroy() + this.player = null + + this.updateStatus(PlayerStatuses.NONE) + } + } + +} + +export default PlyrPlayerProvider diff --git a/app/api/Player/PlyrProvider/index.js b/app/api/Player/PlyrProvider/index.js new file mode 100644 index 00000000..2366b126 --- /dev/null +++ b/app/api/Player/PlyrProvider/index.js @@ -0,0 +1,4 @@ +/** + * Created by tycho on 07/07/2017. + */ +export default from './PlyrPlayerProvider' diff --git a/app/api/Player/StreamProviders/ChromeCastProvider/ChromeCastProvider.js b/app/api/Player/StreamProviders/ChromeCastProvider/ChromeCastProvider.js new file mode 100644 index 00000000..b91de5ec --- /dev/null +++ b/app/api/Player/StreamProviders/ChromeCastProvider/ChromeCastProvider.js @@ -0,0 +1,140 @@ +// @flow +import { Client, DefaultMediaReceiver } from 'castv2-client'; +import mdns from 'mdns'; +import debug from 'debug' +import type { + PlayerProviderInterface, + deviceType, + metadataType +} from './PlayerProviderInterface'; + + +const log = debug('api:players:chromecast') + +type castv2DeviceType = { + fullname: string, + addresses: Array, + port: number, + txtRecord: { + fn: string + } +}; + +export class ChromecastProvider implements PlayerProviderInterface { + + provider = 'Chromecast'; + + providerId = 'chromecast'; + + supportsSubtitles = true; + + selectedDevice: deviceType; + + devices: Array = []; + + browser: { + on: (event: string, cb: (device: castv2DeviceType) => void) => void, + start: () => void, + stop: () => void + }; + + constructor() { + this.browser = mdns.createBrowser(mdns.tcp('googlecast')); + } + + getDevices(timeout: number = 2000) { + return new Promise(resolve => { + const devices = []; + + this.browser.on('serviceUp', service => { + devices.push({ + name : service.txtRecord.fn, + id : service.fullname, + address: service.addresses[0], + port : service.port + }); + }); + + this.browser.start(); + + setTimeout(() => { + this.browser.stop(); + resolve(devices); + this.devices = devices; + }, timeout); + }); + } + + selectDevice(deviceId: string) { + const selectedDevice = this.devices.find(device => device.id === deviceId); + if (!selectedDevice) { + throw new Error('Cannot find selected device'); + } + this.selectedDevice = selectedDevice; + return selectedDevice; + } + + play(contentUrl: string, item) { + const client = new Client(); + + if (!this.selectDevice) { + throw new Error('No device selected'); + } + + return new Promise((resolve, reject) => { + log(`Connecting to: ${this.selectedDevice.name} (${this.selectedDevice.address})`) + + client.connect(this.selectedDevice.address, () => { + log(`Connected to: ${this.selectedDevice.name} (${this.selectedDevice.address})`) + + client.launch(DefaultMediaReceiver, (error, player) => { + if (error) { + reject(error); + } + + // on close + + player.on('status', (status) => { + log('Status broadcast playerState=%s', status.playerState); + }) + + const media = { + // Here you can plug an URL to any mp4, webm, mp3 or jpg file with the proper contentType. + contentId : contentUrl, + contentType: 'video/mp4', + streamType : 'BUFFERED', // or LIVE + + // Title and cover displayed while buffering + metadata: { + type : 0, + metadataType: 0, + title : item.title, + images : [ + { + url: item.images.poster.full || item.images.fanart.full + } + ] + } + }; + + player.load(media, { autoplay: true }, (error) => { + if (error) { + reject(error) + } + resolve(); + }); + }); + }); + + client.on('error', (error) => { + console.log('Error: %s', error.message); + + client.close(); + + reject(error); //Error + }); + }); + } +} + +export default ChromecastProvider; diff --git a/app/api/Player/StreamProviders/ChromeCastProvider/index.js b/app/api/Player/StreamProviders/ChromeCastProvider/index.js new file mode 100644 index 00000000..1f88e830 --- /dev/null +++ b/app/api/Player/StreamProviders/ChromeCastProvider/index.js @@ -0,0 +1,3 @@ +import ChromeCastProvider from './ChromeCastProvider' + +export default ChromeCastProvider diff --git a/app/api/Player/StreamProviders/index.js b/app/api/Player/StreamProviders/index.js new file mode 100644 index 00000000..c9660fec --- /dev/null +++ b/app/api/Player/StreamProviders/index.js @@ -0,0 +1,10 @@ +/** + * Created by tycho on 10/07/2017. + */ +import ChromeCastProvider from './ChromeCastProvider' + +export const streamingProviders = () => [ + new ChromeCastProvider(), +] + +export default streamingProviders diff --git a/app/api/metadata/PctMetadataProvider/PctMetadataHelpers.js b/app/api/Torrents/PctTorrentProvider/PctTorrentHelpers.js similarity index 73% rename from app/api/metadata/PctMetadataProvider/PctMetadataHelpers.js rename to app/api/Torrents/PctTorrentProvider/PctTorrentHelpers.js index d22bfa72..755d6576 100644 --- a/app/api/metadata/PctMetadataProvider/PctMetadataHelpers.js +++ b/app/api/Torrents/PctTorrentProvider/PctTorrentHelpers.js @@ -1,4 +1,5 @@ -import type { ImageType, TorrentType, RatingType } from './PctMetadataTypes' +import { getHealth, formatRuntimeMinutesToObject } from 'api/Torrents/TorrentHelpers' +import type { ImageType, TorrentType, RatingType } from './PctTorrentTypes' export const formatImages = (images: ImageType) => { const replaceWidthPart = (uri: string, replaceWith: string) => uri.replace('w500', replaceWith) @@ -20,38 +21,10 @@ export const formatImages = (images: ImageType) => { } export const formatTorrents = (torrents, type = 'movie') => { - const getHealth = (seed, peer) => { - const ratio = seed && !!peer ? seed / peer : seed - - if (ratio > 1 && seed >= 50 && seed < 100) { - return { - text : 'decent', - color : '#FF9800', - number: 1, - } - } - - if (ratio > 1 && seed >= 100) { - return { - text : 'healthy', - color : '#4CAF50', - number: 2, - } - } - - return { - text : 'poor', - color : '#F44336', - number: 0, - } - } - const formatTorrent = (torrent: TorrentType, quality: string) => ({ ...torrent, quality, - health: { - ...getHealth(torrent.seed || torrent.seeds, torrent.peer || torrent.peers), - }, + health: getHealth(torrent.seed || torrent.seeds, torrent.peer || torrent.peers), seeds : torrent.seed || torrent.seeds, peers : torrent.peer || torrent.peers, }) @@ -78,7 +51,7 @@ export const formatRating = (rating: RatingType) => ({ export const formatShowEpisodes = (episodes) => { let seasons = [] - episodes.map(episode => { + episodes.map((episode) => { if (!seasons[episode.season]) { seasons[episode.season] = [] } @@ -86,6 +59,7 @@ export const formatShowEpisodes = (episodes) => { seasons[episode.season][episode.episode] = { summary : episode.overview, season : episode.season, + number : episode.season, episode : episode.episode, torrents: formatTorrents(episode.torrents, 'show'), } @@ -93,3 +67,7 @@ export const formatShowEpisodes = (episodes) => { return seasons } + +export { + formatRuntimeMinutesToObject, +} diff --git a/app/api/Torrents/PctTorrentProvider/PctTorrentProvider.js b/app/api/Torrents/PctTorrentProvider/PctTorrentProvider.js new file mode 100644 index 00000000..8040dced --- /dev/null +++ b/app/api/Torrents/PctTorrentProvider/PctTorrentProvider.js @@ -0,0 +1,82 @@ +// @flow +import axios from 'axios' + +import * as Helpers from './PctTorrentHelpers' +import type { TorrentProviderInterface } from '../TorrentProviderInterface' +import type { MovieType, ShowType, ShowDetailType } from './PctTorrentTypes' + +export default class PctTorrentProvider implements TorrentProviderInterface { + + popcornAPI: axios = axios.create({ + baseURL: 'https://movies-v2.api-fetch.website/', + }) + + getMovies = (page: number = 1, filters = { limit: 50, sort: 'trending' }) => ( + this.popcornAPI.get(`movies/${page}`, { params: { ...filters } }) + .then(response => this.formatMovies(response.data)) + ) + + getMovie = (itemId: string) => ( + this.popcornAPI.get(`movie/${itemId}`) + .then(response => this.formatMovie(response.data)) + ) + + getShows = (page: number = 1, filters = { limit: 50, sort: 'trending' }) => ( + this.popcornAPI.get(`shows/${page}`, { params: { ...filters } }) + .then(response => this.formatShows(response.data)) + ) + + getShow = (itemId: string) => ( + this.popcornAPI.get(`show/${itemId}`) + .then(response => this.formatShow(response.data, true)) + ) + + formatMovies = (movies: Array) => (movies.map((movie: MovieType) => this.formatMovie(movie))) + + formatMovie = (movie: MovieType) => ({ + id : movie.imdb_id, + title : movie.title, + year : movie.year, + certification: movie.certification, + summary : movie.synopsis, + runtime : Helpers.formatRuntimeMinutesToObject(movie.runtime), + trailer : movie.trailer, + images : Helpers.formatImages(movie.images), + genres : movie.genres, + rating : Helpers.formatRating(movie.rating), + torrents : Helpers.formatTorrents(movie.torrents.en), + type : 'movie', + }) + + formatShows = (shows: Array) => (shows.map(show => this.formatShow(show))) + + formatShow = (show: ShowType | ShowDetailType, isDetail: boolean = false) => { + let formattedShow = { + id : show.imdb_id, + title : show.title, + year : show.year, + images : Helpers.formatImages(show.images), + rating : Helpers.formatRating(show.rating), + num_seasons: show.num_seasons, + type : 'show', + } + + if (isDetail) { + formattedShow = { + ...formattedShow, + runtime: Helpers.formatRuntimeMinutesToObject(show.runtime), + seasons: Helpers.formatShowEpisodes(show.episodes), + summary: show.synopsis, + genres : show.genres, + status : show.status, + } + } + + return formattedShow + } + + getStatus = () => ( + this.popcornAPI.get().then(res => res.ok).catch(() => false) + ) + +} diff --git a/app/api/metadata/PctMetadataProvider/PctMetadataTypes.js b/app/api/Torrents/PctTorrentProvider/PctTorrentTypes.js similarity index 100% rename from app/api/metadata/PctMetadataProvider/PctMetadataTypes.js rename to app/api/Torrents/PctTorrentProvider/PctTorrentTypes.js diff --git a/app/api/Torrents/PctTorrentProvider/index.js b/app/api/Torrents/PctTorrentProvider/index.js new file mode 100644 index 00000000..e1c66f8d --- /dev/null +++ b/app/api/Torrents/PctTorrentProvider/index.js @@ -0,0 +1,3 @@ +import PctTorrentProvider from './PctTorrentProvider' + +export default PctTorrentProvider diff --git a/app/api/Torrents/TorrentAdapter.js b/app/api/Torrents/TorrentAdapter.js new file mode 100644 index 00000000..6e1d135b --- /dev/null +++ b/app/api/Torrents/TorrentAdapter.js @@ -0,0 +1,32 @@ +import debug from 'debug' +import TorrentCollection from 'TorrentCollection' + +import type { ShowType } from 'api/Metadata/MetadataTypes' +import { TorrentProviderInterface } from './TorrentProviderInterface' +import torrentProviders from './TorrentProviders' + +const log = debug('api:torrents:adapter') + +export class TorrentAdapter implements TorrentProviderInterface { + + providers = torrentProviders() + + searchEpisode = (item: ShowType, season: string, episode: string) => { + log(`Search episode ${episode} of season ${season} from the show ${item.title}`) + + return Promise.all( + this.providers.map(provider => provider.searchEpisode(item, season, episode)), + ) + } + + search = (search: string) => { + log(`Search: ${search}`) + + return Promise.all( + this.providers.map(provider => provider.search(search)), + ) + } + +} + +export default TorrentAdapter diff --git a/app/api/Torrents/TorrentHelpers.js b/app/api/Torrents/TorrentHelpers.js new file mode 100644 index 00000000..f0c51e6a --- /dev/null +++ b/app/api/Torrents/TorrentHelpers.js @@ -0,0 +1,133 @@ +// @flow +/* eslint prefer-template: 0 */ +import cache from 'lru-cache' +import url from 'url' + +export const formatShowToSearchQuery = (title, season, episode) => { + let searchTitle = title.toLowerCase() + .replace(' ', '.') + + return `${searchTitle}.${formatSeasonEpisodeToString(season, episode)}` +} + +export const formatSeasonEpisodeToString = (season, episode) => ( + 's' + (String(season).length === 1 ? '0' + String(season) : String(season)) + + 'e' + (String(episode).length === 1 ? '0' + String(episode) : String(episode)) +) + +export const getHealth = (seeds, peers) => { + const ratio = seeds && !!peers ? seeds / peers : seeds + + // Normalize the data. Convert each to a percentage + // Ratio: Anything above a ratio of 5 is good + const normalizedRatio = Math.min(ratio / 5 * 100, 100) + // Seeds: Anything above 30 seeds is good + const normalizedSeeds = Math.min(seeds / 30 * 100, 100) + + // Weight the above metrics differently + // Ratio is weighted 60% whilst seeders is 40% + const weightedRatio = normalizedRatio * 0.6 + const weightedSeeds = normalizedSeeds * 0.4 + const weightedTotal = weightedRatio + weightedSeeds + + // Scale from [0, 100] to [0, 3]. Drops the decimal places + const scaledTotal = ((weightedTotal * 3) / 100) | 0 + + if (scaledTotal === 1) { + return { + text : 'decent', + color : '#FF9800', + number: 1, + ratio, + } + + } else if (scaledTotal >= 2) { + return { + text : 'healthy', + color : '#4CAF50', + number: 2, + ratio, + } + } + + return { + text : 'poor', + color : '#F44336', + number: 0, + ratio, + } +} + +export const formatRuntimeMinutesToObject = (runtimeInMinutes: number) => { + const hours = runtimeInMinutes >= 60 ? Math.round(runtimeInMinutes / 60) : 0 + const minutes = runtimeInMinutes % 60 + + return { + full : hours > 0 + ? `${hours} ${hours > 1 ? 'hours' : 'hour'}${minutes > 0 + ? ` ${minutes} minutes` + : ''}` + : `${minutes} minutes`, + short: hours > 0 + ? `${hours} ${hours > 1 ? 'hrs' : 'hr'}${minutes > 0 + ? ` ${minutes} min` + : ''}` + : `${minutes} min`, + hours, + minutes, + } +} + +export const determineQuality = (magnet: string): string => { + const lowerCaseMetadata = magnet.toLowerCase() + + // Filter non-english languages + if (hasNonEnglishLanguage(lowerCaseMetadata)) { + return null + } + + // Most accurate categorization + if (lowerCaseMetadata.includes('1080')) return '1080p' + if (lowerCaseMetadata.includes('720')) return '720p' + if (lowerCaseMetadata.includes('480')) return '480p' + + // Guess the quality 1080p + if (lowerCaseMetadata.includes('bluray')) return '1080p' + if (lowerCaseMetadata.includes('blu-ray')) return '1080p' + + // Guess the quality 720p, prefer english + if (lowerCaseMetadata.includes('dvd')) return '720p' + if (lowerCaseMetadata.includes('rip')) return '720p' + if (lowerCaseMetadata.includes('mp4')) return '720p' + if (lowerCaseMetadata.includes('web')) return '720p' + if (lowerCaseMetadata.includes('hdtv')) return '720p' + if (lowerCaseMetadata.includes('eng')) return '720p' + + return null +} + +export const hasNonEnglishLanguage = (metadata: string): boolean => { + if (metadata.includes('french')) return true + if (metadata.includes('german')) return true + if (metadata.includes('greek')) return true + if (metadata.includes('dutch')) return true + if (metadata.includes('hindi')) return true + if (metadata.includes('português')) return true + if (metadata.includes('portugues')) return true + if (metadata.includes('spanish')) return true + if (metadata.includes('español')) return true + if (metadata.includes('espanol')) return true + if (metadata.includes('latino')) return true + if (metadata.includes('russian')) return true + if (metadata.includes('subtitulado')) return true + + return false +} + +export const betBestTorrent = (torrentOne, torrentTwo) => { + if (torrentOne.health.ratio > torrentTwo.health.ratio) { + return torrentOne + } + + return torrentTwo +} diff --git a/app/api/Torrents/TorrentProviderInterface.js b/app/api/Torrents/TorrentProviderInterface.js new file mode 100644 index 00000000..ca0aeab6 --- /dev/null +++ b/app/api/Torrents/TorrentProviderInterface.js @@ -0,0 +1,8 @@ +// @flow +export interface TorrentProviderInterface { + + getStatus: () => Promise, + + search: () => Promise + +} diff --git a/app/api/Torrents/TorrentProviders/RarbgTorrentProvider.js b/app/api/Torrents/TorrentProviders/RarbgTorrentProvider.js new file mode 100644 index 00000000..d8da99de --- /dev/null +++ b/app/api/Torrents/TorrentProviders/RarbgTorrentProvider.js @@ -0,0 +1,76 @@ +// @flow +import rbg from 'torrentapi-wrapper' +import debug from 'debug' + +import * as Helpers from 'api/Torrents/TorrentHelpers' +import type { ShowType } from 'api/Metadata/MetadataTypes' +import type { TorrentProviderInterface } from '../TorrentProviderInterface' +import type { TorrentType } from '../TorrentsTypes' + +const log = debug('api:torrents:providers:rarbg') + +export default class RarbgTorrentProvider implements TorrentProviderInterface { + + static providerName = 'rbg' + + searchEpisode = (item: ShowType, season: string, episode: string, retry: boolean = false) => + new Promise((resolve, reject) => { + log('Searching...') + + rbg.search({ + query : Helpers.formatShowToSearchQuery(item.title, season, episode), + category: 'TV', + sort : 'seeders', + verified: false, + }).then((results) => { + const bestTorrents = {} + results.filter(torrent => torrent.episode_info.imdb === item.id) + .map(torrent => this.formatTorrent(torrent, Helpers.determineQuality(torrent.download))) + .forEach((torrent: TorrentType) => { + if (!bestTorrents[torrent.quality] || Helpers.betBestTorrent(bestTorrents[torrent.quality], torrent)) { + bestTorrents[torrent.quality] = torrent + } + }) + + resolve(bestTorrents) + + }).catch(() => { + if (!retry) { + return resolve(this.searchEpisode(item, season, episode, true)) + + } else { + return reject() + } + }) + }) + + search = (query: string, category: string, retry: boolean = false) => + new Promise((resolve, reject) => { + log(`Search ${query} in ${category}`) + rbg.search({ + query, + category, + sort : 'seeders', + verified: false, + }).then((results) => results.map(torrent => this.formatTorrent(torrent))) + .catch(() => { + if (!retry) { + return resolve(this.search(query, category, true)) + + } else { + return reject() + } + }) + }) + + formatTorrent = (torrent, quality) => ({ + url : torrent.download, + seeds : torrent.seeders, + peers : torrent.leechers, + size : torrent.size, + filesize: torrent.size, + provider: RarbgTorrentProvider.providerName, + health : Helpers.getHealth(torrent.seeders, torrent.leechers), + quality, + }) +} diff --git a/app/api/Torrents/TorrentProviders/index.js b/app/api/Torrents/TorrentProviders/index.js new file mode 100644 index 00000000..b3caebdc --- /dev/null +++ b/app/api/Torrents/TorrentProviders/index.js @@ -0,0 +1,9 @@ +import RarbgTorrentProvider from './RarbgTorrentProvider' + +export const TorrentProviders = () => ([ + new RarbgTorrentProvider(), + // new PbTorrentProvider(), + // new YtsTorrentProvider(), +]) + +export default TorrentProviders diff --git a/app/api/Torrents/TorrentsTypes.js b/app/api/Torrents/TorrentsTypes.js new file mode 100644 index 00000000..5c0d0e6a --- /dev/null +++ b/app/api/Torrents/TorrentsTypes.js @@ -0,0 +1,15 @@ +export type TorrentType = { + url: string, + seeds: number, + peers: number, + size: number, + filesize: string, + provider: string, + quality: string, + health: { + text: string, + number: number, + color: string, + ratio: number, + } +} diff --git a/app/api/Torrents/index.js b/app/api/Torrents/index.js new file mode 100644 index 00000000..787485d4 --- /dev/null +++ b/app/api/Torrents/index.js @@ -0,0 +1 @@ +export default from './TorrentAdapter' diff --git a/app/api/metadata/BaseMetadataProvider.js b/app/api/metadata/BaseMetadataProvider.js deleted file mode 100644 index 10485fe6..00000000 --- a/app/api/metadata/BaseMetadataProvider.js +++ /dev/null @@ -1,69 +0,0 @@ -// @flow -import { set, get } from '../../utils/Config' - -import type { RuntimeType } from './MetadataTypes' -import type { contentType } from './MetadataProviderInterface' - -export default class BaseMetadataProvider { - - /** - * Temporarily store the 'favorites', 'recentlyWatched', 'watchList' items - * in config file. The cache can't be used because this data needs to be - * persisted. - */ - updateConfig(type: string, method: string, metadata: contentType) { - const property = `${type}` - - switch (method) { - case 'set': - set(property, [...(get(property) || []), metadata]) - return get(property) - - case 'get': - return get(property) - - case 'remove': { - const items = [ - ...(get(property) || []).filter(item => item.id !== metadata.id) - ] - - return set(property, items) - } - default: - return set(property, [...(get(property) || []), metadata]) - } - } - - favorites(...args) { - return this.updateConfig('favorites', ...args) - } - - recentlyWatched(...args) { - return this.updateConfig('recentlyWatched', ...args) - } - - watchList(...args) { - return this.updateConfig('watchList', ...args) - } - - formatRuntimeMinutesToObject = (runtimeInMinutes: number): RuntimeType => { - const hours = runtimeInMinutes >= 60 ? Math.round(runtimeInMinutes / 60) : 0 - const minutes = runtimeInMinutes % 60 - - return { - full : hours > 0 - ? `${hours} ${hours > 1 ? 'hours' : 'hour'}${minutes > 0 - ? ` ${minutes} minutes` - : ''}` - : `${minutes} minutes`, - short: hours > 0 - ? `${hours} ${hours > 1 ? 'hrs' : 'hr'}${minutes > 0 - ? ` ${minutes} min` - : ''}` - : `${minutes} min`, - hours, - minutes, - } - } - -} diff --git a/app/api/metadata/MetadataAdapter.js b/app/api/metadata/MetadataAdapter.js deleted file mode 100644 index ad1dba7a..00000000 --- a/app/api/metadata/MetadataAdapter.js +++ /dev/null @@ -1,275 +0,0 @@ -/** - * Resolve requests from cache - * @flow - */ -import OpenSubtitles from 'opensubtitles-api' -import { merge, resolveCache, setCache } from '../torrents/BaseTorrentProvider' -// import TraktMetadataProvider from './TraktMetadataProvider'; -import type { runtimeType } from './MetadataProviderInterface' - -type subtitlesType = { - kind: 'captions', - label: string, - srclang: string, - src: string, - default: boolean -}; - -const subtitlesEndpoint = 'https://popcorn-time-api-server.herokuapp.com/subtitles' - -const openSubtitles = new OpenSubtitles({ - useragent: 'OSTestUserAgent', - username : '', - password : '', - ssl : true -}) - -function MetadataAdapter() { - return [ - // new TraktMetadataProvider(), - // new TheMovieDbMetadataProvider() - ] -} - -async function interceptAndHandleRequest(method: string, args: Array) { - const key = JSON.stringify(method) + JSON.stringify(args) - - if (resolveCache(key)) { - return Promise.resolve(resolveCache(key)) - } - - const results = await Promise.all( - MetadataAdapter().map(provider => provider[method].apply(provider, args)) // eslint-disable-line - ) - - const mergedResults = merge(results) - setCache(key, mergedResults) - - return mergedResults -} - -/** - * Get list of movies with specific paramaters - * - * @param {string} query - * @param {number} limit - * @param {string} genre - * @param {string} sortBy - */ -function search(...args: Array) { - return interceptAndHandleRequest('search', args) -} - -/** - * Get details about a specific movie - * - * @param {string} itemId - */ -function getMovie(...args: Array) { - return interceptAndHandleRequest('getMovie', args) -} - -/** - * Get list of movies with specific paramaters - * - * @param {number} page - * @param {number} limit - * @param {string} genre - * @param {string} sortBy - */ -function getMovies(...args: Array) { - return interceptAndHandleRequest('getMovies', args) -} - -/** - * Get list of movies with specific paramaters - * - * @param {string} itemId - * @param {string} type | movie or show - * @param {number} limit | movie or show - */ -function getSimilar(...args: Array) { - return interceptAndHandleRequest('getSimilar', args) -} - -/** - * Get a specific season of a show - * - * @param {string} itemId - * @param {string} type | movie or show - * @param {number} limit | movie or show - */ -function getSeason(...args: Array) { - return interceptAndHandleRequest('getSeason', args) -} - -/** - * Get a list of seasons of a show - * - * @param {string} itemId - * @param {string} type | movie or show - * @param {number} limit | movie or show - */ -function getSeasons(...args: Array) { - return interceptAndHandleRequest('getSeasons', args) -} - -/** - * Get a single episode of a season - * - * @param {string} itemId - * @param {string} type | movie or show - * @param {number} limit | movie or show - */ -function getEpisode(...args: Array) { - return interceptAndHandleRequest('getEpisode', args) -} - -/** - * Get a single show - * - * @param {string} itemId - * @param {string} type | movie or show - * @param {number} limit | movie or show - */ -function getShow(...args: Array) { - return interceptAndHandleRequest('getShow', args) -} - -/** - * Get a list of shows - * - * @param {string} itemId - * @param {string} type | movie or show - * @param {number} limit | movie or show - */ -function getShows(...args: Array) { - return interceptAndHandleRequest('getShows', args) -} - -/** - * Get the subtitles for a movie or show - * - * @param {string} itemId - * @param {string} filename - * @param {object} metadata - */ -async function getSubtitles(imdbId: string, filename: string, length: number, - metadata: { season?: number, episode?: number, activeMode?: string } = {}): Promise> { - const { activeMode } = metadata - - const defaultOptions = { - sublanguageid: 'eng', - // sublanguageid: 'all', // @TODO - // hash: '8e245d9679d31e12', // @TODO - filesize : length || undefined, - filename : filename || undefined, - season : metadata.season || undefined, - episode : metadata.episode || undefined, - extensions : ['srt', 'vtt'], - imdbid : imdbId - } - - const subtitles = (() => { - switch (activeMode) { - case 'shows': { - const { season, episode } = metadata - return openSubtitles.search({ - ...defaultOptions, - ...{ season, episode } - }) - } - default: - return openSubtitles.search(defaultOptions) - } - })() - - return subtitles.then(res => - Object.values(res).map(subtitle => formatSubtitle(subtitle)) - ) -} - -/** - * Handle actions for favorites: addition, deletion, list all - * - * @param {string} method | Ex. 'set', 'get', 'remove' - * @param {object} metadata | Required only for `set` and `remove` - * @param {object} metadata | 'id', Required only remove - */ -function favorites(...args: Array) { - return interceptAndHandleRequest('favorites', args) -} - -/** - * Handle actions for watchList: addition, deletion, list all - * - * @param {string} method | Ex. 'set', 'get', 'remove' - * @param {object} metadata | Required only for `set` and `remove` - * @param {object} metadata | 'id', Required only remove - */ -function watchList(...args: Array) { - return interceptAndHandleRequest('watchList', args) -} - -/** - * Handle actions for recentlyWatched: addition, deletion, list all - * - * @param {string} method | Ex. 'set', 'get', 'remove' - * @param {object} metadata | Required only for `set` and `remove` - * @param {object} metadata | 'id', Required only remove - */ -function recentlyWatched(...args) { - return interceptAndHandleRequest('recentlyWatched', args) -} - -/** - * Convert runtime from minutes to hours - * - * @param {number} runtimeInMinutes - * @return {object} - */ -export function parseRuntimeMinutesToObject(runtimeInMinutes: number): runtimeType { - const hours = runtimeInMinutes >= 60 ? Math.round(runtimeInMinutes / 60) : 0 - const minutes = runtimeInMinutes % 60 - - return { - full : hours > 0 - ? `${hours} ${hours > 1 ? 'hours' : 'hour'}${minutes > 0 - ? ` ${minutes} minutes` - : ''}` - : `${minutes} minutes`, - short: hours > 0 - ? `${hours} ${hours > 1 ? 'hrs' : 'hr'}${minutes > 0 - ? ` ${minutes} min` - : ''}` - : `${minutes} min`, - hours, - minutes, - } -} - -function formatSubtitle(subtitle) { - return { - kind : 'captions', - label : subtitle.langName, - srclang: subtitle.lang, - src : `${subtitlesEndpoint}/${encodeURIComponent(subtitle.url)}`, - default: subtitle.lang === 'en' - } -} - -export default { - getMovie, - getMovies, - getShow, - getShows, - getSeason, - getSeasons, - getEpisode, - search, - getSimilar, - getSubtitles, - favorites, - watchList, - recentlyWatched, -} diff --git a/app/api/metadata/MetadataProviderInterface.js b/app/api/metadata/MetadataProviderInterface.js deleted file mode 100644 index 35ef29b9..00000000 --- a/app/api/metadata/MetadataProviderInterface.js +++ /dev/null @@ -1,38 +0,0 @@ -// @flow -import type { MovieType, ShowType } from './MetadataTypes' - -export interface MetadataProviderInterface { - - getMovies: (page: number, limit: number, options: optionsType) => Promise, - - getMovie: (itemId: string) => MovieType, - - getShows: (page: number, limit: number) => Promise, - - getShow: (itemId: string) => ShowType, - - // provide: (itemId: string, type: string) => Promise>, - - getStatus: () => Promise, - - // getSimilar: (type: string, itemId: string, limit: number) => Promise>, - - // supportedIdTypes: Array<'tmdb' | 'imdb'>, - - // getSeasons: (itemId: string) => Promise>, - - // getSeason: (itemId: string, season: number) => Promise, - - // getEpisode: (itemId: string, season: number, episode: number) => episodeType, - - // search: (query: string, page: number) => Promise>, - - // updateConfig: (type: string, method: string, metadata: contentType) => void, - - // favorites: () => void, - - // recentlyWatched: () => void, - - // watchList: () => void - -} diff --git a/app/api/metadata/PctMetadataProvider/index.js b/app/api/metadata/PctMetadataProvider/index.js deleted file mode 100644 index 562851b2..00000000 --- a/app/api/metadata/PctMetadataProvider/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import PctMetadataProvider from './PctMetadataProvider' - -export default PctMetadataProvider diff --git a/app/api/metadata/TraktMetadataProvider.js_old b/app/api/metadata/TraktMetadataProvider.js_old deleted file mode 100644 index 28d321e4..00000000 --- a/app/api/metadata/TraktMetadataProvider.js_old +++ /dev/null @@ -1,206 +0,0 @@ -// @flow -import fetch from 'isomorphic-fetch'; -import Trakt from 'trakt.tv'; -import { parseRuntimeMinutesToObject } from './MetadataAdapter'; -import type { MetadataProviderInterface } from './MetadataProviderInterface'; - -export default class TraktMetadataAdapter implements MetadataProviderInterface { - clientId = '647c69e4ed1ad13393bf6edd9d8f9fb6fe9faf405b44320a6b71ab960b4540a2'; - - clientSecret = 'f55b0a53c63af683588b47f6de94226b7572a6f83f40bd44c58a7c83fe1f2cb1'; - - trakt: Trakt; - - constructor() { - this.trakt = new Trakt({ - client_id : this.clientId, - client_secret: this.clientSecret - }); - } - - getMovies(page: number = 1, limit: number = 50) { - return this.trakt.movies - .popular({ - paginate: true, - page, - limit, - extended: 'full,images,metadata' - }) - .then(movies => movies.map(movie => formatMetadata(movie, 'movies'))); - } - - getMovie(itemId: string) { - return this.trakt.movies - .summary({ - id : itemId, - extended: 'full,images,metadata' - }) - .then(movie => formatMetadata(movie, 'movies')); - } - - getShows(page: number = 1, limit: number = 50) { - return this.trakt.shows - .popular({ - paginate: true, - page, - limit, - extended: 'full,images,metadata' - }) - .then(shows => shows.map(show => formatMetadata(show, 'shows'))); - } - - getShow(itemId: string) { - return this.trakt.shows - .summary({ - id : itemId, - extended: 'full,images,metadata' - }) - .then(show => formatMetadata(show, 'shows')); - } - - getSeasons(itemId: string) { - return this.trakt.seasons - .summary({ - id : itemId, - extended: 'full,images,metadata' - }) - .then(res => - res.filter(season => season.aired_episodes !== 0).map(season => ({ - season : season.number + 1, - overview: season.overview, - id : season.ids.imdb, - images : { - full : season.images.poster.full, - medium: season.images.poster.medium, - thumb : season.images.poster.thumb - } - })) - ); - } - - getSeason(itemId: string, season: number) { - return this.trakt.seasons - .season({ - id : itemId, - season, - extended: 'full,images,metadata' - }) - .then(episodes => episodes.map(episode => formatSeason(episode))); - } - - getEpisode(itemId: string, season: number, episode: number) { - return this.trakt.episodes - .summary({ - id : itemId, - season, - episode, - extended: 'full,images,metadata' - }) - .then(res => formatSeason(res)); - } - - search(query: string, page: number = 1) { - if (!query) { - throw Error('Query paramater required'); - } - - // http://www.omdbapi.com/?t=Game+of+thrones&y=&plot=short&r=json - return fetch( - `http://www.omdbapi.com/?s=${encodeURIComponent(query)}&y=&page=${page}` - ) - .then(response => response.json()) - .then(response => response.Search.map(movie => formatMovieSearch(movie))); - } - - /** - * @param {string} type | movie or show - * @param {string} itemId | movie or show - */ - getSimilar(type: string = 'movies', itemId: string, limit: number = 5) { - return this.trakt[type] - .related({ - id : itemId, - limit, - extended: 'full,images,metadata' - }) - .then(movies => movies.map(movie => formatMetadata(movie, type))); - } - - // @TODO: Properly implement provider architecture - provide() {} -} - -function formatMetadata(movie = {}, type: string) { - return { - title : movie.title, - year : movie.year, - // @DEPRECATE - id : movie.ids.imdb, - ids : { - imdbId: movie.ids.imdb - }, - type, - certification: movie.certification, - summary : movie.overview, - genres : movie.genres, - rating : movie.rating ? roundRating(movie.rating) : 'n/a', - runtime : parseRuntimeMinutesToObject(movie.runtime), - trailer : movie.trailer, - images : movie.images, - } -} - -function formatMovieSearch(movie) { - return { - title : movie.Title, - year : parseInt(movie.Year, 10), - // @DEPRECATE - id : movie.imdbID, - ids : { - imdbId: movie.imdbID - }, - type : movie.Type.includes('movie') ? 'movies' : 'shows', - certification: movie.Rated, - summary : 'n/a', // omdbapi does not support - genres : [], - rating : 'n/a', // omdbapi does not support - runtime : { - full : 'n/a', // omdbapi does not support - hours : 'n/a', // omdbapi does not support - minutes: 'n/a' // omdbapi does not support - }, - trailer : 'n/a', // omdbapi does not support - images : { - fanart: { - full : movie.Poster || '', - medium: movie.Poster || '', - thumb : movie.Poster || '' - }, - poster: { - full : movie.Poster || '', - medium: movie.Poster || '', - thumb : movie.Poster || '' - } - } - }; -} - -function formatSeason(season, image: string = 'screenshot') { - return { - id : season.ids.imdb, - title : season.title, - season : season.season, - episode : season.number, - overview: season.overview, - rating : season.rating ? roundRating(season.rating) : 'n/a', - images : { - full : season.images[image].full, - medium: season.images[image].medium, - thumb : season.images[image].thumb - } - }; -} - -function roundRating(rating: number): number { - return Math.round(rating * 10) / 10; -} diff --git a/app/api/metadata/TraktMetadataProvider/TraktMetadataProvider.js b/app/api/metadata/TraktMetadataProvider/TraktMetadataProvider.js deleted file mode 100644 index 64017186..00000000 --- a/app/api/metadata/TraktMetadataProvider/TraktMetadataProvider.js +++ /dev/null @@ -1,206 +0,0 @@ -// @flow -import fetch from 'isomorphic-fetch' -import Trakt from 'trakt.tv' -import { parseRuntimeMinutesToObject } from '../MetadataAdapter' -import type { MetadataProviderInterface } from '../MetadataProviderInterface' - -export default class TraktMetadataAdapter implements MetadataProviderInterface { - - clientId = '647c69e4ed1ad13393bf6edd9d8f9fb6fe9faf405b44320a6b71ab960b4540a2' - clientSecret = 'f55b0a53c63af683588b47f6de94226b7572a6f83f40bd44c58a7c83fe1f2cb1' - - trakt: Trakt - - constructor() { - this.trakt = new Trakt({ - client_id : this.clientId, - client_secret: this.clientSecret, - }) - } - - getMovies(page: number = 1, limit: number = 50) { - return this.trakt.movies - .popular({ - paginate: true, - page, - limit, - extended: 'full,images,metadata' - }) - .then(movies => movies.map(movie => formatMetadata(movie, 'movies'))) - } - - getMovie(itemId: string) { - return this.trakt.movies - .summary({ - id : itemId, - extended: 'full,images,metadata' - }) - .then(movie => formatMetadata(movie, 'movies')) - } - - getShows(page: number = 1, limit: number = 50) { - return this.trakt.shows - .popular({ - paginate: true, - page, - limit, - extended: 'full,images,metadata' - }) - .then(shows => shows.map(show => formatMetadata(show, 'shows'))) - } - - getShow(itemId: string) { - return this.trakt.shows - .summary({ - id : itemId, - extended: 'full,images,metadata' - }) - .then(show => formatMetadata(show, 'shows')) - } - - getSeasons(itemId: string) { - return this.trakt.seasons - .summary({ - id : itemId, - extended: 'episodes,full', - }) - - /*.then(res => - res.filter(season => season.aired_episodes !== 0).map(season => ({ - season : season.number + 1, - overview: season.overview, - id : season.ids.imdb, - images : { - full : season.images.poster.full, - medium: season.images.poster.medium, - thumb : season.images.poster.thumb - } - }))*/ - } - - getSeason(itemId: string, season: number) { - return this.trakt.seasons - .season({ - id : itemId, - season, - extended: 'full,images,metadata' - }) - .then(episodes => episodes.map(episode => formatSeason(episode))) - } - - getEpisode(itemId: string, season: number, episode: number) { - return this.trakt.episodes - .summary({ - id : itemId, - season, - episode, - extended: 'full,images,metadata' - }) - .then(res => formatSeason(res)) - } - - search(query: string, page: number = 1) { - if (!query) { - throw Error('Query paramater required') - } - - // http://www.omdbapi.com/?t=Game+of+thrones&y=&plot=short&r=json - return fetch( - `http://www.omdbapi.com/?s=${encodeURIComponent(query)}&y=&page=${page}` - ) - .then(response => response.json()) - .then(response => response.Search.map(movie => formatMovieSearch(movie))) - } - - /** - * @param {string} type | movie or show - * @param {string} itemId | movie or show - */ - getSimilar(type: string = 'movies', itemId: string, limit: number = 5) { - return this.trakt[type] - .related({ - id : itemId, - limit, - extended: 'full,images,metadata' - }) - .then(movies => movies.map(movie => formatMetadata(movie, type))) - } - - // @TODO: Properly implement provider architecture - provide() {} -} - -function formatMetadata(movie = {}, type: string) { - return { - title : movie.title, - year : movie.year, - // @DEPRECATE - id : movie.ids.imdb, - ids : { - imdbId: movie.ids.imdb - }, - type, - certification: movie.certification, - summary : movie.overview, - genres : movie.genres, - rating : movie.rating ? roundRating(movie.rating) : 'n/a', - runtime : parseRuntimeMinutesToObject(movie.runtime), - trailer : movie.trailer, - images : movie.images, - } -} - -function formatMovieSearch(movie) { - return { - title : movie.Title, - year : parseInt(movie.Year, 10), - // @DEPRECATE - id : movie.imdbID, - ids : { - imdbId: movie.imdbID - }, - type : movie.Type.includes('movie') ? 'movies' : 'shows', - certification: movie.Rated, - summary : 'n/a', // omdbapi does not support - genres : [], - rating : 'n/a', // omdbapi does not support - runtime : { - full : 'n/a', // omdbapi does not support - hours : 'n/a', // omdbapi does not support - minutes: 'n/a' // omdbapi does not support - }, - trailer : 'n/a', // omdbapi does not support - images : { - fanart: { - full : movie.Poster || '', - medium: movie.Poster || '', - thumb : movie.Poster || '' - }, - poster: { - full : movie.Poster || '', - medium: movie.Poster || '', - thumb : movie.Poster || '' - } - } - } -} - -function formatSeason(season, image: string = 'screenshot') { - return { - id : season.ids.imdb, - title : season.title, - season : season.season, - episode : season.number, - overview: season.overview, - rating : season.rating ? roundRating(season.rating) : 'n/a', - images : { - full : season.images[image].full, - medium: season.images[image].medium, - thumb : season.images[image].thumb - } - } -} - -function roundRating(rating: number): number { - return Math.round(rating * 10) / 10 -} diff --git a/app/api/torrents/TorrentProviderInterface.js b/app/api/torrents/TorrentProviderInterface.js deleted file mode 100644 index 20928f1d..00000000 --- a/app/api/torrents/TorrentProviderInterface.js +++ /dev/null @@ -1,35 +0,0 @@ -// @flow -export type fetchType = { - quality: string, - magnet: string, - seeders: number, - leechers: number, - metadata: string, - _provider: string -} - -export type torrentType = fetchType & { - health: healthType, - quality: qualityType, - method: torrentQueryType, -} - -export type healthType = 'poor' | 'decent' | 'healthy' - -export type torrentMethodType = 'all' | 'race' - -export type qualityType = '1080p' | '720p' | '480p' | 'default' - -export type torrentQueryType = 'movies' | 'show' | 'season_complete' - -export interface TorrentProviderInterface { - - supportedIdTypes: Array<'tmdb' | 'imdb'>, - - static getStatus: () => Promise, - - static fetch: (itemId: string) => Promise>, - - static provide: (itemId: string, type: torrentType) => Promise> - -} diff --git a/app/api/torrents/BaseTorrentProvider.js b/app/api/torrents_ol/BaseTorrentProvider.js similarity index 100% rename from app/api/torrents/BaseTorrentProvider.js rename to app/api/torrents_ol/BaseTorrentProvider.js diff --git a/app/api/torrents_ol/BaseTorrentProvider.js_olda b/app/api/torrents_ol/BaseTorrentProvider.js_olda new file mode 100644 index 00000000..fa235969 --- /dev/null +++ b/app/api/torrents_ol/BaseTorrentProvider.js_olda @@ -0,0 +1,244 @@ +// @flow +/* eslint prefer-template: 0 */ +import cache from 'lru-cache'; +import url from 'url'; + +export const providerCache = cache({ + maxAge: process.env.CONFIG_CACHE_TIMEOUT + ? parseInt(process.env.CONFIG_CACHE_TIMEOUT, 10) * 1000 * 60 * 60 + : 1000 * 60 * 60 // 1 hr +}); + +/** + * Handle a promise and set a timeout + */ +export function timeout(promise: Promise, time: number = 10000): Promise { + return new Promise((resolve, reject) => { + promise.then(res => resolve(res)).catch(err => console.log(err)); + + setTimeout(() => { + reject(new Error('Timeout exceeded')); + }, process.env.CONFIG_API_TIMEOUT ? parseInt(process.env.CONFIG_API_TIMEOUT, 10) : time); + }); +} + +export function determineQuality(magnet: string, metadata: string = ''): string { + const lowerCaseMetadata = (metadata || magnet).toLowerCase(); + + if (process.env.FLAG_UNVERIFIED_TORRENTS === 'true') { + return '480p'; + } + + // Filter non-english languages + if (hasNonEnglishLanguage(lowerCaseMetadata)) { + return ''; + } + + // Filter videos with 'rendered' subtitles + if (hasSubtitles(lowerCaseMetadata)) { + return process.env.FLAG_SUBTITLE_EMBEDDED_MOVIES === 'true' ? '480p' : ''; + } + + // Most accurate categorization + if (lowerCaseMetadata.includes('1080')) return '1080p'; + if (lowerCaseMetadata.includes('720')) return '720p'; + if (lowerCaseMetadata.includes('480')) return '480p'; + + // Guess the quality 1080p + if (lowerCaseMetadata.includes('bluray')) return '1080p'; + if (lowerCaseMetadata.includes('blu-ray')) return '1080p'; + + // Guess the quality 720p, prefer english + if (lowerCaseMetadata.includes('dvd')) return '720p'; + if (lowerCaseMetadata.includes('rip')) return '720p'; + if (lowerCaseMetadata.includes('mp4')) return '720p'; + if (lowerCaseMetadata.includes('web')) return '720p'; + if (lowerCaseMetadata.includes('hdtv')) return '720p'; + if (lowerCaseMetadata.includes('eng')) return '720p'; + + if (hasNonNativeCodec(lowerCaseMetadata)) { + return process.env.FLAG_SUPPORTED_PLAYBACK_FILTERING === 'true' + ? '720p' + : ''; + } + + if (process.env.NODE_ENV === 'development') { + console.warn(`${magnet}, could not be verified`); + } + + return ''; +} + +export async function convertTmdbToImdb(tmdbId: string): Promise { + /*const theMovieDbProvider = new TheMovieDbMetadataProvider(); + const movie = await theMovieDbProvider.getMovie(tmdbId); + + if (!movie.ids.imdbId) { + throw new Error('Cannot convert tmdbId to imdbId'); + } + + return movie.ids.imdbId;*/ +} + +// export async function convertImdbtoTmdb(imdbId: string): Promise { +// const theMovieDbProvider = new TheMovieDbMetadataProvider(); +// const movie = await theMovieDbProvider.getMovie(imdbId); +// if (!movie.ids.imdbId) { +// throw new Error('Cannot convert imdbId to tmdbId'); +// } +// return movie.ids.imdbId; +// } + +export function formatSeasonEpisodeToString(season: number, episode: number): string { + return ( + 's' + + (String(season).length === 1 ? '0' + String(season) : String(season)) + + ('e' + + (String(episode).length === 1 ? '0' + String(episode) : String(episode))) + ); +} + +export function formatSeasonEpisodeToObject(season: number, episode: ?number): Object { + return { + season : String(season).length === 1 ? '0' + String(season) : String(season), + episode: String(episode).length === 1 + ? '0' + String(episode) + : String(episode) + }; +} + +export function isExactEpisode(title: string, season: number, episode: number): boolean { + return title + .toLowerCase() + .includes(formatSeasonEpisodeToString(season, episode)); +} + +export function getHealth(seeders: number, leechers: number = 0): string { + const ratio = seeders && !!leechers ? seeders / leechers : seeders; + + if (seeders < 50) { + return 'poor'; + } + + if (ratio > 1 && seeders >= 50 && seeders < 100) { + return 'decent'; + } + + if (ratio > 1 && seeders >= 100) { + return 'healthy'; + } + + return 'poor'; +} + +export function hasNonEnglishLanguage(metadata: string): boolean { + if (metadata.includes('french')) return true; + if (metadata.includes('german')) return true; + if (metadata.includes('greek')) return true; + if (metadata.includes('dutch')) return true; + if (metadata.includes('hindi')) return true; + if (metadata.includes('português')) return true; + if (metadata.includes('portugues')) return true; + if (metadata.includes('spanish')) return true; + if (metadata.includes('español')) return true; + if (metadata.includes('espanol')) return true; + if (metadata.includes('latino')) return true; + if (metadata.includes('russian')) return true; + if (metadata.includes('subtitulado')) return true; + + return false; +} + +export function hasSubtitles(metadata: string): boolean { + return metadata.includes('sub'); +} + +export function hasNonNativeCodec(metadata: string): boolean { + return metadata.includes('avi') || metadata.includes('mkv'); +} + +export function sortTorrentsBySeeders(torrents: Array): Array { + return torrents.sort( + (prev: Object, next: Object) => + prev.seeders === next.seeders ? 0 : prev.seeders > next.seeders ? -1 : 1 + ); +} + +export function constructMovieQueries(title: string, + itemId: string): Array { + const queries = [ + title, // default + itemId + ]; + + return title.includes("'") ? [...queries, title.replace(/'/g, '')] : queries; +} + +export function constructSeasonQueries(title: string, + season: number): Array { + const formattedSeasonNumber = `s${formatSeasonEpisodeToObject(season, 1) + .season}`; + + return [ + `${title} season ${season}`, + `${title} season ${season} complete`, + `${title} season ${formattedSeasonNumber} complete` + ]; +} + +/** + * @param {array} results | A two-dimentional array containing arrays of results + */ +export function merge(results: Array) { + return results.reduce((previous, current) => [...previous, ...current]); +} + +export function resolveEndpoint(defaultEndpoint: string, providerId: string) { + const endpointEnvVariable = `CONFIG_ENDPOINT_${providerId}`; + + switch (process.env[endpointEnvVariable]) { + case undefined: + return defaultEndpoint; + + default: + return url.format({ + ...url.parse(defaultEndpoint), + hostname: process.env[endpointEnvVariable], + host : process.env[endpointEnvVariable] + }); + } +} + +export function getIdealTorrent(torrents) { + const idealTorrent = torrents + .filter(torrent => !!torrent) + .filter(torrent => !!torrent && !!torrent.magnet && typeof torrent.seeders === 'number'); + + return idealTorrent.sort((prev, next) => { + if (prev.seeders === next.seeders) { + return 0; + } + + if (!next.seeders || !prev.seeders) return 1; + + return prev.seeders > next.seeders ? -1 : 1; + })[0]; +} + +export function handleProviderError(error: Error) { + if (process.env.NODE_ENV === 'development') { + console.log(error); + } +} + +export function resolveCache(key: string): boolean | any { + return providerCache.has(key) ? providerCache.get(key) : false; +} + +export function setCache(key: string, value: any) { + if (process.env.NODE_ENV === 'development') { + console.info('Setting cache key:', key); + } + + return providerCache.set(key, value); +} diff --git a/app/api/torrents/KatTorrentProvider.js b/app/api/torrents_ol/KatTorrentProvider.js similarity index 100% rename from app/api/torrents/KatTorrentProvider.js rename to app/api/torrents_ol/KatTorrentProvider.js diff --git a/app/api/torrents/PbTorrentProvider.js b/app/api/torrents_ol/PbTorrentProvider.js similarity index 100% rename from app/api/torrents/PbTorrentProvider.js rename to app/api/torrents_ol/PbTorrentProvider.js diff --git a/app/api/torrents/PctTorrentProvider.js b/app/api/torrents_ol/PctTorrentProvider.js similarity index 100% rename from app/api/torrents/PctTorrentProvider.js rename to app/api/torrents_ol/PctTorrentProvider.js diff --git a/app/api/torrents_ol/PctTorrentProvider/PctTorrentHelpers.js b/app/api/torrents_ol/PctTorrentProvider/PctTorrentHelpers.js new file mode 100644 index 00000000..8bb9844c --- /dev/null +++ b/app/api/torrents_ol/PctTorrentProvider/PctTorrentHelpers.js @@ -0,0 +1,130 @@ +import type { ImageType, TorrentType, RatingType } from './PctTorrentTypes' + +export const formatImages = (images: ImageType) => { + const replaceWidthPart = (uri: string, replaceWith: string) => uri.replace('w500', replaceWith) + + return { + poster: { + full : replaceWidthPart(images.poster, 'original'), + high : replaceWidthPart(images.poster, 'w1280'), + medium: replaceWidthPart(images.poster, 'w780'), + thumb : replaceWidthPart(images.poster, 'w342'), + }, + fanart: { + full : replaceWidthPart(images.fanart, 'original'), + high : replaceWidthPart(images.fanart, 'w1280'), + medium: replaceWidthPart(images.fanart, 'w780'), + thumb : replaceWidthPart(images.fanart, 'w342'), + }, + } +} + +export const formatTorrents = (torrents, type = 'movie') => { + const getHealth = (seeds, peers) => { + const ratio = seeds && !!peers ? seeds / peers : seeds + + // Normalize the data. Convert each to a percentage + // Ratio: Anything above a ratio of 5 is good + const normalizedRatio = Math.min(ratio / 5 * 100, 100) + // Seeds: Anything above 30 seeds is good + const normalizedSeeds = Math.min(seeds / 30 * 100, 100) + + // Weight the above metrics differently + // Ratio is weighted 60% whilst seeders is 40% + const weightedRatio = normalizedRatio * 0.6 + const weightedSeeds = normalizedSeeds * 0.4 + const weightedTotal = weightedRatio + weightedSeeds + + // Scale from [0, 100] to [0, 3]. Drops the decimal places + const scaledTotal = ((weightedTotal * 3) / 100) | 0 + + if (scaledTotal === 1) { + return { + text : 'decent', + color : '#FF9800', + number: 1, + } + + } else if (scaledTotal >= 2) { + return { + text : 'healthy', + color : '#4CAF50', + number: 2, + } + } + + return { + text : 'poor', + color : '#F44336', + number: 0, + } + } + + const formatTorrent = (torrent: TorrentType, quality: string) => ({ + ...torrent, + quality, + health: { + ...getHealth(torrent.seed || torrent.seeds, torrent.peer || torrent.peers), + }, + seeds : torrent.seed || torrent.seeds, + peers : torrent.peer || torrent.peers, + }) + + if (type === 'movie') { + return { + '1080p': !torrents['1080p'] ? null : formatTorrent(torrents['1080p'], '1080p'), + '720p' : !torrents['720p'] ? null : formatTorrent(torrents['720p'], '720p'), + } + } + + return { + '1080p': !torrents['1080p'] ? null : formatTorrent(torrents['1080p'], '1080p'), + '720p' : !torrents['720p'] ? null : formatTorrent(torrents['720p'], '720p'), + '480p' : !torrents['480p'] ? null : formatTorrent(torrents['480p'], '480p'), + } +} + +export const formatRating = (rating: RatingType) => ({ + stars: (rating.percentage / 100) * 5, + ...rating, +}) + +export const formatShowEpisodes = (episodes) => { + let seasons = [] + + episodes.map((episode) => { + if (!seasons[episode.season]) { + seasons[episode.season] = [] + } + + seasons[episode.season][episode.episode] = { + summary : episode.overview, + season : episode.season, + number : episode.season, + episode : episode.episode, + torrents: formatTorrents(episode.torrents, 'show'), + } + }) + + return seasons +} + +export const formatRuntimeMinutesToObject = (runtimeInMinutes: number) => { + const hours = runtimeInMinutes >= 60 ? Math.round(runtimeInMinutes / 60) : 0 + const minutes = runtimeInMinutes % 60 + + return { + full : hours > 0 + ? `${hours} ${hours > 1 ? 'hours' : 'hour'}${minutes > 0 + ? ` ${minutes} minutes` + : ''}` + : `${minutes} minutes`, + short: hours > 0 + ? `${hours} ${hours > 1 ? 'hrs' : 'hr'}${minutes > 0 + ? ` ${minutes} min` + : ''}` + : `${minutes} min`, + hours, + minutes, + } +} diff --git a/app/api/metadata/PctMetadataProvider/PctMetadataProvider.js b/app/api/torrents_ol/PctTorrentProvider/PctTorrentProvider.js similarity index 66% rename from app/api/metadata/PctMetadataProvider/PctMetadataProvider.js rename to app/api/torrents_ol/PctTorrentProvider/PctTorrentProvider.js index 12e30682..782496dc 100644 --- a/app/api/metadata/PctMetadataProvider/PctMetadataProvider.js +++ b/app/api/torrents_ol/PctTorrentProvider/PctTorrentProvider.js @@ -1,20 +1,19 @@ // @flow import axios from 'axios' -import BaseMetadataProvider from '../BaseMetadataProvider' -import * as Helpers from './PctMetadataHelpers' +import * as Helpers from './PctTorrentHelpers' -import type { MetadataProviderInterface } from '../MetadataProviderInterface' -import type { MovieType, ShowType, ShowDetailType } from './PctMetadataTypes' +import type { TorrentProviderInterface } from '../TorrentProviderInterface' +import type { MovieType, ShowType, ShowDetailType } from './PctTorrentTypes' -export default class PctMetadataProvider extends BaseMetadataProvider implements MetadataProviderInterface { +export default class PctTorrentProvider implements TorrentProviderInterface { popcornAPI: axios = axios.create({ baseURL: 'https://movies-v2.api-fetch.website/', }) - getMovies = (page: number = 1, limit: number = 50) => ( - this.popcornAPI.get(`movies/${page}`, { params: { page, limit } }) + getMovies = (page: number = 1, filters = { limit: 50, sort: 'trending' }) => ( + this.popcornAPI.get(`movies/${page}`, { params: { ...filters } }) .then(response => this.formatMovies(response.data)) ) @@ -23,8 +22,8 @@ export default class PctMetadataProvider extends BaseMetadataProvider implements .then(response => this.formatMovie(response.data)) ) - getShows = (page: number = 1, limit: number = 50) => ( - this.popcornAPI.get(`shows/${page}`, { params: { page, limit } }) + getShows = (page: number = 1, filters = { limit: 50, sort: 'trending' }) => ( + this.popcornAPI.get(`shows/${page}`, { params: { ...filters } }) .then(response => this.formatShows(response.data)) ) @@ -41,7 +40,7 @@ export default class PctMetadataProvider extends BaseMetadataProvider implements year : movie.year, certification: movie.certification, summary : movie.synopsis, - runtime : this.formatRuntimeMinutesToObject(movie.runtime), + runtime : Helpers.formatRuntimeMinutesToObject(movie.runtime), trailer : movie.trailer, images : Helpers.formatImages(movie.images), genres : movie.genres, @@ -66,11 +65,11 @@ export default class PctMetadataProvider extends BaseMetadataProvider implements if (isDetail) { formattedShow = { ...formattedShow, - runtime : this.formatRuntimeMinutesToObject(show.runtime), - episodes: Helpers.formatShowEpisodes(show.episodes), - summary : show.synopsis, - genres : show.genres, - status : show.status, + runtime: Helpers.formatRuntimeMinutesToObject(show.runtime), + seasons: Helpers.formatShowEpisodes(show.episodes), + summary: show.synopsis, + genres : show.genres, + status : show.status, } } diff --git a/app/api/torrents_ol/PctTorrentProvider/PctTorrentTypes.js b/app/api/torrents_ol/PctTorrentProvider/PctTorrentTypes.js new file mode 100644 index 00000000..ff30256f --- /dev/null +++ b/app/api/torrents_ol/PctTorrentProvider/PctTorrentTypes.js @@ -0,0 +1,70 @@ +// @flow +export type ImageType = { + poster: string, + fanart: string, + banner: string, +} + +export type RatingType = { + percentage: number, + watching: number, + votes: number, + loved: number, + hated: number, +} + +export type MovieType = { + _id: string, + imdb_id: string, + title: string, + year: string, + synopsis: string, + runtime: string, + released: number, + trailer: string, + certification: string, + torrents: { + en: { + '1080p': TorrentType, + '720p': TorrentType, + } + }, + genres: Array + images: ImageType, + rating: RatingType +} + +export type ShowType = { + _id: string, + imdb_id: string, + tvdb_id: string, + title: string, + year: string, + slug: string, + num_seasons: number, + images: ImageType, + rating: RatingType +} + +export type ShowDetailType = ShowType & { + synopsis: string, + runtime: string, + country: string, + network: string, + air_day: string, + air_time: string, + status: string, + last_updated: number, + __v: number, + episodes: [], + genres: Array +} + +export type TorrentType = { + url: string, + seed: number, + peer: number, + size: number, + filesize: string, + provider: string, +} diff --git a/app/api/torrents_ol/PctTorrentProvider/index.js b/app/api/torrents_ol/PctTorrentProvider/index.js new file mode 100644 index 00000000..e1c66f8d --- /dev/null +++ b/app/api/torrents_ol/PctTorrentProvider/index.js @@ -0,0 +1,3 @@ +import PctTorrentProvider from './PctTorrentProvider' + +export default PctTorrentProvider diff --git a/app/api/torrents/TorrentAdapter.js b/app/api/torrents_ol/TorrentAdapter.js similarity index 100% rename from app/api/torrents/TorrentAdapter.js rename to app/api/torrents_ol/TorrentAdapter.js diff --git a/app/api/torrents_ol/TorrentProviderInterface.js b/app/api/torrents_ol/TorrentProviderInterface.js new file mode 100644 index 00000000..ca0aeab6 --- /dev/null +++ b/app/api/torrents_ol/TorrentProviderInterface.js @@ -0,0 +1,8 @@ +// @flow +export interface TorrentProviderInterface { + + getStatus: () => Promise, + + search: () => Promise + +} diff --git a/app/api/torrents/YtsTorrentProvider.js b/app/api/torrents_ol/YtsTorrentProvider.js similarity index 100% rename from app/api/torrents/YtsTorrentProvider.js rename to app/api/torrents_ol/YtsTorrentProvider.js diff --git a/app/api/torrents/example.js b/app/api/torrents_ol/example.js similarity index 100% rename from app/api/torrents/example.js rename to app/api/torrents_ol/example.js diff --git a/app/components/Item/Background/Background.js b/app/components/Item/Background/Background.js index b46d80c4..7511db9e 100644 --- a/app/components/Item/Background/Background.js +++ b/app/components/Item/Background/Background.js @@ -5,53 +5,18 @@ import React from 'react' import classNames from 'classnames' import type { Props } from './BackgroundTypes' -import QualtiySwitch from './QualitySwitch' import classes from './Background.scss' -export const Background = ({ - backgroundImage, poster, children, activeMode, torrent, torrents, - setTorrent, play, showPlayInfo, - }: Props) => - ( -
-
-
showPlayInfo ? play() : null} - className={classes.background__cover}> - {'presentation'} - -
- - {showPlayInfo && activeMode === 'movie' && ( - - )} - -
- - {showPlayInfo && activeMode === 'movie' && ( - - )} -
- - {children} - +export const Background = ({ backgroundImage }: Props) => ( +
-
- ) +
+) export default Background diff --git a/app/components/Item/Background/Background.scss b/app/components/Item/Background/Background.scss index da9ecbf9..a0f7dcc4 100644 --- a/app/components/Item/Background/Background.scss +++ b/app/components/Item/Background/Background.scss @@ -3,80 +3,10 @@ .background { &__container { min-height: $item-background-height; - display: flex; - justify-content: center; - align-items: center; - position: absolute; + position: fixed; top: 0; left: 0; right: 0; - z-index: 1050; - } - - &__cover { - position: relative; - width: 233px; - height: 350px; - cursor: pointer; - - &-overlay { - position: absolute; - background: black; - width: 100%; - height: 100%; - opacity: 0.2; - transition: opacity 0.1s linear; - - &--with-hover { - &:hover { - opacity: 0.3; - } - } - } - - img { - position: absolute; - width: 100%; - height: 100%; - } - - i { - position: absolute; - top: 0; - display: flex; - width: 100%; - height: 100%; - color: white; - font-size: 35px; - justify-content: center; - align-items: center; - pointer-events: none; - - &:before { - display: flex; - align-items: center; - justify-content: center; - border: 2px solid white; - width: 50px; - height: 50px; - border-radius: 50%; - padding-left: 5px; - } - } - } - - &__image { - display: flex; - justify-content: center; - flex-flow: column; - align-items: center; - z-index: 10; - border: 0 solid transparent; - - img { - box-shadow: rgba(0, 0, 0, 0.6) 0 6px 16px 4px; - background: gray; - } } &__overlay { diff --git a/app/components/Item/Background/BackgroundTypes.js b/app/components/Item/Background/BackgroundTypes.js index f42579d3..864a0ad2 100644 --- a/app/components/Item/Background/BackgroundTypes.js +++ b/app/components/Item/Background/BackgroundTypes.js @@ -3,9 +3,4 @@ */ export type Props = { backgroundImage: string, - poster: string, - children: Element, - activeMode: string, - setTorrent: () => void, - showPlayInfo: boolean, } diff --git a/app/components/Item/Cover/Cover.js b/app/components/Item/Cover/Cover.js new file mode 100644 index 00000000..fc76b589 --- /dev/null +++ b/app/components/Item/Cover/Cover.js @@ -0,0 +1,41 @@ +/** + * Created by tycho on 10/07/2017. + */ +import React from 'react' +import classNames from 'classnames' + +import type { Props } from './CoverTypes' +import QualitySwitch from './QualitySwitch' +import classes from './Cover.scss' + +export const Cover = ({ poster, activeMode, torrent, torrents, setTorrent, play, showPlayInfo }: Props) => ( +
+
showPlayInfo && activeMode === 'movie' ? play() : null} + className={classes.cover__image}> + {'presentation'} + +
+ + {showPlayInfo && activeMode === 'movie' && ( + + )} + +
+ + {showPlayInfo && activeMode === 'movie' && ( + + )} +
+) + +export default Cover diff --git a/app/components/Item/Cover/Cover.scss b/app/components/Item/Cover/Cover.scss new file mode 100644 index 00000000..06a54c58 --- /dev/null +++ b/app/components/Item/Cover/Cover.scss @@ -0,0 +1,68 @@ +@import 'base/variables'; + +.cover { + display: flex; + justify-content: center; + flex-flow: column; + align-items: center; + z-index: 10; + border: 0 solid transparent; + + img { + box-shadow: rgba(0, 0, 0, 0.6) 0 6px 16px 4px; + background: gray; + } + + &__image { + position: relative; + width: 233px; + height: 350px; + cursor: pointer; + + &-overlay { + position: absolute; + background: black; + width: 100%; + height: 100%; + opacity: 0.2; + transition: opacity 0.1s linear; + + &--with-hover { + &:hover { + opacity: 0.3; + } + } + } + + img { + position: absolute; + width: 100%; + height: 100%; + } + + i { + position: absolute; + top: 0; + display: flex; + width: 100%; + height: 100%; + color: white; + font-size: 35px; + justify-content: center; + align-items: center; + pointer-events: none; + + &:before { + display: flex; + align-items: center; + justify-content: center; + border: 2px solid white; + width: 50px; + height: 50px; + border-radius: 50%; + padding-left: 5px; + } + } + } + +} diff --git a/app/components/Item/Cover/CoverTypes.js b/app/components/Item/Cover/CoverTypes.js new file mode 100644 index 00000000..f42579d3 --- /dev/null +++ b/app/components/Item/Cover/CoverTypes.js @@ -0,0 +1,11 @@ +/** + * @flow + */ +export type Props = { + backgroundImage: string, + poster: string, + children: Element, + activeMode: string, + setTorrent: () => void, + showPlayInfo: boolean, +} diff --git a/app/components/Item/Background/QualitySwitch/QualtiySwitch.js b/app/components/Item/Cover/QualitySwitch/QualtiySwitch.js similarity index 100% rename from app/components/Item/Background/QualitySwitch/QualtiySwitch.js rename to app/components/Item/Cover/QualitySwitch/QualtiySwitch.js diff --git a/app/components/Item/Background/QualitySwitch/QualtiySwitch.scss b/app/components/Item/Cover/QualitySwitch/QualtiySwitch.scss similarity index 100% rename from app/components/Item/Background/QualitySwitch/QualtiySwitch.scss rename to app/components/Item/Cover/QualitySwitch/QualtiySwitch.scss diff --git a/app/components/Item/Background/QualitySwitch/QualtiySwitchTypes.js b/app/components/Item/Cover/QualitySwitch/QualtiySwitchTypes.js similarity index 100% rename from app/components/Item/Background/QualitySwitch/QualtiySwitchTypes.js rename to app/components/Item/Cover/QualitySwitch/QualtiySwitchTypes.js diff --git a/app/components/Item/Background/QualitySwitch/index.js b/app/components/Item/Cover/QualitySwitch/index.js similarity index 100% rename from app/components/Item/Background/QualitySwitch/index.js rename to app/components/Item/Cover/QualitySwitch/index.js diff --git a/app/components/Item/Cover/index.js b/app/components/Item/Cover/index.js new file mode 100644 index 00000000..2eb7b976 --- /dev/null +++ b/app/components/Item/Cover/index.js @@ -0,0 +1,3 @@ +import Cover from './Cover' + +export default Cover diff --git a/app/components/Item/Info/Info.js b/app/components/Item/Info/Info.js index 03ce477b..875066b6 100644 --- a/app/components/Item/Info/Info.js +++ b/app/components/Item/Info/Info.js @@ -6,19 +6,15 @@ import { Tooltip } from 'reactstrap' import classNames from 'classnames' import Rating from 'components/Rating' -import type { Props } from './InfoTypes' +import type { Props, State } from './InfoTypes' import classes from './Info.scss' export class Info extends React.Component { props: Props - constructor(props) { - super(props) - - this.state = { - trailerTooltipOpen: false, - } + state: State = { + trailerTooltipOpen: false, } toggleTrailerTooltip = () => { @@ -97,6 +93,15 @@ export class Info extends React.Component {
)} +
+ +
+
) diff --git a/app/components/Item/Info/Info.scss b/app/components/Item/Info/Info.scss index 343a47aa..2d076af5 100644 --- a/app/components/Item/Info/Info.scss +++ b/app/components/Item/Info/Info.scss @@ -46,4 +46,8 @@ max-width: 600px; } + &__player { + height: 75px; + } + } diff --git a/app/components/Item/Info/InfoTypes.js b/app/components/Item/Info/InfoTypes.js index efaad0e9..1692e313 100644 --- a/app/components/Item/Info/InfoTypes.js +++ b/app/components/Item/Info/InfoTypes.js @@ -1,9 +1,13 @@ /** * @flow */ -import type { ContentType } from 'api/metadata/MetadataTypes' +import type { ContentType } from 'api/Metadata/MetadataTypes' export type Props = { item: ContentType, play: () => void, } + +export type State = { + trailerTooltipOpen: boolean, +} diff --git a/app/components/Item/Item.scss b/app/components/Item/Item.scss index 6f502cfd..85ed86c8 100644 --- a/app/components/Item/Item.scss +++ b/app/components/Item/Item.scss @@ -1,4 +1,41 @@ .item { position: relative; height: 100%; + + &__content { + display: flex; + width: 100%; + height: 100%; + + &--movie { + @extend .item__content; + + justify-content: center; + align-items: center; + } + + &--show { + @extend .item__content; + + flex-flow: column; + justify-content: space-between; + } + } + + &__row { + &--movie { + width: 100%; + } + + &--show { + width: 100%; + margin-top: 100px; + + &:last-of-type { + display: flex; + margin-bottom: 100px; + } + } + } } + diff --git a/app/components/Item/ItemActions.js b/app/components/Item/ItemActions.js index 10d2d3b5..8c570dbf 100644 --- a/app/components/Item/ItemActions.js +++ b/app/components/Item/ItemActions.js @@ -1,5 +1,6 @@ // @flow import Butter from 'api/Butter' +import { getBestTorrent } from 'api/Torrents/TorrentHelpers' import * as Constants from './ItemConstants' @@ -16,6 +17,19 @@ export function fetchedItem(item) { } } +export function fetchEpisodeTorrents() { + return { + type: Constants.FETCH_EPISODE_TORRENTS, + } +} + +export function fetchedEpisodeTorrents(item) { + return { + type : Constants.FETCHED_EPISODE_TORRENTS, + payload: item, + } +} + export function getItem(itemId, activeMode) { return (dispatch) => { dispatch(fetchItem()) @@ -32,3 +46,54 @@ export function getItem(itemId, activeMode) { } } } + +export function searchEpisodeTorrents(item, inSeason, forEpisode) { + return (dispatch) => { + dispatch(fetchEpisodeTorrents()) + + Butter.searchEpisode(item, inSeason, forEpisode).then((response) => { + const bestTorrents = {} + + response.forEach((torrents) => Object.keys(torrents).forEach((quality) => { + const torrent = torrents[quality] + if (!bestTorrents[torrent.quality] || getBestTorrent(bestTorrents[torrent.quality], torrent)) { + bestTorrents[torrent.quality] = torrent + } + })) + + /** + * Map the torrents to the right episode + */ + const nItem = { + ...item, + seasons: item.seasons.map((season) => { + if (season.number === inSeason) { + return { + ...season, + episodes: season.episodes.map((episode) => { + if (episode.number === forEpisode) { + return { + ...episode, + torrents: { + ...episode.torrents, + ...bestTorrents, + }, + searched: true, + } + + } else { + return episode + } + }), + } + + } else { + return season + } + }), + } + + dispatch(fetchedEpisodeTorrents(nItem)) + }) + } +} diff --git a/app/components/Item/ItemComponent.js b/app/components/Item/ItemComponent.js index 1adcc629..408464dc 100644 --- a/app/components/Item/ItemComponent.js +++ b/app/components/Item/ItemComponent.js @@ -11,6 +11,8 @@ import Player from 'components/Player' import type { Props, State } from './ItemTypes' import Background from './Background' import Info from './Info' +import Cover from './Cover' +import Show from './Show' import classes from './Item.scss' export default class Item extends React.Component { @@ -24,19 +26,7 @@ export default class Item extends React.Component { torrentStatus: TorrentStatuses.NONE, } - constructor(props: Props) { - super(props) - - // this.torrent = new Torrent() - -// this.playerProvider = new ChromecastPlayerProvider() - - // this.subtitleServer = startSubtitleServer() - } - componentWillMount() { - const { getItem, match: { params: { itemId, activeMode } } } = this.props - this.getAllData() // this.initCastingDevices() this.stopPlayback() @@ -62,7 +52,7 @@ export default class Item extends React.Component { window.scrollTo(0, 0) } else if (!isLoading && wasLoading && newItem.type === 'movie') { - this.getBestTorrent(nextProps) + this.getBestMovieTorrent(nextProps) } } @@ -78,7 +68,7 @@ export default class Item extends React.Component { }) } - play = (playerType) => { + play = (playerType, torrent = this.state.torrent) => { const { item, player } = this.props switch (playerType) { @@ -95,8 +85,6 @@ export default class Item extends React.Component { break default: - const { torrent } = this.state - player.play(torrent.url, { title : item.title, image : { @@ -113,7 +101,7 @@ export default class Item extends React.Component { getItem(itemId, activeMode) } - getBestTorrent = (props = this.props) => { + getBestMovieTorrent = (props = this.props) => { const { torrent } = this.state if (torrent) { @@ -124,7 +112,7 @@ export default class Item extends React.Component { let bestQuality = null Object.keys(torrents).map((quality) => { - if (bestQuality === null || parseInt(bestQuality) < parseInt(quality)) { + if (bestQuality === null || parseInt(bestQuality, 10) < parseInt(quality, 10)) { bestQuality = quality } }) @@ -180,29 +168,39 @@ export default class Item extends React.Component { - - {this.showPlayInfo() && ( - + }} /> + +
+
+ + + {this.showPlayInfo() && ( + + )} + + +
+ + {item.type === 'show' && ( + )} - - - - +
) } diff --git a/app/components/Item/ItemConstants.js b/app/components/Item/ItemConstants.js index d7a12b73..60965c83 100644 --- a/app/components/Item/ItemConstants.js +++ b/app/components/Item/ItemConstants.js @@ -1,10 +1,14 @@ export const REDUCER_NAME = 'item' export const INITIAL_STATE = { - isLoading: false, - item : null, + fetchingEpisodeTorrents: false, + isLoading : false, + item : null, + playerStatus : null, } -export const UPDATE_PLAYER_STATUS = `${REDUCER_NAME}.player.status.update` -export const FETCH_ITEM = `${REDUCER_NAME}.fetch.item` -export const FETCHED_ITEM = `${REDUCER_NAME}.fetched.item` +export const FETCH_ITEM = `${REDUCER_NAME}.fetch.item` +export const FETCHED_ITEM = `${REDUCER_NAME}.fetched.item` + +export const FETCH_EPISODE_TORRENTS = `${REDUCER_NAME}.fetch.episode.torrents` +export const FETCHED_EPISODE_TORRENTS = `${REDUCER_NAME}.fetched.episode.torrents` diff --git a/app/components/Item/ItemReducer.js b/app/components/Item/ItemReducer.js index d323f48a..d907e4ce 100644 --- a/app/components/Item/ItemReducer.js +++ b/app/components/Item/ItemReducer.js @@ -17,6 +17,19 @@ export default (state = Constants.INITIAL_STATE, action) => { item : action.payload, } + case Constants.FETCH_EPISODE_TORRENTS: + return { + ...state, + fetchingEpisodeTorrents: true, + } + + case Constants.FETCHED_EPISODE_TORRENTS: + return { + ...state, + fetchingEpisodeTorrents: false, + item : action.payload, + } + default: return state diff --git a/app/components/Item/ItemSelectors.js b/app/components/Item/ItemSelectors.js index f6d9d70d..291a58d7 100644 --- a/app/components/Item/ItemSelectors.js +++ b/app/components/Item/ItemSelectors.js @@ -1,4 +1,5 @@ import { REDUCER_NAME } from './ItemConstants' -export const getItem = state => state[REDUCER_NAME].item -export const getIsLoading = state => state[REDUCER_NAME].isLoading +export const getItem = state => state[REDUCER_NAME].item +export const getIsLoading = state => state[REDUCER_NAME].isLoading +export const getFetchingEpisodeTorrents = state => state[REDUCER_NAME].fetchingEpisodeTorrents diff --git a/app/components/Item/ItemTypes.js b/app/components/Item/ItemTypes.js index 83a05486..fd025461 100644 --- a/app/components/Item/ItemTypes.js +++ b/app/components/Item/ItemTypes.js @@ -1,9 +1,11 @@ -import type { ContentType, TorrentType } from 'api/metadata/MetadataTypes' +import type { ContentType } from 'api/Metadata/MetadataTypes' +import type { TorrentType } from 'api/Torrents/TorrentsTypes' import { PlayerProviderInterface } from 'api/players/PlayerProviderInterface' export type Props = { item: ContentType, isLoading: boolean, + fetchingEpisodeTorrents: boolean, player: PlayerProviderInterface, playerStatus: string, @@ -11,4 +13,5 @@ export type Props = { export type State = { torrent: TorrentType, + torrentStatus: string, } diff --git a/app/components/Item/Show/Seasons/EpisodeInfo/EpisodesInfo.js b/app/components/Item/Show/Seasons/EpisodeInfo/EpisodesInfo.js new file mode 100644 index 00000000..2d05cc5d --- /dev/null +++ b/app/components/Item/Show/Seasons/EpisodeInfo/EpisodesInfo.js @@ -0,0 +1,18 @@ +import React from 'react' + +import type { Props } from './EpisodesInfoTypes' +import classes from './EpisodesInfo.scss' + +export const EpisodeInfo = ({ episode }: Props) => ( +
+
+ {episode.title} +
+ +

+ {episode.summary} +

+
+) + +export default EpisodeInfo diff --git a/app/components/Item/Show/Seasons/EpisodeInfo/EpisodesInfo.scss b/app/components/Item/Show/Seasons/EpisodeInfo/EpisodesInfo.scss new file mode 100644 index 00000000..f963a5b3 --- /dev/null +++ b/app/components/Item/Show/Seasons/EpisodeInfo/EpisodesInfo.scss @@ -0,0 +1,20 @@ +@import 'base/variables'; + +.episode { + background: $body-bg; + width: 100%; + box-shadow: $box-shadow; + color: white; + padding: 5px 40px; + + &__title { + height: 60px; + font-size: 20px; + line-height: 60px; + } + + p { + font-size: 14px; + padding-bottom: 10px; + } +} diff --git a/app/components/Item/Show/Seasons/EpisodeInfo/EpisodesInfoTypes.js b/app/components/Item/Show/Seasons/EpisodeInfo/EpisodesInfoTypes.js new file mode 100644 index 00000000..5af62640 --- /dev/null +++ b/app/components/Item/Show/Seasons/EpisodeInfo/EpisodesInfoTypes.js @@ -0,0 +1,6 @@ +// @flow +import type { EpisodeType } from 'api/Metadata/MetadataTypes' + +export type Props = { + episode: EpisodeType, +} diff --git a/app/components/Item/Show/Seasons/EpisodeInfo/index.js b/app/components/Item/Show/Seasons/EpisodeInfo/index.js new file mode 100644 index 00000000..cb9cf70c --- /dev/null +++ b/app/components/Item/Show/Seasons/EpisodeInfo/index.js @@ -0,0 +1 @@ +export default from './EpisodesInfo' diff --git a/app/components/Item/Show/Seasons/Episodes/Episodes.js b/app/components/Item/Show/Seasons/Episodes/Episodes.js new file mode 100644 index 00000000..e0f77189 --- /dev/null +++ b/app/components/Item/Show/Seasons/Episodes/Episodes.js @@ -0,0 +1,87 @@ +/** + * Created by tycho on 10/07/2017. + */ +import React from 'react' +import classNames from 'classnames' +import { Tooltip } from 'reactstrap' + +import type { Props, State } from './EpisodesTypes' + +export class Episodes extends React.Component { + + props: Props + + state: State = { + tooltips: [], + } + + toggleMagnetTooltip = (episode) => { + const { tooltips } = this.state + + tooltips[episode] = !tooltips[episode] + + this.setState({ + tooltips, + }) + } + + render() { + const { selectedSeason, selectedEpisode } = this.props + const { selectSeasonAndEpisode } = this.props + const { tooltips } = this.state + + const today = new Date().getTime() + + return ( +
+ {selectedSeason.episodes.map(episode => + today, + })} + key={episode.number} + onClick={() => { + if (episode.aired < today) { + selectSeasonAndEpisode(selectedSeason.number, episode.number) + } + }} + > +
+ {episode.number}. {episode.title} +
+ +
+ ) + } +} + +export default Episodes diff --git a/app/components/Item/Show/Seasons/Episodes/EpisodesTypes.js b/app/components/Item/Show/Seasons/Episodes/EpisodesTypes.js new file mode 100644 index 00000000..7962cc2f --- /dev/null +++ b/app/components/Item/Show/Seasons/Episodes/EpisodesTypes.js @@ -0,0 +1,12 @@ +// @flow +import type { SeasonType } from 'api/Metadata/MetadataTypes' + +export type Props = { + selectedSeason: SeasonType, + selectedEpisode: number, + selectSeasonAndEpisode: () => void, +} + +export type State = { + tooltips: Array, +} diff --git a/app/components/Item/Show/Seasons/Episodes/index.js b/app/components/Item/Show/Seasons/Episodes/index.js new file mode 100644 index 00000000..2e89f11d --- /dev/null +++ b/app/components/Item/Show/Seasons/Episodes/index.js @@ -0,0 +1 @@ +export default from './Episodes' diff --git a/app/components/Item/Show/Seasons/Seasons.js b/app/components/Item/Show/Seasons/Seasons.js new file mode 100644 index 00000000..55a43dbb --- /dev/null +++ b/app/components/Item/Show/Seasons/Seasons.js @@ -0,0 +1,51 @@ +/** + * Created by tycho on 10/07/2017. + */ +import React from 'react' +import classNames from 'classnames' + +import type { Props } from './SeasonsTypes' +import Episodes from './Episodes' +import EpisodeInfo from './EpisodeInfo' +import classes from './Seasons.scss' + +export const Seasons = ({ seasons, selectSeasonAndEpisode, selectedSeason, selectedEpisode }: Props) => ( +
+ + +
+ + + +
+ +
+ + + +
+
+) + +export default Seasons diff --git a/app/components/Item/Show/Seasons/Seasons.scss b/app/components/Item/Show/Seasons/Seasons.scss new file mode 100644 index 00000000..3b71f6e2 --- /dev/null +++ b/app/components/Item/Show/Seasons/Seasons.scss @@ -0,0 +1,8 @@ +@import 'base/variables'; + +.seasons { + display: flex; + justify-content: center; + flex-flow: column; + align-items: center; +} diff --git a/app/components/Item/Show/Seasons/SeasonsTypes.js b/app/components/Item/Show/Seasons/SeasonsTypes.js new file mode 100644 index 00000000..62a3c8b2 --- /dev/null +++ b/app/components/Item/Show/Seasons/SeasonsTypes.js @@ -0,0 +1,9 @@ +// @flow +import type { SeasonType } from 'api/Metadata/MetadataTypes' + +export type Props = { + seasons: Array, + selectedSeason: number, + selectedEpisode: number, + selectSeasonAndEpisode: () => void, +} diff --git a/app/components/Item/Show/Seasons/index.js b/app/components/Item/Show/Seasons/index.js new file mode 100644 index 00000000..24b77275 --- /dev/null +++ b/app/components/Item/Show/Seasons/index.js @@ -0,0 +1 @@ +export default from './Seasons' diff --git a/app/components/Item/Show/Show.js b/app/components/Item/Show/Show.js new file mode 100644 index 00000000..df53d35f --- /dev/null +++ b/app/components/Item/Show/Show.js @@ -0,0 +1,109 @@ +import React from 'react' +import classNames from 'classnames' + +import type { State, Props } from './ShowTypes' +import itemClasses from '../Item.scss' +import classes from './Show.scss' +import Seasons from './Seasons' + +export class Show extends React.Component { + + props: Props + + state: State = { + selectedSeason : 1, + selectedEpisode: 1, + } + + playEpisode = (torrent) => { + if (torrent !== null) { + const { play } = this.props + + play('default', torrent) + + } else { + const { item, searchEpisodeTorrents } = this.props + const season = this.getSeason() + const episode = this.getEpisode() + + if (!episode.searched) { + searchEpisodeTorrents(item, season.number, episode.number) + } + } + } + + selectSeasonAndEpisode = (season, episode) => this.setState({ + selectedSeason : season, + selectedEpisode: episode, + }) + + getSeason = () => { + const { item } = this.props + const { selectedSeason } = this.state + + if (!item.seasons) { + return null + } + + return item.seasons.find(season => season.number === selectedSeason) + } + + getEpisode = () => { + const { selectedEpisode } = this.state + const season = this.getSeason() + + if (!season || !season.episodes) { + return null + } + + return season.episodes.find(episode => episode.number === selectedEpisode) + } + + render() { + const { item, fetchingEpisodeTorrents } = this.props + const season = this.getSeason() + const episode = this.getEpisode() + const { torrents, searched } = episode + + return ( +
+
+ + {torrents && Object.keys(torrents).map(quality => ( + + ))} + +
+ +
+ + +
+
+ ) + } +} + +export default Show diff --git a/app/components/Item/Show/Show.scss b/app/components/Item/Show/Show.scss new file mode 100644 index 00000000..20b8c505 --- /dev/null +++ b/app/components/Item/Show/Show.scss @@ -0,0 +1,12 @@ +.show { + &__actions { + display: flex; + align-items: center; + justify-content: center; + + button { + position: relative; + margin: 0 20px; + } + } +} diff --git a/app/components/Item/Show/ShowContainer.js b/app/components/Item/Show/ShowContainer.js new file mode 100644 index 00000000..8822f234 --- /dev/null +++ b/app/components/Item/Show/ShowContainer.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux' + +import { searchEpisodeTorrents } from '../ItemActions' +import * as Selectors from '../ItemSelectors' + +import Show from './Show' + +export const mapStateToProps = state => ({ + item : Selectors.getItem(state), + fetchingEpisodeTorrents: Selectors.getFetchingEpisodeTorrents(state), +}) + +export default connect(mapStateToProps, { searchEpisodeTorrents })(Show) diff --git a/app/components/Item/Show/ShowTypes.js b/app/components/Item/Show/ShowTypes.js new file mode 100644 index 00000000..e92c302b --- /dev/null +++ b/app/components/Item/Show/ShowTypes.js @@ -0,0 +1,13 @@ +import type { TorrentType } from 'api/Torrents/TorrentType' +import type { ContentType } from 'api/Metadata/MetadataTypes' + +export type Props = { + item: ContentType, + fetchingEpisodeTorrents: boolean, + play: (playerType: string, torrent: TorrentType) => void, +} + +export type State = { + selectedSeason: number, + selectedEpisode: number, +} diff --git a/app/components/Item/Show/index.js b/app/components/Item/Show/index.js new file mode 100644 index 00000000..8599b420 --- /dev/null +++ b/app/components/Item/Show/index.js @@ -0,0 +1 @@ +export default from './ShowContainer' diff --git a/app/components/show/Show.jsx b/app/components/show/Show.jsx deleted file mode 100644 index b145a37f..00000000 --- a/app/components/show/Show.jsx +++ /dev/null @@ -1,79 +0,0 @@ -// @flow -import React from 'react' -import classNames from 'classnames' - -import type { Props } from './ShowConstants' - -export default function Show(props: Props) { - const { seasons, selectShow, selectedSeason, episodes, selectedEpisode } = props; - - return ( -
- - - - -
    -
  • -

    Season overview:

    -
  • -
  • -
    - {seasons.length && selectedSeason && seasons[selectedSeason] - ? seasons[selectedSeason].overview - : null} -
    -
  • -
-
    -
  • -

    Episode overview:

    -
  • -
  • -
    - {episodes.length && selectedSeason && episodes[selectedEpisode] - ? episodes[selectedEpisode].overview - : null} -
    -
  • -
-
- ) -} - -Show.defaultProps = { - seasons : [], - episodes: [], - episode : {} -}; diff --git a/app/components/show/ShowConstants.js b/app/components/show/ShowConstants.js deleted file mode 100644 index 69ddaf65..00000000 --- a/app/components/show/ShowConstants.js +++ /dev/null @@ -1,15 +0,0 @@ -// @flow -export type Props = { - selectShow: (type: string, season: number, episode: ?number) => void, - selectedSeason: number, - selectedEpisode: number, - seasons: Array<{ - season: number, - overview: string - }>, - episodes: Array<{ - episode: number, - overview: string, - title: string - }> -}; diff --git a/app/styles/base/_variables.scss b/app/styles/base/_variables.scss index 80b750a6..206aa946 100644 --- a/app/styles/base/_variables.scss +++ b/app/styles/base/_variables.scss @@ -72,7 +72,7 @@ $headings-font-weight: 400; // // Card list -$card-list-color: #848484; +$card-list-color: $navbar-dark-color; // Card $card-max-with: 140px; @@ -92,3 +92,6 @@ $notie-font-size-small: 18px; // Plyr $plyr-color-main: gray; $plyr-tooltip-radius: 0; + +$box-shadow: rgba(0, 0, 0, 0.6) 0 6px 16px 4px; + diff --git a/app/styles/components/button.scss b/app/styles/components/button.scss index b11451fa..70009a02 100644 --- a/app/styles/components/button.scss +++ b/app/styles/components/button.scss @@ -31,4 +31,13 @@ border-radius: 30px; } + &.pct-btn-available { + transition: opacity 0.1s ease-out; + opacity: 1; + + &:hover { + opacity: 0.8; + } + } + } diff --git a/app/styles/components/list.scss b/app/styles/components/list.scss new file mode 100644 index 00000000..96c7a14f --- /dev/null +++ b/app/styles/components/list.scss @@ -0,0 +1,64 @@ +.list { + box-shadow: $box-shadow; + background: $body-bg; + width: 100%; + color: #FFF; + max-height: 600px; + overflow-x: auto; + + &-item { + display: flex; + width: 100%; + background: $body-bg; + padding: 5px 30px; + cursor: pointer; + + &__text { + height: 60px; + line-height: 60px; + overflow: hidden; + text-overflow: ellipsis; + } + + &__health { + margin-left: auto; + display: flex; + align-items: center; + + i { + font-size: 22px; + color: rgba(255, 255, 255, 0.6); + } + + &-status { + margin-left: 5px; + width: 10px; + height: 10px; + border-radius: 50%; + transition: background-color 500ms linear; + + &:first-of-type { + margin-left: 20px; + } + } + } + + &:hover { + background: lighten($body-bg, 20%); + transition: background-color 200ms linear; + } + + &--active { + background: lighten($body-bg, 20%); + } + + &--disabled { + background: darken($body-bg, 5%); + + &:hover { + background: darken($body-bg, 5%); + } + } + } + +} diff --git a/app/styles/core.global.scss b/app/styles/core.global.scss index f8e5ed9b..abe67b80 100644 --- a/app/styles/core.global.scss +++ b/app/styles/core.global.scss @@ -16,6 +16,7 @@ // @import './components/button'; +@import './components/list'; @import './components/stars'; // diff --git a/app/utils/Config.js b/app/utils/Config.js index 9c579b8b..4de797d8 100644 --- a/app/utils/Config.js +++ b/app/utils/Config.js @@ -5,31 +5,29 @@ * If it doesn't, create it and initialize it with the fields: * 'favorites', 'watchList', 'recentlyWatched' */ -import ConfigStore from 'configstore'; +import ConfigStore from 'configstore' -export default function setupConfig() { - return new ConfigStore('popcorn-time-experimental', { - favorites: [], - recentlyWatched: [], - watchList: [], - state: {} - }); -} - -const config = setupConfig(); +export const config = new ConfigStore('popcorn-time', { + favorites : [], + recentlyWatched: [], + watchList : [], + state : {}, +}) export function set(key: string, value: any) { - return config.set(key, value); + return config.set(key, value) } export function get(key: string) { - return config.get(key); + return config.get(key) } export function remove(key: string) { - return config.delete(key); + return config.delete(key) } export function clear() { - return config.clear(); + return config.clear() } + +export default config diff --git a/notes.md b/notes.md index 112a1a20..c7656a01 100644 --- a/notes.md +++ b/notes.md @@ -12,3 +12,10 @@ API 2 ( For TV ?) Get collections from the API above then for the detail page use TheMovieDBMetadata Merge TheMovieDBMetadata and above API and when the episode does not have a magnet search for one + + + +#### Database +Use `https://github.com/popcorn-official/popcorn-desktop/blob/development/src/app/database.js` + +instead of `configstore` diff --git a/package.json b/package.json index ef99f421..7fa4f015 100644 --- a/package.json +++ b/package.json @@ -225,11 +225,13 @@ "why-did-you-update": "0.0.8" }, "dependencies": { + "TorrentCollection": "0.0.2", "animate.css": "^3.5.2", "axios": "^0.16.2", "bluebird": "^3.5.0", "bootstrap": "4.0.0-alpha.4", "debug": "^2.6.8", + "has-own-property": "^1.0.0", "isomorphic-fetch": "2.2.1", "network-address": "1.1.2", "notie": "4.3.1", @@ -258,6 +260,7 @@ "speedtest-net": "1.3.1", "srt2vtt": "1.3.1", "super-kat": "0.1.0", + "torrentapi-wrapper": "0.0.2", "trakt.tv": "5.0.1", "vlc-command": "1.1.1", "webtorrent": "github:amilajack/webtorrent",