PodcastService   B
last analyzed

Complexity

Total Complexity 52

Size/Duplication

Total Lines 378
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 173
c 1
b 0
f 0
dl 0
loc 378
rs 7.44
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 subscribe() 0 21 5
A getChannel() 0 10 3
A injectEpisodes() 0 11 4
A getEpisode() 0 6 2
A unsubscribe() 0 8 2
A __construct() 0 7 1
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 exportToFile() 0 14 1
A importFromFile() 0 27 5
A updateAllChannels() 0 27 6

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->warning("Requested channel $id not found: " . $ex->getMessage());
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->warning("Requested episode $id not found: " . $ex->getMessage());
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{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->warning("Channel $channelId to be unsubscribed not found: " . $ex->getMessage());
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{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->warning("Channel $id to be updated not found: " . $ex->getMessage());
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->warning("RSS feed for the channel {$channel->id} was invalid");
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{changed: int, unchanged: int, failed: int} where each int represent number of channels in that category
218
	 */
219
	public function updateAllChannels(
220
			string $userId, ?float $olderThan = null, bool $force = false, ?callable $progressCallback = null) : array {
221
222
		$result = ['changed' => 0, 'unchanged' => 0, 'failed' => 0];
223
224
		if ($olderThan === null) {
225
			$ids = $this->channelBusinessLayer->findAllIds($userId);
226
		} else {
227
			$ids = $this->channelBusinessLayer->findAllIdsNotUpdatedForHours($userId, $olderThan);
228
		}
229
230
		foreach ($ids as $id) {
231
			$channelResult = $this->updateChannel($id, $userId, null, $force);
232
			if ($channelResult['updated']) {
233
				$result['changed']++;
234
			} elseif ($channelResult['status'] === self::STATUS_OK) {
235
				$result['unchanged']++;
236
			} else {
237
				$result['failed']++;
238
			}
239
240
			if ($progressCallback !== null) {
241
				$progressCallback($channelResult);
242
			}
243
		}
244
245
		return $result;
246
	}
247
248
	/**
249
	 * Reset all the subscribed podcasts of the user
250
	 */
251
	public function resetAll(string $userId) : void {
252
		$this->episodeBusinessLayer->deleteAll($userId);
253
		$this->channelBusinessLayer->deleteAll($userId);
254
	}
255
256
	private function updateEpisodesFromXml(\SimpleXMLElement $items, string $userId, int $channelId) : array {
257
		$episodes = [];
258
		// loop the episodes from XML in reverse order to store them to the DB in chronological order
259
		for ($count = \count($items), $i = $count-1; $i >= 0; --$i) {
260
			if ($items[$i] !== null) {
261
				$episodes[] = $this->episodeBusinessLayer->addOrUpdate($userId, $channelId, $items[$i]);
262
			}
263
		}
264
		// return the episodes in inverted chronological order (newest first)
265
		return \array_reverse($episodes);
266
	}
267
268
	/**
269
	 * export all the podcast channels of a user to an OPML file
270
	 * @param string $userId user
271
	 * @param Folder $userFolder home dir of the user
272
	 * @param string $folderPath target parent folder path
273
	 * @param string $filename target file name
274
	 * @param string $collisionMode action to take on file name collision,
275
	 *								supported values:
276
	 *								- 'overwrite' The existing file will be overwritten
277
	 *								- 'keepboth' The new file is named with a suffix to make it unique
278
	 *								- 'abort' (default) The operation will fail
279
	 * @return string path of the written file
280
	 * @throws \OCP\Files\NotFoundException if the $folderPath is not a valid folder
281
	 * @throws \RuntimeException on name conflict if $collisionMode == 'abort'
282
	 * @throws \OCP\Files\NotPermittedException if the user is not allowed to write to the given folder
283
	 */
284
	public function exportToFile(
285
		string $userId, Folder $userFolder, string $folderPath, string $filename, string $collisionMode='abort') : string {
286
		$targetFolder = FilesUtil::getFolderFromRelativePath($userFolder, $folderPath);
287
288
		$filename = FilesUtil::sanitizeFileName($filename, ['opml']);
289
290
		$file = FilesUtil::createFile($targetFolder, $filename, $collisionMode);
291
292
		$channels = $this->channelBusinessLayer->findAll($userId, SortBy::Name);
293
294
		$content = self::channelsToOpml($channels);
295
		$file->putContent($content);
296
297
		return $userFolder->getRelativePath($file->getPath());
298
	}
299
300
	/**
301
	 * @param PodcastChannel[] $channels
302
	 */
303
	private static function channelsToOpml(array $channels) : string {
304
		$dom = new \DOMDocument('1.0', 'UTF-8');
305
		$dom->formatOutput = true;
306
307
		$rootElem = $dom->createElement('opml');
308
		$rootElem->setAttribute('version', '1.0');
309
		$dom->appendChild($rootElem);
310
311
		$headElem = $dom->createElement('head');
312
		$titleElem = $dom->createElement('title', 'Podcast channels from ownCloud/Nextcloud Music');
313
		$now = new \DateTime();
314
		$dateCreatedElem = $dom->createElement('dateCreated', $now->format(\DateTime::RFC822));
315
		$headElem->appendChild($titleElem);
316
		$headElem->appendChild($dateCreatedElem);
317
		$rootElem->appendChild($headElem);
318
319
		$bodyElem = $dom->createElement('body');
320
		foreach ($channels as $channel) {
321
			$outlineElem = $dom->createElement('outline');
322
			$outlineElem->setAttribute('type', 'rss');
323
			$outlineElem->setAttribute('text', $channel->getTitle());
324
			$outlineElem->setAttribute('title', $channel->getTitle());
325
			$outlineElem->setAttribute('xmlUrl', $channel->getRssUrl());
326
			$outlineElem->setAttribute('htmlUrl', $channel->getLinkUrl());
327
			$bodyElem->appendChild($outlineElem);
328
		}
329
		$rootElem->appendChild($bodyElem);
330
331
		return $dom->saveXML();
332
	}
333
334
	/**
335
	 * import podcast channels from an OPML file
336
	 * @param string $userId user
337
	 * @param Folder $userFolder user home dir
338
	 * @param string $filePath path of the file to import
339
	 * @return array with three keys:
340
	 * 			- 'channels': Array of PodcastChannel objects imported from the file
341
	 * 			- 'not_changed_count': An integer showing the number of channels in the file which were already subscribed by the user
342
	 * 			- 'failed_count': An integer showing the number of entries in the file which were not valid URLs
343
	 * @throws \OCP\Files\NotFoundException if the $filePath is not a valid file
344
	 * @throws \UnexpectedValueException if the $filePath points to a file of unsupported type
345
	 */
346
	public function importFromFile(string $userId, Folder $userFolder, string $filePath, ?callable $progressCallback = null) : array {
347
		$channelUrls = self::parseOpml($userFolder, $filePath);
348
349
		$channels = [];
350
		$existingCount = 0;
351
		$failedCount = 0;
352
353
		foreach ($channelUrls as $rssUrl) {
354
			$channelResult = $this->subscribe($rssUrl, $userId);
355
			if (!empty($channelResult['channel'])) {
356
				$channels[] = $channelResult['channel'];
357
			} else if ($channelResult['status'] == self::STATUS_ALREADY_EXISTS) {
358
				$existingCount++;
359
			} else {
360
				$failedCount++;
361
			}
362
363
			if ($progressCallback !== null) {
364
				$channelResult['rss'] = $rssUrl;
365
				$progressCallback($channelResult);
366
			}
367
		}
368
369
		return [
370
			'channels' => $channels,
371
			'not_changed_count' => $existingCount,
372
			'failed_count' => $failedCount
373
		];
374
	}
375
376
	/**
377
	 * @return string[] RSS URLs
378
	 */
379
	public static function parseOpml(Folder $userFolder, string $filePath) : array {
380
		$rssUrls = [];
381
382
		$file = self::getFile($userFolder, $filePath);
383
		$rootNode = \simplexml_load_string($file->getContent(), \SimpleXMLElement::class, LIBXML_NOCDATA);
384
		if ($rootNode === false) {
385
			throw new \UnexpectedValueException('the file is not in valid OPML format');
386
		}
387
388
		$rssNodes = $rootNode->xpath("/opml/body//outline[@type='rss']");
389
390
		foreach ($rssNodes as $node) {
391
			$rssUrls[] = (string)$node->attributes()['xmlUrl'];
392
		}
393
394
		return $rssUrls;
395
	}
396
397
	/**
398
	 * @throws \OCP\Files\NotFoundException if the $path does not point to a file under the $baseFolder
399
	 */
400
	private static function getFile(Folder $baseFolder, string $path) : File {
401
		$node = $baseFolder->get($path);
402
		if (!($node instanceof File)) {
403
			throw new \OCP\Files\NotFoundException();
404
		}
405
		return $node;
406
	}
407
}
408