Passed
Pull Request — master (#1266)
by Matthew
04:13
created

ExternalScrobbler::recordTrackPlayed()   B

Complexity

Conditions 6
Paths 9

Size

Total Lines 32
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 22
nc 9
nop 3
dl 0
loc 32
rs 8.9457
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 Matthew Wells
10
 * @copyright Matthew Wells 2025
11
 */
12
13
namespace OCA\Music\Service;
14
15
use DateTime;
16
use OCA\Music\AppFramework\Core\Logger;
17
use OCA\Music\BusinessLayer\AlbumBusinessLayer;
18
use OCA\Music\BusinessLayer\TrackBusinessLayer;
19
use OCA\Music\Db\Track;
20
use OCP\IConfig;
21
use OCP\IURLGenerator;
22
use OCP\Security\ICrypto;
23
24
class ExternalScrobbler implements Scrobbler
25
{
26
	private IConfig $config;
27
	private Logger $logger;
28
	private IURLGenerator $urlGenerator;
29
	private TrackBusinessLayer $trackBusinessLayer;
30
	private AlbumBusinessLayer $albumBusinessLayer;
31
	private ICrypto $crypto;
32
	private string $name;
33
	private string $identifier;
34
	private string $endpoint;
35
	private string $tokenRequestUrl;
36
	private ?string $appName;
37
38
	public function __construct(
39
		IConfig $config,
40
		Logger $logger,
41
		IURLGenerator $urlGenerator,
42
		TrackBusinessLayer $trackBusinessLayer,
43
		AlbumBusinessLayer $albumBusinessLayer,
44
		ICrypto $crypto,
45
		string $name,
46
		string $identifier,
47
		string $endpoint,
48
		string $tokenRequestUrl,
49
		?string $appName = 'music'
50
	) {
51
		$this->config = $config;
52
		$this->logger = $logger;
53
		$this->urlGenerator = $urlGenerator;
54
		$this->trackBusinessLayer = $trackBusinessLayer;
55
		$this->albumBusinessLayer = $albumBusinessLayer;
56
		$this->crypto = $crypto;
57
		$this->name = $name;
58
		$this->identifier = $identifier;
59
		$this->endpoint = $endpoint;
60
		$this->tokenRequestUrl = $tokenRequestUrl;
61
		$this->appName = $appName;
62
	}
63
64
	/**
65
	 * @throws \Exception when curl initialization or session key save fails
66
	 * @throws ScrobbleServiceException when auth.getSession call fails
67
	 */
68
	public function generateSession(string $token, string $userId) : void {
69
        $xml = $this->execRequest($this->generateMethodParams('auth.getSession', ['token' => $token]));
70
71
		$status = (string)$xml['status'];
72
		if ($status !== 'ok') {
73
			throw new ScrobbleServiceException((string)$xml->error, (int)$xml->error['code']);
74
		}
75
		$sessionValue = (string)$xml->session->key;
76
77
		$this->saveApiSession($userId, $sessionValue);
78
	}
79
80
	/**
81
	 * @throws \InvalidArgumentException
82
	 */
83
	public function clearSession(?string $userId) : void {
84
		try {
85
			$this->config->deleteUserValue($userId, $this->appName, $this->identifier . '.scrobbleSessionKey');
86
		} catch (\InvalidArgumentException $e) {
87
			$this->logger->error(
88
				'Could not delete user config "' . $this->identifier . '.scrobbleSessionKey". ' . $e->getMessage()
89
			);
90
			throw $e;
91
		}
92
	}
93
94
	public function getApiSession(string $userId) : ?string {
95
		$encryptedKey = $this->config->getUserValue($userId, $this->appName, $this->identifier . '.scrobbleSessionKey');
96
		if (!$encryptedKey) {
97
			return null;
98
		}
99
		$key = $this->crypto->decrypt($encryptedKey, $userId . $this->config->getSystemValue('secret'));
100
		return $key;
101
	}
102
103
	public function getName() : string {
104
		return $this->name;
105
	}
106
107
	public function getIdentifier() : string {
108
		return $this->identifier;
109
	}
110
111
	public function getApiKey() : ?string {
112
		return $this->config->getSystemValue('music.' . $this->identifier . '_api_key', null);
113
	}
114
115
	public function getApiSecret() : ?string {
116
		return $this->config->getSystemValue('music.' . $this->identifier . '_api_secret', null);
117
	}
118
119
	public function recordTrackPlayed(int $trackId, string $userId, ?\DateTime $timeOfPlay = null) : void {
120
		$timeOfPlay = $timeOfPlay ?? new \DateTime();
121
		$sessionKey = $this->getApiSession($userId);
122
		if (!$sessionKey) {
123
			return;
124
		}
125
126
		$timestamp = $timeOfPlay->getTimestamp();
127
		$scrobbleData = [
128
			'sk' => $sessionKey
129
		];
130
131
		/** @var array<Track> $tracks */
132
		$tracks = $this->trackBusinessLayer->findById([$trackId], $userId);
133
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
134
		foreach ($tracks as $i => $track) {
135
			if ($track->getAlbum()) {
136
				$albumArtist = $track->getAlbum()->getAlbumArtistName();
137
				if ($albumArtist !== $track->getArtistName()) {
138
					$scrobbleData["albumArtist[{$i}]"] = $albumArtist;
139
				}
140
			}
141
			$scrobbleData["artist[{$i}]"] = $track->getArtistName();
142
			$scrobbleData["track[{$i}]"] = $track->getTitle();
143
			$scrobbleData["timestamp[{$i}]"] = $timestamp;
144
			$scrobbleData["album[{$i}]"] = $track->getAlbumName();
145
			$scrobbleData["trackNumber[{$i}]"] = $track->getNumber();
146
		}
147
        $xml = $this->execRequest($this->generateMethodParams('track.scrobble', $scrobbleData));
148
149
		if ((string)$xml['status'] !== 'ok') {
150
			$this->logger->warning('Failed to scrobble to ' . $this->name);
151
		}
152
	}
153
154
	public function getTokenRequestUrl(): ?string {
155
		$apiKey = $this->getApiKey();
156
		if (!$apiKey) {
157
			return null;
158
		}
159
160
		$tokenHandleUrl = $this->urlGenerator->linkToRouteAbsolute('music.scrobbler.handleToken', [
161
			'serviceIdentifier' => $this->identifier
162
		]);
163
		return "{$this->tokenRequestUrl}?api_key={$apiKey}&cb={$tokenHandleUrl}";
164
	}
165
166
	private function saveApiSession(string $userId, string $sessionValue) : void {
167
		try {
168
			$encryptedKey = $this->crypto->encrypt(
169
				$sessionValue,
170
				$userId . $this->config->getSystemValue('secret')
171
			);
172
			$this->config->setUserValue($userId, $this->appName, $this->identifier . '.scrobbleSessionKey', $encryptedKey);
173
		} catch (\Exception $e) {
174
			$this->logger->error('Encryption of scrobble session key failed');
175
			throw $e;
176
		}
177
	}
178
179
	/**
180
	 * @param array<string, string|array> $params
181
	 */
182
	private function generateSignature(array $params) : string {
183
		\ksort($params);
184
		$paramString = '';
185
		foreach ($params as $key => $value) {
186
			if (\is_array($value)) {
187
				foreach ($value as $valIdx => $valVal) {
188
					$paramString .= "{$key}[{$valIdx}]{$valVal}";
189
				}
190
			} else {
191
				$paramString .= $key . $value;
192
			}
193
		}
194
195
		$paramString .= $this->getApiSecret();
196
		return \md5($paramString);
197
	}
198
199
	/**
200
	 * @return array<string, string>
201
	 * @param array<string, mixed> $moreParams
202
	 * @param bool $sign
203
	 * @return array<string, mixed>
204
	 */
205
	private function generateMethodParams(string $method, array $moreParams = [], bool $sign = true) : array {
206
		$params = \array_merge($moreParams, [
207
			'method' => $method,
208
			'api_key' => $this->getApiKey()
209
		]);
210
211
		if ($sign) {
212
			$params['api_sig'] = $this->generateSignature($params);
213
		}
214
215
		return $params;
216
	}
217
218
    private function execRequest(array $params) : ?\SimpleXMLElement {
219
		$ch = \curl_init();
220
		if (!$ch) {
221
			$this->logger->error('Failed to initialize a curl handle, is the php curl extension installed?');
222
			throw new \RuntimeException('Unable to initialize a curl handle');
223
		}
224
		\curl_setopt($ch, \CURLOPT_URL, $this->endpoint);
225
		\curl_setopt($ch, \CURLOPT_CONNECTTIMEOUT, 10);
226
		\curl_setopt($ch, \CURLOPT_POST, true);
227
		\curl_setopt($ch, \CURLOPT_RETURNTRANSFER, true);
228
        \curl_setopt($ch, \CURLOPT_POSTFIELDS, \http_build_query($params));
229
		$xmlString = \curl_exec($ch);
230
        \assert(\is_string($xmlString));
231
		$xml = \simplexml_load_string($xmlString);
232
        return $xml ?: null;
0 ignored issues
show
introduced by
$xml is of type SimpleXMLElement, thus it always evaluated to true.
Loading history...
233
    }
234
}
235