|
1
|
|
|
<?php |
|
2
|
|
|
|
|
3
|
|
|
declare(strict_types=1); |
|
4
|
|
|
|
|
5
|
|
|
namespace App\Services\Nzb; |
|
6
|
|
|
|
|
7
|
|
|
use App\Models\Release; |
|
8
|
|
|
use App\Models\Settings; |
|
9
|
|
|
use App\Services\NNTP\NNTPService; |
|
10
|
|
|
use App\Services\PostProcessService; |
|
11
|
|
|
use Blacklight\Nfo; |
|
12
|
|
|
|
|
13
|
|
|
/** |
|
14
|
|
|
* Service for processing NZB contents - extracting NFO files, PAR2 information, |
|
15
|
|
|
* and calculating release completion from NZB files. |
|
16
|
|
|
*/ |
|
17
|
|
|
class NzbContentsService |
|
18
|
|
|
{ |
|
19
|
|
|
protected NzbService $nzbService; |
|
20
|
|
|
|
|
21
|
|
|
protected NzbParserService $parserService; |
|
22
|
|
|
|
|
23
|
|
|
protected NNTPService $nntp; |
|
24
|
|
|
|
|
25
|
|
|
protected Nfo $nfo; |
|
26
|
|
|
|
|
27
|
|
|
protected PostProcessService $postProcessService; |
|
28
|
|
|
|
|
29
|
|
|
protected bool $lookupPar2; |
|
30
|
|
|
|
|
31
|
|
|
protected bool $echoOutput; |
|
32
|
|
|
|
|
33
|
|
|
protected bool $alternateNntp; |
|
34
|
|
|
|
|
35
|
|
|
public function __construct( |
|
36
|
|
|
?NzbService $nzbService = null, |
|
37
|
|
|
?NzbParserService $parserService = null, |
|
38
|
|
|
?NNTPService $nntp = null, |
|
39
|
|
|
?Nfo $nfo = null, |
|
40
|
|
|
?PostProcessService $postProcessService = null |
|
41
|
|
|
) { |
|
42
|
|
|
$this->echoOutput = (bool) config('nntmux.echocli'); |
|
43
|
|
|
$this->nzbService = $nzbService ?? app(NzbService::class); |
|
44
|
|
|
$this->parserService = $parserService ?? app(NzbParserService::class); |
|
45
|
|
|
$this->nntp = $nntp ?? new NNTPService(); |
|
46
|
|
|
$this->nfo = $nfo ?? new Nfo(); |
|
47
|
|
|
$this->postProcessService = $postProcessService ?? app(PostProcessService::class); |
|
48
|
|
|
$this->lookupPar2 = (int) Settings::settingValue('lookuppar2') === 1; |
|
49
|
|
|
$this->alternateNntp = (bool) config('nntmux_nntp.use_alternate_nntp_server'); |
|
50
|
|
|
} |
|
51
|
|
|
|
|
52
|
|
|
/** |
|
53
|
|
|
* Look for an .nfo file in the NZB, download it, verify it, and return the content. |
|
54
|
|
|
* |
|
55
|
|
|
* @param string $guid The release GUID. |
|
56
|
|
|
* @param int $relID The release ID. |
|
57
|
|
|
* @param int $groupID The group ID. |
|
58
|
|
|
* @param string $groupName The group name. |
|
59
|
|
|
* @return string|false The verified NFO content as a string, or false if not found, download failed, or verification failed. |
|
60
|
|
|
* |
|
61
|
|
|
* @throws \Exception If NNTP operations fail. |
|
62
|
|
|
*/ |
|
63
|
|
|
public function getNfoFromNzb(string $guid, int $relID, int $groupID, string $groupName): string|false |
|
64
|
|
|
{ |
|
65
|
|
|
// Step 1: Attempt to find a potential NFO message ID |
|
66
|
|
|
$messageID = $this->parseNzb($guid, $relID, $groupID, true); |
|
67
|
|
|
|
|
68
|
|
|
// If no NFO message ID found |
|
69
|
|
|
if ($messageID === false || ! isset($messageID['id'])) { |
|
70
|
|
|
if ($this->echoOutput) { |
|
71
|
|
|
echo '-'; |
|
72
|
|
|
} |
|
73
|
|
|
// Make sure we set status to NFO_NONFO |
|
74
|
|
|
Release::query()->where('id', $relID)->update(['nfostatus' => Nfo::NFO_NONFO]); |
|
75
|
|
|
|
|
76
|
|
|
return false; |
|
77
|
|
|
} |
|
78
|
|
|
|
|
79
|
|
|
// Step 2: Attempt to download the potential NFO content |
|
80
|
|
|
$fetchedBinary = $this->nntp->getMessages($groupName, $messageID['id'], $this->alternateNntp); |
|
81
|
|
|
|
|
82
|
|
|
// Check if download failed |
|
83
|
|
|
if ($this->nntp->isError($fetchedBinary)) { |
|
84
|
|
|
// NFO download failed, decrement attempts to allow retries |
|
85
|
|
|
Release::query()->where('id', $relID)->decrement('nfostatus'); |
|
86
|
|
|
if ($this->echoOutput) { |
|
87
|
|
|
echo 'f'; |
|
88
|
|
|
} |
|
89
|
|
|
|
|
90
|
|
|
return false; |
|
91
|
|
|
} |
|
92
|
|
|
|
|
93
|
|
|
// Step 3: Verify if the downloaded content is actually an NFO file |
|
94
|
|
|
if ($this->nfo->isNFO($fetchedBinary, $guid)) { |
|
95
|
|
|
// NFO verification successful |
|
96
|
|
|
if ($this->echoOutput) { |
|
97
|
|
|
// Show if it was found via explicit name (+) or potentially hidden (*) |
|
98
|
|
|
echo $messageID['hidden'] === false ? '+' : '*'; |
|
99
|
|
|
} |
|
100
|
|
|
|
|
101
|
|
|
return $fetchedBinary; |
|
102
|
|
|
} |
|
103
|
|
|
|
|
104
|
|
|
// Step 4: Handle verification failure - not a valid NFO |
|
105
|
|
|
if ($this->echoOutput) { |
|
106
|
|
|
echo '-'; |
|
107
|
|
|
} |
|
108
|
|
|
Release::query()->where('id', $relID)->update(['nfostatus' => Nfo::NFO_NONFO]); |
|
109
|
|
|
|
|
110
|
|
|
return false; |
|
111
|
|
|
} |
|
112
|
|
|
|
|
113
|
|
|
/** |
|
114
|
|
|
* Gets the completion from the NZB, optionally looks if there is an NFO/PAR2 file. |
|
115
|
|
|
* |
|
116
|
|
|
* @param string $guid The release GUID. |
|
117
|
|
|
* @param int $relID The release ID. |
|
118
|
|
|
* @param int $groupID The group ID. |
|
119
|
|
|
* @param bool $nfoCheck Whether to specifically look for an NFO file. |
|
120
|
|
|
* @return array|false An array containing NFO message ID and hidden status, or false if not found/error. |
|
121
|
|
|
* |
|
122
|
|
|
* @throws \Exception If NNTP operations fail. |
|
123
|
|
|
*/ |
|
124
|
|
|
public function parseNzb(string $guid, int $relID, int $groupID, bool $nfoCheck = false): bool|array |
|
125
|
|
|
{ |
|
126
|
|
|
$nzbFile = $this->loadNzb($guid); |
|
127
|
|
|
if ($nzbFile === false) { |
|
128
|
|
|
return false; |
|
129
|
|
|
} |
|
130
|
|
|
|
|
131
|
|
|
$messageID = $hiddenID = ''; |
|
|
|
|
|
|
132
|
|
|
$actualParts = $artificialParts = 0; |
|
133
|
|
|
// Initialize foundPAR2 based on settings; if lookupPar2 is false, we don't need to find one. |
|
134
|
|
|
$foundPAR2 = $this->lookupPar2 === false; |
|
135
|
|
|
// Initialize NFO flags based on whether we are checking for NFOs. |
|
136
|
|
|
$foundNFO = $hiddenNFO = $nfoCheck === false; |
|
137
|
|
|
$nfoMessageId = null; // Store potential NFO message ID here |
|
138
|
|
|
|
|
139
|
|
|
foreach ($nzbFile->file as $nzbContents) { |
|
140
|
|
|
$segmentCountInFile = 0; |
|
141
|
|
|
$firstSegmentId = null; // Initialize here for each file |
|
142
|
|
|
foreach ($nzbContents->segments->segment as $segment) { |
|
143
|
|
|
$actualParts++; |
|
144
|
|
|
$segmentCountInFile++; |
|
145
|
|
|
// Store the first segment ID of the current file, potentially useful for NFO/PAR2 |
|
146
|
|
|
if ($segmentCountInFile === 1) { |
|
147
|
|
|
$firstSegmentId = (string) $segment; |
|
148
|
|
|
} |
|
149
|
|
|
} |
|
150
|
|
|
|
|
151
|
|
|
$subject = (string) $nzbContents->attributes()->subject; |
|
|
|
|
|
|
152
|
|
|
|
|
153
|
|
|
// Calculate artificial parts from subject |
|
154
|
|
|
$artificialParts += $this->parserService->extractPartsTotal($subject); |
|
155
|
|
|
|
|
156
|
|
|
// --- NFO Detection --- |
|
157
|
|
|
// Check for explicit NFO files first (with enhanced patterns) |
|
158
|
|
|
if ($nfoCheck && ! $foundNFO && isset($firstSegmentId)) { |
|
159
|
|
|
$nfoDetection = $this->parserService->detectNfoFile($subject); |
|
160
|
|
|
if ($nfoDetection !== false) { |
|
161
|
|
|
$nfoMessageId = ['hidden' => $nfoDetection['hidden'], 'id' => $firstSegmentId, 'priority' => $nfoDetection['priority']]; |
|
162
|
|
|
$foundNFO = true; |
|
163
|
|
|
} |
|
164
|
|
|
} |
|
165
|
|
|
|
|
166
|
|
|
// Check for potential "hidden" NFOs with improved detection |
|
167
|
|
|
// Only consider this if an explicit NFO wasn't found yet |
|
168
|
|
|
if ($nfoCheck && ! $foundNFO && ! $hiddenNFO && isset($firstSegmentId)) { |
|
169
|
|
|
$hiddenNfoDetection = $this->parserService->detectHiddenNfoFile($subject, $segmentCountInFile); |
|
170
|
|
|
if ($hiddenNfoDetection !== false) { |
|
171
|
|
|
$nfoMessageId = ['hidden' => $hiddenNfoDetection['hidden'], 'id' => $firstSegmentId, 'priority' => $hiddenNfoDetection['priority']]; |
|
172
|
|
|
$hiddenNFO = true; |
|
173
|
|
|
} |
|
174
|
|
|
} |
|
175
|
|
|
|
|
176
|
|
|
// --- PAR2 Detection --- |
|
177
|
|
|
// Look specifically for the .par2 index file (often small, but not always 1/1) |
|
178
|
|
|
if ($this->lookupPar2 && ! $foundPAR2 && isset($firstSegmentId) && $this->parserService->detectPar2IndexFile($subject)) { |
|
179
|
|
|
// Attempt to parse the PAR2 file using its first segment ID |
|
180
|
|
|
if ($this->postProcessService->parsePAR2($firstSegmentId, $relID, $groupID, $this->nntp, 1) === true) { |
|
181
|
|
|
Release::query()->where('id', $relID)->update(['proc_par2' => 1]); |
|
182
|
|
|
$foundPAR2 = true; |
|
183
|
|
|
} |
|
184
|
|
|
} |
|
185
|
|
|
} // End foreach $nzbFile->file |
|
186
|
|
|
|
|
187
|
|
|
// Calculate completion |
|
188
|
|
|
$completion = $this->calculateCompletion($actualParts, $artificialParts); |
|
189
|
|
|
|
|
190
|
|
|
Release::query()->where('id', $relID)->update(['completion' => $completion]); |
|
191
|
|
|
|
|
192
|
|
|
// If NFO check was requested, return the found message ID (prioritizing explicit) |
|
193
|
|
|
if ($nfoCheck && $nfoMessageId !== null && isset($nfoMessageId['id']) && \strlen($nfoMessageId['id']) > 1) { |
|
194
|
|
|
return $nfoMessageId; |
|
195
|
|
|
} |
|
196
|
|
|
|
|
197
|
|
|
// If NFO check was requested but nothing suitable was found |
|
198
|
|
|
if ($nfoCheck && $nfoMessageId === null) { |
|
199
|
|
|
// Update status to indicate no NFO was found in the NZB structure |
|
200
|
|
|
Release::query()->where('id', $relID)->update(['nfostatus' => Nfo::NFO_NONFO]); |
|
201
|
|
|
|
|
202
|
|
|
return false; |
|
203
|
|
|
} |
|
204
|
|
|
|
|
205
|
|
|
// If NFO check was not requested, the function's primary goal might be just completion/PAR2 update. |
|
206
|
|
|
return false; |
|
207
|
|
|
} |
|
208
|
|
|
|
|
209
|
|
|
/** |
|
210
|
|
|
* Loads and parses an NZB file based on a GUID. |
|
211
|
|
|
* |
|
212
|
|
|
* @param string $guid The release GUID to locate the NZB file |
|
213
|
|
|
* @return \SimpleXMLElement|false The parsed NZB file as SimpleXMLElement or false on failure |
|
214
|
|
|
*/ |
|
215
|
|
|
public function loadNzb(string $guid): \SimpleXMLElement|false |
|
216
|
|
|
{ |
|
217
|
|
|
// Fetch the NZB file path using the GUID |
|
218
|
|
|
$nzbPath = $this->nzbService->nzbPath($guid); |
|
219
|
|
|
if ($nzbPath === false) { |
|
220
|
|
|
return false; |
|
221
|
|
|
} |
|
222
|
|
|
|
|
223
|
|
|
// Attempt to decompress the NZB file |
|
224
|
|
|
$nzbContents = unzipGzipFile($nzbPath); |
|
225
|
|
|
if (empty($nzbContents)) { |
|
226
|
|
|
if ($this->echoOutput) { |
|
227
|
|
|
$perms = fileperms($nzbPath); |
|
228
|
|
|
$formattedPerms = $perms !== false ? decoct($perms & 0777) : 'unknown'; |
|
229
|
|
|
echo PHP_EOL."Unable to decompress: {$nzbPath} - {$formattedPerms} - may have bad file permissions, skipping.".PHP_EOL; |
|
230
|
|
|
} |
|
231
|
|
|
|
|
232
|
|
|
return false; |
|
233
|
|
|
} |
|
234
|
|
|
|
|
235
|
|
|
return $this->parserService->parseNzbXml($nzbContents, $this->echoOutput, $guid); |
|
236
|
|
|
} |
|
237
|
|
|
|
|
238
|
|
|
/** |
|
239
|
|
|
* Attempts to get the release name from a par2 file. |
|
240
|
|
|
* |
|
241
|
|
|
* @throws \Exception |
|
242
|
|
|
*/ |
|
243
|
|
|
public function checkPar2(string $guid, int $relID, int $groupID, int $nameStatus, int $show): bool |
|
244
|
|
|
{ |
|
245
|
|
|
$nzbFile = $this->loadNzb($guid); |
|
246
|
|
|
if ($nzbFile !== false) { |
|
247
|
|
|
foreach ($nzbFile->file as $nzbContents) { |
|
248
|
|
|
if ($nameStatus === 1 |
|
249
|
|
|
&& $this->postProcessService->parsePAR2((string) $nzbContents->segments->segment, $relID, $groupID, $this->nntp, $show) |
|
250
|
|
|
&& preg_match('/\.(par[2" ]|\d{2,3}").+\(1\/1\)/i', (string) $nzbContents->attributes()->subject) |
|
251
|
|
|
) { |
|
252
|
|
|
Release::query()->where('id', $relID)->update(['proc_par2' => 1]); |
|
253
|
|
|
|
|
254
|
|
|
return true; |
|
255
|
|
|
} |
|
256
|
|
|
} |
|
257
|
|
|
} |
|
258
|
|
|
|
|
259
|
|
|
if ($nameStatus === 1) { |
|
260
|
|
|
Release::query()->where('id', $relID)->update(['proc_par2' => 1]); |
|
261
|
|
|
} |
|
262
|
|
|
|
|
263
|
|
|
return false; |
|
264
|
|
|
} |
|
265
|
|
|
|
|
266
|
|
|
/** |
|
267
|
|
|
* Calculate the completion percentage from actual and expected parts. |
|
268
|
|
|
* |
|
269
|
|
|
* @param int $actualParts The actual number of parts found |
|
270
|
|
|
* @param int $artificialParts The expected number of parts from subject |
|
271
|
|
|
* @return float The completion percentage (0-100) |
|
272
|
|
|
*/ |
|
273
|
|
|
protected function calculateCompletion(int $actualParts, int $artificialParts): float |
|
274
|
|
|
{ |
|
275
|
|
|
// Avoid division by zero and handle cases where parts info might be missing/incorrect |
|
276
|
|
|
if ($artificialParts > 0) { |
|
277
|
|
|
return min(100, ($actualParts / $artificialParts) * 100); |
|
278
|
|
|
} elseif ($actualParts > 0) { |
|
279
|
|
|
// If artificial parts couldn't be determined, but we have actual parts, |
|
280
|
|
|
// we can't calculate completion accurately based on subject. |
|
281
|
|
|
return 0; |
|
282
|
|
|
} |
|
283
|
|
|
|
|
284
|
|
|
// If both are zero (e.g., empty NZB or parsing issue), completion is 0. |
|
285
|
|
|
return 0; |
|
286
|
|
|
} |
|
287
|
|
|
|
|
288
|
|
|
/** |
|
289
|
|
|
* Set NNTP service instance. |
|
290
|
|
|
*/ |
|
291
|
|
|
public function setNntp(NNTPService $nntp): void |
|
292
|
|
|
{ |
|
293
|
|
|
$this->nntp = $nntp; |
|
294
|
|
|
} |
|
295
|
|
|
|
|
296
|
|
|
/** |
|
297
|
|
|
* Set NFO handler instance. |
|
298
|
|
|
*/ |
|
299
|
|
|
public function setNfo(Nfo $nfo): void |
|
300
|
|
|
{ |
|
301
|
|
|
$this->nfo = $nfo; |
|
302
|
|
|
} |
|
303
|
|
|
|
|
304
|
|
|
/** |
|
305
|
|
|
* Set echo output setting. |
|
306
|
|
|
*/ |
|
307
|
|
|
public function setEchoOutput(bool $echo): void |
|
308
|
|
|
{ |
|
309
|
|
|
$this->echoOutput = $echo; |
|
310
|
|
|
} |
|
311
|
|
|
|
|
312
|
|
|
/** |
|
313
|
|
|
* Get echo output setting. |
|
314
|
|
|
*/ |
|
315
|
|
|
public function getEchoOutput(): bool |
|
316
|
|
|
{ |
|
317
|
|
|
return $this->echoOutput; |
|
318
|
|
|
} |
|
319
|
|
|
} |
|
320
|
|
|
|
|
321
|
|
|
|