-
Notifications
You must be signed in to change notification settings - Fork 4
/
index.js
307 lines (274 loc) · 11.5 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
'use strict'
// @flow
/*:: import type { Options, Show, OrigSubtitles } from './types' */
const { spawn } = require('child_process')
const feed = require('feed-read')
const { flatten, partialRight, uniqBy } = require('lodash')
const subtitles = require('subtitler')
const retryPromise = require('promise-retry')
const fs = require('fs')
const gunzip = require('gunzip-maybe')
const http = require('http')
const shellEscape = require('shell-escape')
const utils = require('./utils')
const path = require('path')
const glob = require('glob-promise')
const playOffline = require('./play')
const selectShow = require('./browse')
const selectMovie = require('./movies')
const debug = require('debug')('show-time')
const levenshtein = require('levenshtein')
const qs = require('querystring')
const SUBTITLES_TTL = 3600
const RE_TITLE_TOKENS = /720p|PROPER|REPACK/
const RE_TITLE_NUMBER = /[ .-](\d{1,2})x(\d{1,2})(?:[ .-]|$)/
const dedupeSubtitles = partialRight(uniqBy, 'SubDownloadLink')
module.exports = (options/*:Options*/) /*:Promise<any>*/ =>
checkOptions(options)
.then(opts => // void
selectVideo(opts) // { url, title }
.then((video/*:?Show*/) => (debug('Selected (1/3)', video), video))
.then(selectTorrent) // when url is an array of described torrents
.then((torrent/*:?Show*/) => (debug('Selected (2/3)', torrent), torrent))
.then(downloadSubtitles(opts)) // { url, title, subtitles }
.then(show => (debug('Selected (3/3)', show), show))
.then(play(opts))
)
const checkOptions = options => {
const opts = Object.assign({}, options)
opts.log = opts.log || console.log.bind(console)
opts.langs = opts.langs || opts.lang.split(/\s*,\s*/).map(l => l.trim())
if (!opts.feed && !opts.offline && !opts.browse && opts.movie && !opts.title) {
return Promise.reject(Error('No feed configured, you must configure one or provide one of those options: --offline, --browse, --movie, or <title>'))
}
if (opts.browse || opts.title) {
if (opts.offline) {
return Promise.reject(Error('Using option --browse or <title> is incompatible with offline mode'))
}
// Grab 'feed' option from browsing showrss
return cacheReady(opts).then(() => selectShow(opts)).then(feed => {
if (!feed) {
process.exit(0)
}
return Object.assign(opts, { feed })
})
}
if (opts.offline && !opts.cache) {
return Promise.reject(Error('Cannot use "offline" option while cache is disabled'))
}
return cacheReady(opts).then(() => opts)
}
const cacheReady = ({ cache }) => cache ? utils.createDir(cache) : Promise.resolve()
const readFeed = rss => new Promise((resolve, reject) => feed(rss, (err, articles) => err ? reject(err) : resolve(articles)))
const pad0 = s => (s.length === 1) ? '0' + s : s
const fetchSubtitles = (title/*:string*/) /*:Promise<OrigSubtitles[]>*/ => {
// Cleanup title
title = title.replace(RE_TITLE_TOKENS, '')
const titles = [
title.replace(RE_TITLE_NUMBER, (string, season, episode) => ` S${pad0(season)}E${pad0(episode)} `),
title.replace(RE_TITLE_NUMBER, (string, season, episode) => ` ${pad0(season)}x${pad0(episode)} `)
]
let token = null, results = null
return subtitles.api.login()
.then(_token => token = _token)
.then(() => Promise.all(titles.map(t => subtitles.api.searchForTitle(token, null, t))))
.then(flatten)
.then(dedupeSubtitles)
.then(_results => results = _results)
.then(() => subtitles.api.logout(token))
.then(() => results)
}
const searchSubtitles = (title, cache, _skipReadCache) /*:Promise<OrigSubtitles[]>*/ => {
const filename = title + '.json'
const getData = () => fetchSubtitles(title)
return utils.getCached(_skipReadCache ? null : cache, filename, getData, { ttl: SUBTITLES_TTL })
}
const selectVideo = (opts) /*:() => Promise<?Show>*/ => opts.movie
? selectMovie(opts)
: selectEpisode(opts)
const selectEpisode = ({ feed, cache, offline, log }) /*:() => Promise<Show>*/ => offline
? // Offline mode
glob(path.join(cache, '*/'))
.then(dirs => dirs.length === 0
? Promise.reject(Error('Nothing to play offline'))
: dirs
)
.then(dirs => utils.ask.list('Partially or complete available episodes', dirs.map(d => ({
name: path.basename(d),
value: d
}))))
.then(dir => utils.biggestFile(dir).then(f => ({
title: path.basename(dir),
url: f.name
})))
.then(show => {
log("File path: " + show.url)
return show
})
: // Online
readFeed(feed)
.then(articles => utils.ask.list('Recent available episodes', articles.map(a => ({ name: a.title, value: {
title: a.title,
url: a.link
}}))))
.then(show => {
log("Magnet URL: " + show.url)
return show
})
const selectTorrent = (show/*:?Show*/) /*:Promise<?Show>*/ => {
if (!show || !Array.isArray(show.url)) {
return Promise.resolve(show)
}
return _selectTorrent(show)
}
const _selectTorrent = (show/*:Show*/) /*:Promise<Show>*/ => {
if (typeof show.url === 'string') {
return Promise.resolve(show)
}
const urls = show.url.map(({ description, url }) => ({
name: description,
value: url
}))
return utils.ask.list('Select torrent', urls).then(url => {
show.url = url
return show
})
}
/*$FlowFixMe*/
const downloadSubtitles = ({ langs, cache, offline, log }) => (show /*:?Show*/) /*:Promise<?Show>*/ => {
if (!show) {
return Promise.resolve(show)
}
const filename = utils.cachePath(cache, show.title + '.srt', true)
if (!filename) {
return Promise.reject(Error('Fallback to temporary dir failed'))
}
return _downloadSubtitles({ langs, cache, offline, log }, show, filename)
}
const _downloadSubtitles = ({ langs, cache, offline, log }, show/*:Show*/, filename/*:string*/) /*:Promise<Show>*/ => {
const searchAndDownload_off = () => {
log('Subtitles download disabled in offline mode')
return Promise.resolve(null)
}
const searchAndDownload_on = () => utils.ask.confirm('Download subtitles?', true)
.then(utils.ifTrue(() => retryPromise(retry => {
log('Searching subtitles...')
return searchSubtitles(show.title, cache)
.then(selectSubtitle(langs, log, show))
.catch(err => {
debug('Subtitles Error', err)
log('Failed looking up for subtitles, try again...')
return retry(err)
})
}, { retries: 5 })
))
.then(utils.ifTrue(downloadAs(filename, log)))
const searchAndDownload /*:() => Promise<any>*/ = offline ? searchAndDownload_off : searchAndDownload_on
const downloaded = (filename && utils.canRead(filename))
? utils.ask.confirm('Found previously downloaded subtitles, continue with it?', true)
.then(reuse => reuse ? Promise.resolve(filename) : searchAndDownload(show))
: searchAndDownload(show)
const setSubtitles = (subtitles/*:?string*/) /*:Show*/ => {
if (subtitles) {
show.subtitles = subtitles
}
return show
}
return downloaded
.then(setSubtitles)
.catch(() => {
log('OpenSubtitles seems to be grumpy today, I give up')
return utils.ask.confirm('Continue without subtitles?', true)
.then(cont => {
if (!cont) {
process.exit(1)
}
return show
})
})
}
const downloadAs = (filename /*:string*/, log/*:Function*/) => (url/*:string*/) /*:Promise<string>*/ => new Promise((resolve, reject) => {
log('Download: ' + url)
log('To: ' + filename)
http.get(url, res => {
const output = fs.createWriteStream(filename)
const uncompress = gunzip()
res.on('error', reject)
uncompress.on('error', reject)
output.on('error', reject)
output.on('close', () => resolve(filename))
res.pipe(uncompress).pipe(output)
}).on('error', reject)
})
const selectSubtitle = (langs/*:Array<string>*/, log/*:Function*/, show/*:?Show*/) => (allSubtitles/*:OrigSubtitles[]*/) /*:Promise<?string>*/ => {
const filtered = langs && Array.isArray(langs) && langs.length > 0
let subtitles = filtered ? allSubtitles.filter(s => langs.indexOf(s.SubLanguageID) !== -1) : allSubtitles
if (filtered && !subtitles.length) {
log('No subtitles for your languages, showing all subtitles')
subtitles = allSubtitles
}
if (!subtitles.length) {
log('No subtitles found')
return Promise.resolve(null)
}
// Sort by language index asc, then similarity desc (= levenshtein asc), then date desc
const dn = show && qs.parse(show.url).dn // Use magnet's dn when possible (more info about releaser)
const title = show ? dn || show.title : null
debug('Reference title to sort subtitles', { show, title })
const sortedSubtitles = subtitles.sort((s1, s2) => {
const l1 = langs.indexOf(s1.SubLanguageID)
const l2 = langs.indexOf(s2.SubLanguageID)
debug('Sort (lang)', { l1: s1.SubLanguageID, l2: s2.SubLanguageID, i1: l1, i2: l2 })
if (l1 !== l2) return l1 - l2;
if (show) {
const d1 = levenshtein(title, s1.MovieReleaseName)
const d2 = levenshtein(title, s2.MovieReleaseName)
debug('Sort (Levenshtein)', { title, s1: s1.MovieReleaseName, s2: s2.MovieReleaseName, d1, d2 })
if (d1 !== d2) return d1 - d2;
// else: fallback to date when title distance is the same
}
const d1 = new Date(s1.SubAddDate)
const d2 = new Date(s2.SubAddDate)
debug('Sort (date)', { d1, d2 })
return (+d2) - (+d1)
})
const choices = sortedSubtitles.map(s => ({
name: s.SubAddDate + ' [' + s.SubLanguageID + '] ' + s.SubFileName + ' (' + Math.round(s.SubSize / 1024) + 'Kb)',
value: s.SubDownloadLink
})).concat([{
name: 'Continue without subtitles',
value: null
}])
return utils.ask.list('Available subtitles', choices)
}
const play = options => (options.player === 'chromecast')
? castNow(path.join(__dirname, 'node_modules', '.bin', 'castnow'), options.cache, options.offline, options.port, options['peer-port'], options.log)
: streamTorrent(path.join(__dirname, 'node_modules', '.bin', 'peerflix'), options.cache, options.offline, options.player, options.port, options['peer-port'], options.log)
const castNow = (castnowBin, cache, offline, port, peerPort, log) => show => new Promise((resolve, reject) => {
const cachePath = cache ? utils.cachePath(cache, show.title) : null
const args/*:string[]*/ = [show.url]
.concat(offline ? [] : ['--peerflix-port', String(port || 8888), '--peerflix-peer-port', String(peerPort)])
.concat((offline || !cachePath) ? [] : ['--peerflix-path', cachePath])
.concat(show.subtitles ? ['--subtitles', show.subtitles] : [])
log('Running castnow...')
log(shellEscape([castnowBin].concat(args)))
const child = spawn(castnowBin, args, { stdio: 'inherit' })
child.on('error', reject)
child.on('exit', code => code ? reject(code) : resolve())
})
const streamTorrent = (peerflixBin, cache, offline, player, port, peerPort, log) => offline
? // Offline mode
show => playOffline(player, show.url, show.subtitles)
: // Online mode
show => new Promise((resolve, reject) => {
const cachePath = cache ? utils.cachePath(cache, show.title) : null
const args/*:string[]*/ = [show.url, '--port', String(port || 8888), '--peer-port', String(peerPort)]
.concat(cachePath ? ['--path', cachePath] : [])
.concat(player && show.subtitles ? ['--subtitles', show.subtitles] : [])
.concat(player ? ['--' + player] : [])
log('Running peerflix...')
log(shellEscape([peerflixBin].concat(args)))
const child = spawn(peerflixBin, args, { stdio: 'inherit' })
child.on('error', reject)
child.on('exit', code => code ? reject(code) : resolve())
})