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
|
|
|
|