Passed
Push — feature/786_podcasts ( 30025c...d8f350 )
by Pauli
02:43
created

PodcastService::subscribe()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 21
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 5
eloc 13
c 2
b 0
f 0
nc 4
nop 2
dl 0
loc 21
rs 9.5222
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
	 * Inject episodes to the given podcast channels
88
	 * @param PodcastChannel[] $channels input/output
89
	 * @param bool $allChannelsIncluded Set this to true if $channels contains all the podcasts of the user.
90
	 *									This helps in optimizing the DB query.
91
	 */
92
	public function injectEpisodes(array &$channels, string $userId, bool $allChannelsIncluded) : void {
93
		if ($allChannelsIncluded || \count($channels) > 999) {
94
			$episodes = $this->episodeBusinessLayer->findAll($userId, SortBy::Newest);
95
		} else {
96
			$episodes = $this->episodeBusinessLayer->findAllByChannel(Util::extractIds($channels), $userId);
97
		}
98
99
		$episodesPerChannel = [];
100
		foreach ($episodes as $episode) {
101
			$episodesPerChannel[$episode->getChannelId()][] = $episode;
102
		}
103
104
		foreach ($channels as &$channel) {
105
			$channel->setEpisodes($episodesPerChannel[$channel->getId()] ?? []);
106
		}
107
	}
108
109
	/**
110
	 * Add a followed podcast for a user from an RSS feed
111
	 * @return array like ['status' => int, 'channel' => ?PodcastChannel]
112
	 */
113
	public function subscribe(string $url, string $userId) : array {
114
		$content = \file_get_contents($url);
115
		if ($content === false) {
116
			return ['status' => self::STATUS_INVALID_URL, 'channel' => null];
117
		}
118
119
		$xmlTree = \simplexml_load_string($content, \SimpleXMLElement::class, LIBXML_NOCDATA);
120
		if ($xmlTree === false || !$xmlTree->channel) {
121
			return ['status' => self::STATUS_INVALID_RSS, 'channel' => null];
122
		}
123
124
		try {
125
			$channel = $this->channelBusinessLayer->create($userId, $url, $content, $xmlTree->channel);
126
		} catch (\OCA\Music\AppFramework\Db\UniqueConstraintViolationException $ex) {
127
			return ['status' => self::STATUS_ALREADY_EXISTS, 'channel' => null];
128
		}
129
130
		$episodes = $this->updateEpisodesFromXml($xmlTree->channel->item, $userId, $channel->getId());
131
		$channel->setEpisodes(\array_reverse($episodes));
132
133
		return ['status' => self::STATUS_OK, 'channel' => $channel];
134
	}
135
136
	/**
137
	 * Deletes a podcast channel from a user
138
	 * @return int status code
139
	 */
140
	public function unsubscribe(int $channelId, string $userId) : int {
141
		try {
142
			$this->channelBusinessLayer->delete($channelId, $userId); // throws if not found
143
			$this->episodeBusinessLayer->deleteByChannel($channelId, $userId); // does not throw
144
			return self::STATUS_OK;
145
		} catch (BusinessLayerException $ex) {
146
			$this->logger->log("Channel $channelId to be unsubscirbed not found: " . $ex->getMessage(), 'warn');
147
			return self::STATUS_NOT_FOUND;
148
		}
149
	}
150
151
	/**
152
	 * Check a single podcast channel for updates
153
	 * @param ?string $prevHash Previous content hash known by the client. If given, the result will tell
154
	 *							if the channel content has updated from this state. If omitted, the result
155
	 *							will tell if the channel changed from its previous server-known state.
156
	 * @param bool $force Value true will cause the episodes to be parsed and updated to the database even
157
	 *					in case the RSS hasn't been changed at all since the previous update. This might be
158
	 *					useful during the development or if the previous update was unexpectedly aborted.
159
	 * @return array like ['status' => int, 'updated' => bool, 'channel' => ?PodcastChannel]
160
	 */
