forked from nzbget/FailureLink
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathmain.py
399 lines (338 loc) · 13.8 KB
/
main.py
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
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
#!/usr/bin/env python
#
# FailureLink post-processing script for NZBGet
#
# Copyright (C) 2013-2014 Andrey Prygunkov <[email protected]>
# Copyright (c) 2013-2014 Clinton Hall <[email protected]>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
import os
import sys
import platform
import subprocess
import json
import ssl
import shutil
import stat
from base64 import standard_b64encode
from xmlrpc.client import ServerProxy
import urllib.request, urllib.error
from urllib.error import HTTPError
from email.message import EmailMessage
import xml.etree.ElementTree as ET
# Exit codes used by NZBGet
POSTPROCESS_SUCCESS=93
POSTPROCESS_NONE=95
POSTPROCESS_ERROR=94
# Check if the script is called from nzbget 12.0 or later
if not 'NZBOP_FEEDHISTORY' in os.environ:
print('*** NZBGet post-processing script ***')
print('This script is supposed to be called from nzbget (12.0 or later).')
sys.exit(POSTPROCESS_ERROR)
# Init script config options
verbose = False
download_another_release=os.environ.get('NZBPO_DOWNLOADANOTHERRELEASE', 'yes') == 'yes'
verbose=os.environ.get('NZBPO_VERBOSE', 'no') == 'yes'
delete=os.environ.get('NZBPO_DELETE', 'no') == 'yes'
nzbget = None
MEDIACONTAINER = (os.environ.get('NZBPO_MEDIAEXTENSIONS', 'False')).split(',')
PROGRAM_DIR = os.path.normpath(os.path.abspath(os.path.join(__file__, os.pardir)))
CHECKVIDEO = os.environ.get('NZBPO_CHECKVID', 'no') == 'yes'
if 'NZBPO_TESTVID' in os.environ and os.path.isfile(os.environ['NZBPO_TESTVID']):
TEST_FILE = os.environ['NZBPO_TESTVID']
else:
TEST_FILE = None
FFPROBE = None
if 'NZBPO_FFPROBE' in os.environ and os.environ['NZBPO_FFPROBE'] != "":
if os.path.isfile(os.environ['NZBPO_FFPROBE']) or os.access(os.environ['NZBPO_FFPROBE'], os.X_OK):
FFPROBE = os.environ['NZBPO_FFPROBE']
if CHECKVIDEO and not FFPROBE:
if platform.system() == 'windows':
if os.path.isfile(os.path.join(PROGRAM_DIR, 'ffprobe.exe')):
FFPROBE = os.path.join(PROGRAM_DIR, 'ffprobe.exe')
elif os.path.isfile(os.path.join(PROGRAM_DIR, 'ffprobe')) or os.access(os.path.join(PROGRAM_DIR, 'ffprobe'), os.X_OK):
FFPROBE = os.path.join(PROGRAM_DIR, 'ffprobe')
elif os.path.isfile(os.path.join(PROGRAM_DIR, 'avprobe')) or os.access(os.path.join(PROGRAM_DIR, 'avprobe'), os.X_OK):
FFPROBE = os.path.join(PROGRAM_DIR, 'avprobe')
else:
try:
FFPROBE = subprocess.Popen(['which', 'ffprobe'], stdout=subprocess.PIPE).communicate()[0].decode('utf-8').strip()
except: pass
if not FFPROBE:
try:
FFPROBE = subprocess.Popen(['which', 'avprobe'], stdout=subprocess.PIPE).communicate()[0].decode('utf-8').strip()
except: pass
if CHECKVIDEO and FFPROBE:
result = 1
devnull = open(os.devnull, 'w')
try:
command = [FFPROBE, '-h']
proc = subprocess.Popen(command, stdout=devnull, stderr=devnull)
out, err = proc.communicate()
result = proc.returncode
except:
FFPROBE = None
devnull.close()
if result:
FFPROBE = None
if CHECKVIDEO and not FFPROBE:
print('[WARNING] Failed to locate ffprobe, video corruption detection disabled!')
print('[WARNING] Install ffmpeg with x264 support to enable this feature ...')
def isVideoGood(videofile):
fileNameExt = os.path.basename(videofile)
fileName, fileExt = os.path.splitext(fileNameExt)
disable = False
if fileExt not in MEDIACONTAINER or not FFPROBE:
return True
print('[INFO] Checking [%s] for corruption, please stand by ...' % (fileNameExt))
video_details, result = getVideoDetails(videofile)
if result != 0:
print('[Error] FAILED: [%s] is corrupted!' % (fileNameExt))
return False
if video_details.get("error"):
print('[INFO] FAILED: [%s] returned error [%s].' % (fileNameExt, str(video_details.get("error"))))
return False
if video_details.get("streams"):
videoStreams = [item for item in video_details["streams"] if item["codec_type"] == "video"]
audioStreams = [item for item in video_details["streams"] if item["codec_type"] == "audio"]
if len(videoStreams) > 0 and len(audioStreams) > 0:
print('[INFO] SUCCESS: [%s] has no corruption.' % (fileNameExt))
return True
else:
print('[INFO] FAILED: [%s] has %s video streams and %s audio streams. Assume corruption.' % (fileNameExt, str(len(videoStreams)), str(len(audioStreams))))
return False
def getVideoDetails(videofile):
video_details = {}
result = 1
if not FFPROBE:
return video_details, result
if 'avprobe' in FFPROBE:
print_format = '-of'
else:
print_format = '-print_format'
try:
command = [FFPROBE, '-v', 'quiet', print_format, 'json', '-show_format', '-show_streams', '-show_error', videofile]
proc = subprocess.Popen(command, stdout=subprocess.PIPE)
out, err = proc.communicate()
result = proc.returncode
video_details = json.loads(out)
except: pass
if not video_details:
try:
command = [FFPROBE, '-v', 'quiet', print_format, 'json', '-show_format', '-show_streams', videofile]
proc = subprocess.Popen(command, stdout=subprocess.PIPE)
out, err = proc.communicate()
result = proc.returncode
video_details = json.loads(out)
except:
print('[ERROR] Checking [%s] has failed' % (videofile))
return video_details, result
def corruption_check():
corrupt = False
if not CHECKVIDEO:
return corrupt
if not TEST_FILE:
ffprobe_Tested = False
elif isVideoGood(TEST_FILE):
ffprobe_Tested = True
else:
print('[INFO] DISABLED: ffprobe failed to analyse streams from test file. Stopping corruption check.')
return corrupt
num_files = 0
good_files = 0
for dir, dirs, files in os.walk(os.environ['NZBPP_DIRECTORY']):
for file in files:
if os.path.split(dir)[1][0] == '.': # hidden directory.
continue
filepath = os.path.join(dir, file)
num_files += 1
if isVideoGood(filepath):
good_files += 1
if num_files > 0 and good_files < num_files:
print('[INFO] Corrupt video file found.')
corrupt = True
# check for NZBGet V14+
NZBGetVersion=os.environ['NZBOP_VERSION']
if NZBGetVersion[0:5] >= '14.0':
print('[NZB] MARK=BAD')
return corrupt
def downloadNzb(failure_link):
# Contact indexer site
if download_another_release:
print('[INFO] Requesting another release from indexer site')
else:
print('[INFO] Sending failure status to indexer site')
sys.stdout.flush()
nzbcontent = None
headers = None
try:
headers = {'User-Agent' : 'NZBGet (FailureLink)'}
req = urllib.request.Request(failure_link, None, headers)
try:
response = urllib.request.urlopen(req)
except:
print('[WARNING] SSL certificate verify failed, retry with bypass SSL cert.')
context = ssl._create_unverified_context()
response = urllib.request.urlopen(req, context=context)
else:
pass
if download_another_release:
nzbcontent = response.read()
headers = response.info()
except HTTPError as e:
if e.code == 404:
print('[INFO] No other releases found')
else:
print('[ERROR] %s' % e.code)
sys.exit(POSTPROCESS_ERROR)
except Exception as err:
print('[ERROR] %s' % err)
sys.exit(POSTPROCESS_ERROR)
return nzbcontent, headers
def connectToNzbGet():
global nzbget
# First we need to know connection info: host, port and password of NZBGet server.
# NZBGet passes all configuration options to post-processing script as
# environment variables.
host = os.environ['NZBOP_CONTROLIP']
port = os.environ['NZBOP_CONTROLPORT']
username = os.environ['NZBOP_CONTROLUSERNAME']
password = os.environ['NZBOP_CONTROLPASSWORD']
if host == '0.0.0.0': host = '127.0.0.1'
# Build an URL for XML-RPC requests
# TODO: encode username and password in URL-format
rpcUrl = 'http://%s:%s@%s:%s/xmlrpc' % (username, password, host, port)
# Create remote server object
nzbget = ServerProxy(rpcUrl)
def queueNzb(filename, category, nzbcontent64):
# Adding nzb-file to queue
# Signature:
# append(string NZBFilename, string Category, int Priority, bool AddToTop, string Content,
# bool AddPaused, string DupeKey, int DupeScore, string DupeMode)
nzbget.append(filename, category, 0, True, nzbcontent64, True, '', 0, 'ALL')
# We need to find the id of the added nzb-file
root = ET.fromstring(nzbget.listgroups().data)
groups = root.findall('.//struct')
for group in groups:
nzb_id = group.find(".//member[name='NZBID']/value/i4").text
nzb_filename = group.find(".//member[name='NZBFilename']/value/string").text
if verbose:
print(f'NZBID: {nzb_id}, NZBFilename: {nzb_filename}')
if nzb_filename == filename:
groupid = int(nzb_id)
break
if verbose:
print('GroupID: %i' % groupid)
return groupid
def setupDnzbHeaders(groupid, headers):
for header in headers:
if verbose:
print(header.strip())
if header[0:7] == 'X-DNZB-':
name = header.split(':')[0].strip()
value = headers.get(name)
if verbose:
print('%s=%s' % (name, value))
# Setting "X-DNZB-" as post-processing parameter
param = '*DNZB:%s=%s' % (name[7:], value)
nzbget.editqueue('GroupSetParameter', 0, param, [groupid])
def unpauseGroup(groupid):
nzbget.editqueue('GroupResume', 0, '', [groupid])
def onerror(func, path, exc_info):
"""
Error handler for ``shutil.rmtree``.
If the error is due to an access error (read only file)
it attempts to add write permission and then retries.
If the error is for another reason it re-raises the error.
Usage : ``shutil.rmtree(path, onerror=onerror)``
"""
if not os.access(path, os.W_OK):
# Is the error an access error ?
os.chmod(path, stat.S_IWUSR)
func(path)
else:
raise
def rmDir(dirName):
print('[INFO] Deleting %s' % (dirName))
try:
shutil.rmtree(dirName, onerror=onerror)
except:
print('[ERROR] Unable to delete folder %s' % (dirName))
def main():
# Check par and unpack status for errors.
# NZBPP_PARSTATUS - result of par-check:
# 0 = not checked: par-check is disabled or nzb-file does
# not contain any par-files;
# 1 = checked and failed to repair;
# 2 = checked and successfully repaired;
# 3 = checked and can be repaired but repair is disabled.
# 4 = par-check needed but skipped (option ParCheck=manual);
# NZBPP_UNPACKSTATUS - result of unpack:
# 0 = unpack is disabled or was skipped due to nzb-file
# properties or due to errors during par-check;
# 1 = unpack failed;
# 2 = unpack successful.
failure = os.environ.get('NZBPP_PARSTATUS', 'False') == '1' or os.environ.get('NZBPP_UNPACKSTATUS', 'False') == '1' or os.environ.get('NZBPP_PPSTATUS_FAKE') == 'yes'
failure_link = os.environ.get('NZBPR__DNZB_FAILURE')
if failure:
corrupt = False
else:
corrupt = corruption_check()
if corrupt and failure_link:
failure_link = failure_link + '&corrupt=true'
if not (failure or corrupt):
sys.exit(POSTPROCESS_SUCCESS)
if delete and os.path.isdir(os.environ['NZBPP_DIRECTORY']):
rmDir(os.environ['NZBPP_DIRECTORY'])
if not failure_link:
sys.exit(POSTPROCESS_SUCCESS)
nzbcontent, headers = downloadNzb(failure_link)
if not download_another_release:
sys.exit(POSTPROCESS_SUCCESS)
if verbose:
print(headers)
if not nzbcontent or nzbcontent[0:5] != b'<?xml':
print('[INFO] No other releases found')
if verbose and nzbcontent:
print(nzbcontent)
sys.exit(POSTPROCESS_SUCCESS)
print('[INFO] Another release found, adding to queue')
sys.stdout.flush()
# Parsing filename from header
msg = EmailMessage()
msg['Content-Disposition'] = headers.get('Content-Disposition', '')
filename = msg.get_filename()
if verbose:
print('filename: %s' % filename)
# Parsing category from headers
category = headers.get('X-DNZB-Category', '')
if verbose:
print('category: %s' % category)
# Encode nzb-file content into base64
nzbcontent64=standard_b64encode(nzbcontent)
nzbcontent = None
connectToNzbGet()
groupid = queueNzb(filename, category, nzbcontent64)
if groupid == 0:
print('[WARNING] Could not find added nzb-file in the list of downloads')
sys.stdout.flush()
sys.exit(POSTPROCESS_ERROR)
setupDnzbHeaders(groupid, headers)
unpauseGroup(groupid)
main()
# All OK, returning exit status 'POSTPROCESS_SUCCESS' (int <93>) to let NZBGet know
# that our script has successfully completed.
sys.exit(POSTPROCESS_SUCCESS)