Passed
Push — master ( 124be7...f358a5 )
by Pauli
03:35 queued 16s
created

PodcastService::parseOpml()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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