161
	public function updateChannel(int $id, string $userId, ?string $prevHash = null, bool $force = false) : array {
162
		$updated = false;
163
		$status = self::STATUS_OK;
164
165
		try {
166
			$channel = $this->channelBusinessLayer->find($id, $userId);
167
		} catch (BusinessLayerException $ex) {
168
			$this->logger->log("Channel $id to be updated not found: " . $ex->getMessage(), 'warn');
169
			$status = self::STATUS_NOT_FOUND;
170
			$channel = null;
171
		}
172
173
		if ($channel !== null) {
174
			$xmlTree = null;
175
			$content = \file_get_contents($channel->getRssUrl());
176
			if ($content === null) {
177
				$status = self::STATUS_INVALID_URL;
178
			} else {
179
				$xmlTree = \simplexml_load_string($content, \SimpleXMLElement::class, LIBXML_NOCDATA);
180
			}
181
182
			if (!$xmlTree || !$xmlTree->channel) {
183
				$this->logger->log("RSS feed for the chanenl {$channel->id} was invalid", 'warn');
184
				$status = self::STATUS_INVALID_RSS;
185
			} else if ($this->channelBusinessLayer->updateChannel($channel, $content, $xmlTree->channel) || $force) {
186
				// update the episodes too if channel content has actually changed or update is forced
187
				$episodes = $this->updateEpisodesFromXml($xmlTree->channel->item, $userId, $id);
188
				$channel->setEpisodes($episodes);
189
				$this->episodeBusinessLayer->deleteByChannelExcluding($id, Util::extractIds($episodes), $userId);
190
				$updated = true;
191
			} else if ($prevHash !== null && $prevHash !== $channel->getContentHash()) {
192
				// the channel content is not new for the server but it is still new for the client
193
				$channel->setEpisodes($this->episodeBusinessLayer->findAllByChannel($id, $userId));
194
				$updated = true;
195
			}
196
		}
197
198
		return [
199
			'status' => $status,
200
			'updated' => $updated,
201
			'channel' => $channel
202
		];
203
	}
204
205
	/**
206
	 * Check updates for all chanenls of the user, one-by-one
207
	 * @return array like ['changed' => int, 'unchanged' => int, 'failed' => int]
208
	 *			where each int represent number of channels in that category
209
	 */
210
	public function updateAllChannels(
211
			string $userId, ?float $olderThan = null, bool $force = false, ?callable $progressCallback = null) : array {
212
213
		$result = ['changed' => 0, 'unchanged' => 0, 'failed' => 0];
214
215
		if ($olderThan === null) {
216
			$ids = $this->channelBusinessLayer->findAllIds($userId);
217
		} else {
218
			$ids = $this->channelBusinessLayer->findAllIdsNotUpdatedForHours($userId, $olderThan);
219
		}
220
221
		foreach ($ids as $id) {
222
			$channelResult = $this->updateChannel($id, $userId, null, $force);
223
			if ($channelResult['updated']) {
224
				$result['changed']++;
225
			} elseif ($channelResult['status'] === self::STATUS_OK) {
226
				$result['unchanged']++;
227
			} else {
228
				$result['failed']++;
229
			}
230
231
			if ($progressCallback !== null) {
232
				$progressCallback($channelResult);
233
			}
234
		}
235
236
		return $result;
237
	}
238
239
	/**
240
	 * Reset all the subscribed podcasts of the user
241
	 */
242
	public function resetAll(string $userId) : void {
243
		$this->episodeBusinessLayer->deleteAll($userId);
244
		$this->channelBusinessLayer->deleteAll($userId);
245
	}
246
247
	private function updateEpisodesFromXml(\SimpleXMLElement $items, string $userId, int $channelId) : array {
248
		$episodes = [];
249
		// loop the episodes from XML in reverse order to get chronological order
250
		for ($count = \count($items), $i = $count-1; $i >= 0; --$i) {
251
			if ($items[$i] !== null) {
252
				$episodes[] = $this->episodeBusinessLayer->addOrUpdate($userId, $channelId, $items[$i]);
253
			}
254
		}
255
		return $episodes;
256
	}
257
258
}
259