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

ExternalScrobbler::execRequest()   A

Complexity

Conditions 4
Paths 2

Size

Total Lines 16
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 4
eloc 12
c 2
b 0
f 0
nc 2
nop 1
dl 0
loc 16
rs 9.8666
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
            if ($xml instanceof \SimpleXMLElement) {
0 ignored issues
show
introduced by
$xml is always a sub-type of SimpleXMLElement.
Loading history...
74
                $error = (string)$xml->error;
75
                $code = (int)$xml->code;
76
            } else {
77
                $error = 'Empty response';
78
                $code = 0;
79
            }
80
			throw new ScrobbleServiceException($error, $code);
81
		}
82
		$sessionValue = (string)$xml->session->key;
83
84
		$this->saveApiSession($userId, $sessionValue);
85
	}
86
87
	/**
88
	 * @throws \InvalidArgumentException
89
	 */
90
	public function clearSession(string $userId) : void {
91
		try {
92
			$this->config->deleteUserValue($userId, $this->appName, $this->identifier . '.scrobbleSessionKey');
93
		} catch (\InvalidArgumentException $e) {
94
			$this->logger->error(
95
				'Could not delete user config "' . $this->identifier . '.scrobbleSessionKey". ' . $e->getMessage()
96
			);
97
			throw $e;
98
		}
99
	}
100
101
	public function getApiSession(string $userId) : ?string {
102
		$encryptedKey = $this->config->getUserValue($userId, $this->appName, $this->identifier . '.scrobbleSessionKey');
103
		if (!$encryptedKey) {
104
			return null;
105
		}
106
		$key = $this->crypto->decrypt($encryptedKey, $userId . $this->config->getSystemValue('secret'));
107
		return $key;
108
	}
109
110
	public function getName() : string {
111
		return $this->name;
112
	}
113
114
	public function getIdentifier() : string {
115
		return $this->identifier;
116
	}
117
118
	public function getApiKey() : ?string {
119
		return $this->config->getSystemValue('music.' . $this->identifier . '_api_key', null);
120
	}
121
122
	public function getApiSecret() : ?string {
123
		return $this->config->getSystemValue('music.' . $this->identifier . '_api_secret', null);
124
	}
125
126
	public function recordTrackPlayed(int $trackId, string $userId, ?\DateTime $timeOfPlay = null) : void {
127
		$timeOfPlay = $timeOfPlay ?? new \DateTime();
128
		$sessionKey = $this->getApiSession($userId);
129
		if (!$sessionKey) {
130
			return;
131
		}
132
133
		$timestamp = $timeOfPlay->getTimestamp();
134
		$scrobbleData = [
135
			'sk' => $sessionKey
136
		];
137
138
		/** @var array<Track> $tracks */
139
		$tracks = $this->trackBusinessLayer->findById([$trackId], $userId);
140
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
141
		foreach ($tracks as $i => $track) {
142
			if ($track->getAlbum()) {
143
				$albumArtist = $track->getAlbum()->getAlbumArtistName();
144
				if ($albumArtist !== $track->getArtistName()) {
145
					$scrobbleData["albumArtist[{$i}]"] = $albumArtist;
146
				}
147
			}
148
			$scrobbleData["artist[{$i}]"] = $track->getArtistName();
149
			$scrobbleData["track[{$i}]"] = $track->getTitle();
150
			$scrobbleData["timestamp[{$i}]"] = $timestamp;
151
			$scrobbleData["album[{$i}]"] = $track->getAlbumName();
152
			$scrobbleData["trackNumber[{$i}]"] = $track->getNumber();
153
		}
154
        $xml = $this->execRequest($this->generateMethodParams('track.scrobble', $scrobbleData));
155
156
		if ((string)$xml['status'] !== 'ok') {
157
			$this->logger->warning('Failed to scrobble to ' . $this->name);
158
		}
159
	}
160
161
	public function getTokenRequestUrl(): ?string {
162
		$apiKey = $this->getApiKey();
163
		if (!$apiKey) {
164
			return null;
165
		}
166
167
		$tokenHandleUrl = $this->urlGenerator->linkToRouteAbsolute('music.scrobbler.handleToken', [
168
			'serviceIdentifier' => $this->identifier
169
		]);
170
		return "{$this->tokenRequestUrl}?api_key={$apiKey}&cb={$tokenHandleUrl}";
171
	}
172
173
	private function saveApiSession(string $userId, string $sessionValue) : void {
174
		try {
175
			$encryptedKey = $this->crypto->encrypt(
176
				$sessionValue,
177
				$userId . $this->config->getSystemValue('secret')
178
			);
179
			$this->config->setUserValue($userId, $this->appName, $this->identifier . '.scrobbleSessionKey', $encryptedKey);
180
		} catch (\Exception $e) {
181
			$this->logger->error('Encryption of scrobble session key failed');
182
			throw $e;
183
		}
184
	}
185
186
	/**
187
	 * @param array<string, string|array> $params
188
	 */
189
	private function generateSignature(array $params) : string {
190
		\ksort($params);
191
		$paramString = '';
192
		foreach ($params as $key => $value) {
193
			if (\is_array($value)) {
194
				foreach ($value as $valIdx => $valVal) {
195
					$paramString .= "{$key}[{$valIdx}]{$valVal}";
196
				}
197
			} else {
198
				$paramString .= $key . $value;
199
			}
200
		}
201
202
		$paramString .= $this->getApiSecret();
203
		return \md5($paramString);
204
	}
205
206
	/**
207
	 * @return array<string, string>
208
	 * @param array<string, mixed> $moreParams
209
	 * @param bool $sign
210
	 * @return array<string, mixed>
211
	 */
212
	private function generateMethodParams(string $method, array $moreParams = [], bool $sign = true) : array {
213
		$params = \array_merge($moreParams, [
214
			'method' => $method,
215
			'api_key' => $this->getApiKey()
216
		]);
217
218
		if ($sign) {
219
			$params['api_sig'] = $this->generateSignature($params);
220
		}
221
222
		return $params;
223
	}
224
225
    private function execRequest(array $params) : ?\SimpleXMLElement {
226
		$ch = \curl_init();
227
		if (!$ch) {
228
			$this->logger->error('Failed to initialize a curl handle, is the php curl extension installed?');
229
			throw new \RuntimeException('Unable to initialize a curl handle');
230
		}
231
		\curl_setopt($ch, \CURLOPT_URL, $this->endpoint);
232
		\curl_setopt($ch, \CURLOPT_CONNECTTIMEOUT, 10);
233
		\curl_setopt($ch, \CURLOPT_POST, true);
234
		\curl_setopt($ch, \CURLOPT_RETURNTRANSFER, true);
235
        \curl_setopt($ch, \CURLOPT_POSTFIELDS, \http_build_query($params));
236
        /** @var string $xmlString */
237
		$xmlString = \curl_exec($ch) ?: '';
238
        /** @var \SimpleXMLElement|false $xml */
239
        $xml = \simplexml_load_string($xmlString);
240
        return $xml ?: null;
0 ignored issues
show
introduced by
$xml is of type SimpleXMLElement, thus it always evaluated to true.
Loading history...
241
    }
242
}
243