Passed
Pull Request — master (#888)
by Robin
02:38
created

PodcastService   A

Complexity

Total Complexity 41

Size/Duplication

Total Lines 255
Duplicated Lines 0 %

Importance

Changes 10
Bugs 0 Features 0
Metric Value
wmc 41
eloc 117
c 10
b 0
f 0
dl 0
loc 255
rs 9.1199

13 Methods

Rating   Name   Duplication   Size   Complexity  
A getChannel() 0 10 3
A getAllChannels() 0 8 2
A __construct() 0 7 1
A getEpisode() 0 6 2
A updateAllChannels() 0 27 6
A updateEpisodesFromXml() 0 9 3
A subscribe() 0 21 5
A getLatestEpisodes() 0 2 1
A injectEpisodes() 0 14 5
A resetAll() 0 3 1
A unsubscribe() 0 8 2
B updateChannel() 0 41 9
A fetchUrl() 0 9 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
11
 */
12
13
namespace OCA\Music\Utility;
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
23
24
class PodcastService {
25
	private $channelBusinessLayer;
26
	private $episodeBusinessLayer;
27
	private $logger;
28
29
	public const STATUS_OK = 0;
30
	public const STATUS_INVALID_URL = 1;
31
	public const STATUS_INVALID_RSS = 2;
32
	public const STATUS_ALREADY_EXISTS = 3;
33
	public const STATUS_NOT_FOUND = 4;
34
35
	public function __construct(
36
			PodcastChannelBusinessLayer $channelBusinessLayer,
37
			PodcastEpisodeBusinessLayer $episodeBusinessLayer,
38
			Logger $logger) {
39
		$this->channelBusinessLayer = $channelBusinessLayer;
40
		$this->episodeBusinessLayer = $episodeBusinessLayer;
41
		$this->logger = $logger;
42
	}
43
44
	/**
45
	 * Get a specified podcast channel of a user
46
	 */
47
	public function getChannel(int $id, string $userId, bool $includeEpisodes) : ?PodcastChannel {
48
		try {
49
			$channel = $this->channelBusinessLayer->find($id, $userId);
50
			if ($includeEpisodes) {
51
				$channel->setEpisodes($this->episodeBusinessLayer->findAllByChannel($id, $userId));
52
			}
53
			return $channel;
54
		} catch (BusinessLayerException $ex) {
55
			$this->logger->log("Requested channel $id not found: " . $ex->getMessage(), 'warn');
56
			return null;
57
		}
58
	}
59
60
	/**
61
	 * Get all podcast channels of a user
62
	 * @return PodcastChannel[]
63
	 */
64
	public function getAllChannels(string $userId, bool $includeEpisodes) : array {
65
		$channels = $this->channelBusinessLayer->findAll($userId, SortBy::Name);
66
67
		if ($includeEpisodes) {
68
			$this->injectEpisodes($channels, $userId, /*$allChannelsIncluded=*/ true);
69
		}
70
71
		return $channels;
72
	}
73
74
	/**
75
	 * Get a specified podcast episode of a user
76
	 */
77
	public function getEpisode(int $id, string $userId) : ?PodcastEpisode {
78
		try {
79
			return $this->episodeBusinessLayer->find($id, $userId);
80
		} catch (BusinessLayerException $ex) {
81
			$this->logger->log("Requested episode $id not found: " . $ex->getMessage(), 'warn');
82
			return null;
83
		}
84
	}
85
86
	/**
87
	 * Get the latest added podcast episodes
88
	 * @return PodcastEpisode[]
89
	 */
90
	public function getLatestEpisodes(string $userId, int $maxCount) : array {
91
		return $this->episodeBusinessLayer->findAll($userId, SortBy::Newest, $maxCount);
92
	}
93
94
	/**
95
	 * Inject episodes to the given podcast channels
96
	 * @param PodcastChannel[] $channels input/output
97
	 * @param bool $allChannelsIncluded Set this to true if $channels contains all the podcasts of the user.
98
	 *									This helps in optimizing the DB query.
99
	 */
100
	public function injectEpisodes(array &$channels, string $userId, bool $allChannelsIncluded) : void {
101
		if ($allChannelsIncluded || \count($channels) > 999) {
102
			$episodes = $this->episodeBusinessLayer->findAll($userId, SortBy::Newest);
103
		} else {
104
			$episodes = $this->episodeBusinessLayer->findAllByChannel(Util::extractIds($channels), $userId);
105
		}
106
107
		$episodesPerChannel = [];
108
		foreach ($episodes as $episode) {
109
			$episodesPerChannel[$episode->getChannelId()][] = $episode;
110
		}
111
112
		foreach ($channels as &$channel) {
113
			$channel->setEpisodes($episodesPerChannel[$channel->getId()] ?? []);
114
		}
115
	}
116
117
	/**
118
	 * Add a followed podcast for a user from an RSS feed
119
	 * @return array like ['status' => int, 'channel' => ?PodcastChannel]
120
	 */
121
	public function subscribe(string $url, string $userId) : array {
122
		$content = self::fetchUrl($url);
123
		if ($content === false) {
124
			return ['status' => self::STATUS_INVALID_URL, 'channel' => null];
125
		}
126
127
		$xmlTree = \simplexml_load_string($content, \SimpleXMLElement::class, LIBXML_NOCDATA);
128
		if ($xmlTree === false || !$xmlTree->channel) {
129
			return ['status' => self::STATUS_INVALID_RSS, 'channel' => null];
130
		}
131
132
		try {
133
			$channel = $this->channelBusinessLayer->create($userId, $url, $content, $xmlTree->channel);
134
		} catch (\OCA\Music\AppFramework\Db\UniqueConstraintViolationException $ex) {
135
			return ['status' => self::STATUS_ALREADY_EXISTS, 'channel' => null];
136
		}
137
138
		$episodes = $this->updateEpisodesFromXml($xmlTree->channel->item, $userId, $channel->getId());
139
		$channel->setEpisodes(\array_reverse($episodes));
140
141
		return ['status' => self::STATUS_OK, 'channel' => $channel];
142
	}
143
144
	/**
145
	 * Deletes a podcast channel from a user
146
	 * @return int status code
147
	 */
148
	public function unsubscribe(int $channelId, string $userId) : int {
149
		try {
150
			$this->channelBusinessLayer->delete($channelId, $userId); // throws if not found
151
			$this->episodeBusinessLayer->deleteByChannel($channelId, $userId); // does not throw
152
			return self::STATUS_OK;
153
		} catch (BusinessLayerException $ex) {
154
			$this->logger->log("Channel $channelId to be unsubscirbed not found: " . $ex->getMessage(), 'warn');
155
			return self::STATUS_NOT_FOUND;
156
		}
157
	}
158
159
	/**
160
	 * Check a single podcast channel for updates
161
	 * @param ?string $prevHash Previous content hash known by the client. If given, the result will tell
162
	 *							if the channel content has updated from this state. If omitted, the result
163
	 *							will tell if the channel changed from its previous server-known state.
164
	 * @param bool $force Value true will cause the channel to be parsed and updated to the database even
165
	 *					in case the RSS hasn't been changed at all since the previous update. This might be
166
	 *					useful during the development or if the previous update was unexpectedly aborted.
167
	 * @return array like ['status' => int, 'updated' => bool, 'channel' => ?PodcastChannel]
168
	 */
169
	public function updateChannel(int $id, string $userId, ?string $prevHash = null, bool $force = false) : array {
170
		$updated = false;
171
		$status = self::STATUS_OK;
172
173
		try {
174
			$channel = $this->channelBusinessLayer->find($id, $userId);
175
		} catch (BusinessLayerException $ex) {
176
			$this->logger->log("Channel $id to be updated not found: " . $ex->getMessage(), 'warn');
177
			$status = self::STATUS_NOT_FOUND;
178
			$channel = null;
179
		}
180
181
		if ($channel !== null) {
182
			$xmlTree = null;
183
			$content = self::fetchUrl($channel->getRssUrl());
184
			if ($content === null) {
185
				$status = self::STATUS_INVALID_URL;
186
			} else {
187
				$xmlTree = \simplexml_load_string($content, \SimpleXMLElement::class, LIBXML_NOCDATA);
188
			}
189
190
			if (!$xmlTree || !$xmlTree->channel) {
191
				$this->logger->log("RSS feed for the chanenl {$channel->id} was invalid", 'warn');
192
				$status = self::STATUS_INVALID_RSS;
193
			} else if ($this->channelBusinessLayer->updateChannel($channel, $content, $xmlTree->channel, $force)) {
194
				// update the episodes too if channel content has actually changed or update is forced
195
				$episodes = $this->updateEpisodesFromXml($xmlTree->channel->item, $userId, $id);
196
				$channel->setEpisodes($episodes);
197
				$this->episodeBusinessLayer->deleteByChannelExcluding($id, Util::extractIds($episodes), $userId);
198
				$updated = true;
199
			} else if ($prevHash !== null && $prevHash !== $channel->getContentHash()) {
200
				// the channel content is not new for the server but it is still new for the client
201
				$channel->setEpisodes($this->episodeBusinessLayer->findAllByChannel($id, $userId));
202
				$updated = true;
203
			}
204
		}
205
206
		return [
207
			'status' => $status,
208
			'updated' => $updated,
209
			'channel' => $channel
210
		];
211
	}
212
213
	/**
214
	 * Check updates for all chanenls of the user, one-by-one
215
	 * @return array like ['changed' => int, 'unchanged' => int, 'failed' => int]
216
	 *			where each int represent number of channels in that category
217
	 */
218
	public function updateAllChannels(
219
			string $userId, ?float $olderThan = null, bool $force = false, ?callable $progressCallback = null) : array {
220
221
		$result = ['changed' => 0, 'unchanged' => 0, 'failed' => 0];
222
223
		if ($olderThan === null) {
224
			$ids = $this->channelBusinessLayer->findAllIds($userId);
225
		} else {
226
			$ids = $this->channelBusinessLayer->findAllIdsNotUpdatedForHours($userId, $olderThan);
227
		}
228
229
		foreach ($ids as $id) {
230
			$channelResult = $this->updateChannel($id, $userId, null, $force);
231
			if ($channelResult['updated']) {
232
				$result['changed']++;
233
			} elseif ($channelResult['status'] === self::STATUS_OK) {
234
				$result['unchanged']++;
235
			} else {
236
				$result['failed']++;
237
			}
238
239
			if ($progressCallback !== null) {
240
				$progressCallback($channelResult);
241
			}
242
		}
243
244
		return $result;
245
	}
246
247
	/**
248
	 * Reset all the subscribed podcasts of the user
249
	 */
250
	public function resetAll(string $userId) : void {
251
		$this->episodeBusinessLayer->deleteAll($userId);
252
		$this->channelBusinessLayer->deleteAll($userId);
253
	}
254
255
	private function updateEpisodesFromXml(\SimpleXMLElement $items, string $userId, int $channelId) : array {
256
		$episodes = [];
257
		// loop the episodes from XML in reverse order to get chronological order
258
		for ($count = \count($items), $i = $count-1; $i >= 0; --$i) {
259
			if ($items[$i] !== null) {
260
				$episodes[] = $this->episodeBusinessLayer->addOrUpdate($userId, $channelId, $items[$i]);
261
			}
262
		}
263
		return $episodes;
264
	}
265
266
	/**
267
	 * @param string $url
268
	 * @return string|false
269
	 */
270
	private static function fetchUrl(string $url) {
271
		// some podcast services require a valid user agent to be set
272
		$opts = [
273
			"http" => [
274
				"header" => "User-Agent: PodcastService"
275
			]
276
		];
277
		$context = stream_context_create($opts);
278
		return \file_get_contents($url, false, $context);
279
	}
280
}
281