|
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\BusinessLayer; |
|
14
|
|
|
|
|
15
|
|
|
use \OCA\Music\AppFramework\BusinessLayer\BusinessLayer; |
|
16
|
|
|
use \OCA\Music\AppFramework\BusinessLayer\BusinessLayerException; |
|
17
|
|
|
use \OCA\Music\AppFramework\Core\Logger; |
|
18
|
|
|
|
|
19
|
|
|
use \OCA\Music\Db\BaseMapper; |
|
20
|
|
|
use \OCA\Music\Db\PodcastEpisodeMapper; |
|
21
|
|
|
use \OCA\Music\Db\PodcastEpisode; |
|
22
|
|
|
|
|
23
|
|
|
use \OCA\Music\Utility\Util; |
|
24
|
|
|
|
|
25
|
|
|
|
|
26
|
|
|
/** |
|
27
|
|
|
* Base class functions with the actually used inherited types to help IDE and Scrutinizer: |
|
28
|
|
|
* @method PodcastEpisode find(int $episodeId, string $userId) |
|
29
|
|
|
* @method PodcastEpisode[] findAll(string $userId, int $sortBy=SortBy::None, int $limit=null, int $offset=null, ?string $createdMin=null, ?string $createdMax=null, ?string $updatedMin=null, ?string $updatedMax=null) |
|
30
|
|
|
* @method PodcastEpisode[] findAllByName(string $name, string $userId, bool $fuzzy=false, int $limit=null, int $offset=null, ?string $createdMin=null, ?string $createdMax=null, ?string $updatedMin=null, ?string $updatedMax=null) |
|
31
|
|
|
*/ |
|
32
|
|
|
class PodcastEpisodeBusinessLayer extends BusinessLayer { |
|
33
|
|
|
protected $mapper; // eclipse the definition from the base class, to help IDE and Scrutinizer to know the actual type |
|
34
|
|
|
private $logger; |
|
35
|
|
|
|
|
36
|
|
|
public function __construct(PodcastEpisodeMapper $mapper, Logger $logger) { |
|
37
|
|
|
parent::__construct($mapper); |
|
38
|
|
|
$this->mapper = $mapper; |
|
39
|
|
|
$this->logger = $logger; |
|
40
|
|
|
} |
|
41
|
|
|
|
|
42
|
|
|
/** |
|
43
|
|
|
* @param int|int[] $channelId |
|
44
|
|
|
* @return PodcastEpisode[] |
|
45
|
|
|
*/ |
|
46
|
|
|
public function findAllByChannel($channelIds, string $userId, ?int $limit=null, ?int $offset=null) : array { |
|
47
|
|
|
if (!\is_array($channelIds)) { |
|
48
|
|
|
$channelIds = [$channelIds]; |
|
49
|
|
|
} |
|
50
|
|
|
return $this->mapper->findAllByChannel($channelIds, $userId, $limit, $offset); |
|
51
|
|
|
} |
|
52
|
|
|
|
|
53
|
|
|
public function deleteByChannel(int $channelId, string $userId) : void { |
|
54
|
|
|
$this->mapper->deleteByChannel($channelId, $userId); |
|
55
|
|
|
} |
|
56
|
|
|
|
|
57
|
|
|
public function deleteByChannelExcluding(int $channelId, array $excludedIds, string $userId) : void { |
|
58
|
|
|
$this->mapper->deleteByChannelExcluding($channelId, $excludedIds, $userId); |
|
59
|
|
|
} |
|
60
|
|
|
|
|
61
|
|
|
public function addOrUpdate(string $userId, int $channelId, \SimpleXMLElement $xmlNode) : PodcastEpisode { |
|
62
|
|
|
$episode = self::parseEpisodeFromXml($xmlNode, $this->logger); |
|
63
|
|
|
|
|
64
|
|
|
$episode->setUserId($userId); |
|
65
|
|
|
$episode->setChannelId($channelId); |
|
66
|
|
|
|
|
67
|
|
|
return $this->mapper->insertOrUpdate($episode); |
|
68
|
|
|
} |
|
69
|
|
|
|
|
70
|
|
|
private static function parseEpisodeFromXml(\SimpleXMLElement $xmlNode, Logger $logger) : PodcastEpisode { |
|
71
|
|
|
$episode = new PodcastEpisode(); |
|
72
|
|
|
|
|
73
|
|
|
$itunesNodes = $xmlNode->children('http://www.itunes.com/dtds/podcast-1.0.dtd'); |
|
74
|
|
|
|
|
75
|
|
|
if (!$xmlNode->enclosure || !$xmlNode->enclosure->attributes()) { |
|
76
|
|
|
$logger->log("No stream URL for the episode " . $xmlNode->title, 'debug'); |
|
77
|
|
|
$streamUrl = null; |
|
78
|
|
|
$mimetype = null; |
|
79
|
|
|
$size = null; |
|
80
|
|
|
} else { |
|
81
|
|
|
$streamUrl = (string)$xmlNode->enclosure->attributes()['url']; |
|
82
|
|
|
$mimetype = (string)$xmlNode->enclosure->attributes()['type']; |
|
83
|
|
|
$size = (int)$xmlNode->enclosure->attributes()['length']; |
|
84
|
|
|
} |
|
85
|
|
|
|
|
86
|
|
|
$guid = (string)$xmlNode->guid ?: $streamUrl; |
|
87
|
|
|
if (!$guid) { |
|
88
|
|
|
throw new \OCA\Music\AppFramework\BusinessLayer\BusinessLayerException( |
|
89
|
|
|
'Invalid episode, neither <guid> nor <enclosure url> is included'); |
|
90
|
|
|
} |
|
91
|
|
|
|
|
92
|
|
|
$episode->setStreamUrl( Util::truncate($streamUrl, 2048) ); |
|
93
|
|
|
$episode->setMimetype( Util::truncate($mimetype, 256) ); |
|
94
|
|
|
$episode->setSize( $size ); |
|
95
|
|
|
$episode->setDuration( self::parseDuration((string)$itunesNodes->duration) ); |
|
96
|
|
|
$episode->setGuid( Util::truncate($guid, 2048) ); |
|
97
|
|
|
$episode->setGuidHash( \hash('md5', $guid) ); |
|
98
|
|
|
$episode->setTitle( self::parseTitle($itunesNodes->title, $xmlNode->title, $itunesNodes->episode) ); |
|
99
|
|
|
$episode->setEpisode( (int)$itunesNodes->episode ?: null ); |
|
100
|
|
|
$episode->setSeason( (int)$itunesNodes->season ?: null ); |
|
101
|
|
|
$episode->setLinkUrl( Util::truncate((string)$xmlNode->link, 2048) ); |
|
102
|
|
|
$episode->setPublished( \date(BaseMapper::SQL_DATE_FORMAT, \strtotime((string)($xmlNode->pubDate))) ); |
|
103
|
|
|
$episode->setKeywords( Util::truncate((string)$itunesNodes->keywords, 256) ); |
|
104
|
|
|
$episode->setCopyright( Util::truncate((string)$xmlNode->copyright, 256) ); |
|
105
|
|
|
$episode->setAuthor( Util::truncate((string)($xmlNode->author ?: $itunesNodes->author), 256) ); |
|
106
|
|
|
$episode->setDescription( (string)($xmlNode->description ?: $itunesNodes->summary) ); |
|
107
|
|
|
|
|
108
|
|
|
return $episode; |
|
109
|
|
|
} |
|
110
|
|
|
|
|
111
|
|
|
private static function parseTitle($itunesTitle, $title, $episode) : ?string { |
|
112
|
|
|
// Prefer to use the iTunes title over the standard title, becuase sometimes, |
|
113
|
|
|
// the generic title contains the episode number which is also provided separately |
|
114
|
|
|
// while the iTunes title does not. |
|
115
|
|
|
$result = (string)($itunesTitle ?: $title); |
|
116
|
|
|
|
|
117
|
|
|
// If there still is the same episode number prefixed in the beginning of the title |
|
118
|
|
|
// as is provided separately, attempt to crop that. |
|
119
|
|
|
if ($episode) { |
|
120
|
|
|
$matches = null; |
|
121
|
|
|
if (\preg_match("/^$episode\s*[\.:-]\s*(.+)$/", $result, $matches) === 1) { |
|
122
|
|
|
$result = $matches[1]; |
|
123
|
|
|
} |
|
124
|
|
|
} |
|
125
|
|
|
|
|
126
|
|
|
return Util::truncate($result, 256); |
|
127
|
|
|
} |
|
128
|
|
|
|
|
129
|
|
|
private static function parseDuration(string $data) :?int { |
|
130
|
|
|
$matches = null; |
|
131
|
|
|
|
|
132
|
|
|
if (\ctype_digit($data)) { |
|
133
|
|
|
return (int)$data; // plain seconds |
|
134
|
|
|
} elseif (\is_string($data) && \preg_match('/^(\d\d):(\d\d):(\d\d).*/', $data, $matches) === 1) { |
|
135
|
|
|
return (int)$matches[1] * 3600 + (int)$matches[2] * 60 + (int)$matches[3]; // HH:MM:SS |
|
136
|
|
|
} else { |
|
137
|
|
|
return null; // no value or unsupported format |
|
138
|
|
|
} |
|
139
|
|
|
} |
|
140
|
|
|
} |
|
141
|
|
|
|