Passed
Push — master ( f358a5...b5f949 )
by Pauli
03:17
created

PodcastService   B

Complexity

Total Complexity 52

Size/Duplication

Total Lines 379
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 173
dl 0
loc 379
rs 7.44
c 0
b 0
f 0
wmc 52

17 Methods

Rating   Name   Duplication   Size   Complexity  
A getLatestEpisodes() 0 2 1
B updateChannel() 0 42 9
A getAllChannels() 0 8 2
A resetAll() 0 3 1
A updateEpisodesFromXml() 0 10 3
A getFile() 0 6 2
A channelsToOpml() 0 29 2
A parseOpml() 0 16 3
A subscribe() 0 21 5
A exportToFile() 0 14 1
A getChannel() 0 10 3
A importFromFile() 0 27 5
A injectEpisodes() 0 11 4
A updateAllChannels() 0 27 6
A getEpisode() 0 6 2
A unsubscribe() 0 8 2
A __construct() 0 7 1

How to fix   Complexity   

Complex Class

Complex classes like PodcastService often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use PodcastService, and based on these observations, apply Extract Interface, too.

1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2021 - 2025
11
 */
12
13
namespace OCA\Music\Service;
14
15
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
16
use OCA\Music\AppFramework\Core\Logger;
17
use OCA\Music\BusinessLayer\PodcastChannelBusinessLayer;
18
use OCA\Music\BusinessLayer\PodcastEpisodeBusinessLayer;
19
use OCA\Music\Db\PodcastChannel;
20
use OCA\Music\Db\PodcastEpisode;
21
use OCA\Music\Db\SortBy;
22
use OCA\Music\Utility\ArrayUtil;
23
use OCA\Music\Utility\FilesUtil;
24
use OCA\Music\Utility\HttpUtil;
25
use OCP\Files\File;
26
use OCP\Files\Folder;
27
28
class PodcastService {
29
	private PodcastChannelBusinessLayer $channelBusinessLayer;
30
	private PodcastEpisodeBusinessLayer $episodeBusinessLayer;
31
	private Logger $logger;
32
33
	public const STATUS_OK = 0;
34
	public const STATUS_INVALID_URL = 1;
35
	public const STATUS_INVALID_RSS = 2;
36
	public const STATUS_ALREADY_EXISTS = 3;
37
	public const STATUS_NOT_FOUND = 4;
38
39
	public function __construct(
40
			PodcastChannelBusinessLayer $channelBusinessLayer,
41
			PodcastEpisodeBusinessLayer $episodeBusinessLayer,
42
			Logger $logger) {
43
		$this->channelBusinessLayer = $channelBusinessLayer;
44
		$this->episodeBusinessLayer = $episodeBusinessLayer;
45
		$this->logger = $logger;
46
	}
47
48
	/**
49
	 * Get a specified podcast channel of a user
50
	 */
51
	public function getChannel(int $id, string $userId, bool $includeEpisodes) : ?PodcastChannel {
52
		try {
53
			$channel = $this->channelBusinessLayer->find($id, $userId);
54
			if ($includeEpisodes) {
55
				$channel->setEpisodes($this->episodeBusinessLayer->findAllByChannel($id, $userId));
56
			}
57
			return $channel;
58
		} catch (BusinessLayerException $ex) {
59
			$this->logger->log("Requested channel $id not found: " . $ex->getMessage(), 'warn');
60
			return null;
61
		}
62
	}
63
64
	/**
65
	 * Get all podcast channels of a user
66
	 * @return PodcastChannel[]
67
	 */
68
	public function getAllChannels(string $userId, bool $includeEpisodes) : array {
69
		$channels = $this->channelBusinessLayer->findAll($userId, SortBy::Name);
70
71
		if ($includeEpisodes) {
72
			$this->injectEpisodes($channels, $userId, /*$allChannelsIncluded=*/ true);
73
		}
74
75
		return $channels;
76
	}
77
78
	/**
79
	 * Get a specified podcast episode of a user
80
	 */
81
	public function getEpisode(int $id, string $userId) : ?PodcastEpisode {
82
		try {
83
			return $this->episodeBusinessLayer->find($id, $userId);
84
		} catch (BusinessLayerException $ex) {
85
			$this->logger->log("Requested episode $id not found: " . $ex->getMessage(), 'warn');
86
			return null;
87
		}
88
	}
89
90
	/**
91
	 * Get the latest added podcast episodes
92
	 * @return PodcastEpisode[]
93
	 */
94
	public function getLatestEpisodes(string $userId, int $maxCount) : array {
95
		return $this->episodeBusinessLayer->findAll($userId, SortBy::Newest, $maxCount);
96
	}
97
98
	/**
99
	 * Inject episodes to the given podcast channels
100
	 * @param PodcastChannel[] $channels input/output
101
	 * @param bool $allChannelsIncluded Set this to true if $channels contains all the podcasts of the user.
102
	 *									This helps in optimizing the DB query.
103
	 */
104
	public function injectEpisodes(array &$channels, string $userId, bool $allChannelsIncluded) : void {
105
		if ($allChannelsIncluded || \count($channels) >= $this->channelBusinessLayer::MAX_SQL_ARGS) {
106
			$episodes = $this->episodeBusinessLayer->findAll($userId, SortBy::Newest);
107
		} else {
108
			$episodes = $this->episodeBusinessLayer->findAllByChannel(ArrayUtil::extractIds($channels), $userId);
109
		}
110
111
		$episodesPerChannel = ArrayUtil::groupBy($episodes, 'getChannelId');
112
113
		foreach ($channels as &$channel) {
114
			$channel->setEpisodes($episodesPerChannel[$channel->getId()] ?? []);
115
		}
116
	}
117
118
	/**
119
	 * Add a followed podcast for a user from an RSS feed
120
	 * @return array like ['status' => int, 'channel' => ?PodcastChannel]
121
	 */
122
	public function subscribe(string $url, string $userId) : array {
123
		$content = HttpUtil::loadFromUrl($url)['content'];
124
		if ($content === false) {
125
			return ['status' => self::STATUS_INVALID_URL, 'channel' => null];
126
		}
127
128
		$xmlTree = \simplexml_load_string($content, \SimpleXMLElement::class, LIBXML_NOCDATA);
129
		if ($xmlTree === false || !$xmlTree->channel) {
130
			return ['status' => self::STATUS_INVALID_RSS, 'channel' => null];
131
		}
132
133
		try {
134
			$channel = $this->channelBusinessLayer->create($userId, $url, $content, $xmlTree->channel);
135
		} catch (\OCA\Music\AppFramework\Db\UniqueConstraintViolationException $ex) {
136
			return ['status' => self::STATUS_ALREADY_EXISTS, 'channel' => null];
137
		}
138
139
		$episodes = $this->updateEpisodesFromXml($xmlTree->channel->item, $userId, $channel->getId());
140
		$channel->setEpisodes($episodes);
141
142
		return ['status' => self::STATUS_OK, 'channel' => $channel];
143
	}
144
145
	/**
146
	 * Deletes a podcast channel from a user
147
	 * @return int status code
148
	 */
149
	public function unsubscribe(int $channelId, string $userId) : int {
150
		try {
151
			$this->channelBusinessLayer->delete($channelId, $userId); // throws if not found
152
			$this->episodeBusinessLayer->deleteByChannel($channelId, $userId); // does not throw
153
			return self::STATUS_OK;
154
		} catch (BusinessLayerException $ex) {
155
			$this->logger->log("Channel $channelId to be unsubscribed not found: " . $ex->getMessage(), 'warn');
156
			return self::STATUS_NOT_FOUND;
157
		}
158
	}
159
160
	/**
161
	 * Check a single podcast channel for updates
162
	 * @param ?string $prevHash Previous content hash known by the client. If given, the result will tell
163
	 *							if the channel content has updated from this state. If omitted, the result
164
	 *							will tell if the channel changed from its previous server-known state.
165
	 * @param bool $force Value true will cause the channel to be parsed and updated to the database even
166
	 *					in case the RSS hasn't been changed at all since the previous update. This might be
167
	 *					useful during the development or if the previous update was unexpectedly aborted.
168
	 * @return array like ['status' => int, 'updated' => bool, 'channel' => ?PodcastChannel]
169
	 */
170
	public function updateChannel(int $id, string $userId, ?string $prevHash = null, bool $force = false) : array {
171
		$updated = false;
172
		$status = self::STATUS_OK;
173
174
		try {
175
			$channel = $this->channelBusinessLayer->find($id, $userId);
176
		} catch (BusinessLayerException $ex) {
177
			$this->logger->log("Channel $id to be updated not found: " . $ex->getMessage(), 'warn');
178
			$status = self::STATUS_NOT_FOUND;
179
			$channel = null;
180
		}
181
182
		if ($channel !== null) {
183
			$xmlTree = null;
184
			$content = HttpUtil::loadFromUrl($channel->getRssUrl())['content'];
185
			if ($content === false) {
186
				$status = self::STATUS_INVALID_URL;
187
			} else {
188
				$xmlTree = \simplexml_load_string($content, \SimpleXMLElement::class, LIBXML_NOCDATA);
189
			}
190
191
			if (!$xmlTree || !$xmlTree->channel) {
192
				$this->logger->log("RSS feed for the channel {$channel->id} was invalid", 'warn');
193
				$this->channelBusinessLayer->markUpdateChecked($channel);
194
				$status = self::STATUS_INVALID_RSS;
195
			} else if ($this->channelBusinessLayer->updateChannel($channel, $content, $xmlTree->channel, $force)) {
196
				// update the episodes too if channel content has actually changed or update is forced
197
				$episodes = $this->updateEpisodesFromXml($xmlTree->channel->item, $userId, $id);
198
				$channel->setEpisodes($episodes);
199
				$this->episodeBusinessLayer->deleteByChannelExcluding($id, ArrayUtil::extractIds($episodes), $userId);
200
				$updated = true;
201
			} else if ($prevHash !== null && $prevHash !== $channel->getContentHash()) {
202
				// the channel content is not new for the server but it is still new for the client
203
				$channel->setEpisodes($this->episodeBusinessLayer->findAllByChannel($id, $userId));
204
				$updated = true;
205
			}
206
		}
207
208
		return [
209
			'status' => $status,
210
			'updated' => $updated,
211
			'channel' => $channel
212
		];
213
	}
214
215
	/**
216
	 * Check updates for all channels of the user, one-by-one
217
	 * @return array like ['changed' => int, 'unchanged' => int, 'failed' => int]
218
	 *			where each int represent number of channels in that category
219
	 */
220
	public function updateAllChannels(
221
			string $userId, ?float $olderThan = null, bool $force = false, ?callable $progressCallback = null) : array {
222
223
		$result = ['changed' => 0, 'unchanged' => 0, 'failed' => 0];
224
225
		if ($olderThan === null) {
226
			$ids = $this->channelBusinessLayer->findAllIds($userId);
227
		} else {
228
			$ids = $this->channelBusinessLayer->findAllIdsNotUpdatedForHours($userId, $olderThan);
229
		}
230
231
		foreach ($ids as $id) {
232
			$channelResult = $this->updateChannel($id, $userId, null, $force);
233
			if ($channelResult['updated']) {
234
				$result['changed']++;
235
			} elseif ($channelResult['status'] === self::STATUS_OK) {
236
				$result['unchanged']++;
237
			} else {
238
				$result['failed']++;
239
			}
240
241
			if ($progressCallback !== null) {
242
				$progressCallback($channelResult);
243
			}
244
		}
245
246
		return $result;
247
	}
248
249
	/**
250
	 * Reset all the subscribed podcasts of the user
251
	 */
252
	public function resetAll(string $userId) : void {
253
		$this->episodeBusinessLayer->deleteAll($userId);
254
		$this->channelBusinessLayer->deleteAll($userId);
255
	}
256
257
	private function updateEpisodesFromXml(\SimpleXMLElement $items, string $userId, int $channelId) : array {
258
		$episodes = [];
259
		// loop the episodes from XML in reverse order to store them to the DB in chronological order
260
		for ($count = \count($items), $i = $count-1; $i >= 0; --$i) {
261
			if ($items[$i] !== null) {
262
				$episodes[] = $this->episodeBusinessLayer->addOrUpdate($userId, $channelId, $items[$i]);
263
			}
264
		}
265
		// return the episodes in inverted chronological order (newest first)
266
		return \array_reverse($episodes);
267
	}
268
269
	/**
270
	 * export all the podcast channels of a user to an OPML file
271
	 * @param string $userId user
272
	 * @param Folder $userFolder home dir of the user
273
	 * @param string $folderPath target parent folder path
274
	 * @param string $filename target file name
275
	 * @param string $collisionMode action to take on file name collision,
276
	 *								supported values:
277
	 *								- 'overwrite' The existing file will be overwritten
278
	 *								- 'keepboth' The new file is named with a suffix to make it unique
279
	 *								- 'abort' (default) The operation will fail
280
	 * @return string path of the written file
281
	 * @throws \OCP\Files\NotFoundException if the $folderPath is not a valid folder
282
	 * @throws \RuntimeException on name conflict if $collisionMode == 'abort'
283
	 * @throws \OCP\Files\NotPermittedException if the user is not allowed to write to the given folder
284
	 */
285
	public function exportToFile(
286
		string $userId, Folder $userFolder, string $folderPath, string $filename, string $collisionMode='abort') : string {
287
		$targetFolder = FilesUtil::getFolderFromRelativePath($userFolder, $folderPath);
288
289
		$filename = FilesUtil::sanitizeFileName($filename, ['opml']);
290
291
		$file = FilesUtil::createFile($targetFolder, $filename, $collisionMode);
292
293
		$channels = $this->channelBusinessLayer->findAll($userId, SortBy::Name);
294
295
		$content = self::channelsToOpml($channels);
296
		$file->putContent($content);
297
298
		return $userFolder->getRelativePath($file->getPath());
299
	}
300
301
	/**
302
	 * @param PodcastChannel[] $channels
303
	 */
304
	private static function channelsToOpml(array $channels) : string {
305
		$dom = new \DOMDocument('1.0', 'UTF-8');
306
		$dom->formatOutput = true;
307
308
		$rootElem = $dom->createElement('opml');
309
		$rootElem->setAttribute('version', '1.0');
310
		$dom->appendChild($rootElem);
311
312
		$headElem = $dom->createElement('head');
313
		$titleElem = $dom->createElement('title', 'Podcast channels from ownCloud/Nextcloud Music');
314
		$now = new \DateTime();
315
		$dateCreatedElem = $dom->createElement('dateCreated', $now->format(\DateTime::RFC822));
316
		$headElem->appendChild($titleElem);
317
		$headElem->appendChild($dateCreatedElem);
318
		$rootElem->appendChild($headElem);
319
320
		$bodyElem = $dom->createElement('body');
321
		foreach ($channels as $channel) {
322
			$outlineElem = $dom->createElement('outline');
323
			$outlineElem->setAttribute('type', 'rss');
324
			$outlineElem->setAttribute('text', $channel->getTitle());
325
			$outlineElem->setAttribute('title', $channel->getTitle());
326
			$outlineElem->setAttribute('xmlUrl', $channel->getRssUrl());
327
			$outlineElem->setAttribute('htmlUrl', $channel->getLinkUrl());
328
			$bodyElem->appendChild($outlineElem);
329
		}
330
		$rootElem->appendChild($bodyElem);
331
332
		return $dom->saveXML();
333
	}
334
335
	/**
336
	 * import podcast channels from an OPML file
337
	 * @param string $userId user
338
	 * @param Folder $userFolder user home dir
339
	 * @param string $filePath path of the file to import
340
	 * @return array with three keys:
341
	 * 			- 'channels': Array of PodcastChannel objects imported from the file
342
	 * 			- 'not_changed_count': An integer showing the number of channels in the file which were already subscribed by the user
343
	 * 			- 'failed_count': An integer showing the number of entries in the file which were not valid URLs
344
	 * @throws \OCP\Files\NotFoundException if the $filePath is not a valid file
345
	 * @throws \UnexpectedValueException if the $filePath points to a file of unsupported type
346
	 */
347
	public function importFromFile(string $userId, Folder $userFolder, string $filePath, ?callable $progressCallback = null) : array {
348
		$channelUrls = self::parseOpml($userFolder, $filePath);
349
350
		$channels = [];
351
		$existingCount = 0;
352
		$failedCount = 0;
353
354
		foreach ($channelUrls as $rssUrl) {
355
			$channelResult = $this->subscribe($rssUrl, $userId);
356
			if (!empty($channelResult['channel'])) {
357
				$channels[] = $channelResult['channel'];
358
			} else if ($channelResult['status'] == self::STATUS_ALREADY_EXISTS) {
359
				$existingCount++;
360
			} else {
361
				$failedCount++;
362
			}
363
364
			if ($progressCallback !== null) {
365
				$channelResult['rss'] = $rssUrl;
366
				$progressCallback($channelResult);
367
			}
368
		}
369
370
		return [
371
			'channels' => $channels,
372
			'not_changed_count' => $existingCount,
373
			'failed_count' => $failedCount
374
		];
375
	}
376
377
	/**
378
	 * @return string[] RSS URLs
379
	 */
380
	public static function parseOpml(Folder $userFolder, string $filePath) : array {
381
		$rssUrls = [];
382
383
		$file = self::getFile($userFolder, $filePath);
384
		$rootNode = \simplexml_load_string($file->getContent(), \SimpleXMLElement::class, LIBXML_NOCDATA);
385
		if ($rootNode === false) {
386
			throw new \UnexpectedValueException('the file is not in valid OPML format');
387
		}
388
389
		$rssNodes = $rootNode->xpath("/opml/body//outline[@type='rss']");
390
391
		foreach ($rssNodes as $node) {
392
			$rssUrls[] = (string)$node->attributes()['xmlUrl'];
393
		}
394
395
		return $rssUrls;
396
	}
397
398
	/**
399
	 * @throws \OCP\Files\NotFoundException if the $path does not point to a file under the $baseFolder
400
	 */
401
	private static function getFile(Folder $baseFolder, string $path) : File {
402
		$node = $baseFolder->get($path);
403
		if (!($node instanceof File)) {
404
			throw new \OCP\Files\NotFoundException();
405
		}
406
		return $node;
407
	}
408
}
409