Passed
Push — master ( 54eb8d...5f3e02 )
by Pauli
04:51
created

LastfmService::getInfoFromLastFm()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 30
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 18
nc 3
nop 1
dl 0
loc 30
rs 9.6666
c 0
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 2020 - 2025
11
 */
12
13
namespace OCA\Music\Service;
14
15
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
16
use OCA\Music\AppFramework\Core\Logger;
17
use OCA\Music\BusinessLayer\AlbumBusinessLayer;
18
use OCA\Music\BusinessLayer\ArtistBusinessLayer;
19
use OCA\Music\BusinessLayer\TrackBusinessLayer;
20
use OCA\Music\Db\Artist;
21
use OCA\Music\Db\MatchMode;
22
use OCA\Music\Db\Track;
23
use OCA\Music\Utility\HttpUtil;
24
use OCA\Music\Utility\StringUtil;
25
26
use OCP\IConfig;
27
28
class LastfmService {
29
	private AlbumBusinessLayer $albumBusinessLayer;
30
	private ArtistBusinessLayer $artistBusinessLayer;
31
	private TrackBusinessLayer $trackBusinessLayer;
32
	private Logger $logger;
33
	private string $apiKey;
34
35
	private const LASTFM_URL = 'http://ws.audioscrobbler.com/2.0/';
36
37
	public function __construct(
38
			AlbumBusinessLayer $albumBusinessLayer,
39
			ArtistBusinessLayer $artistBusinessLayer,
40
			TrackBusinessLayer $trackBusinessLayer,
41
			IConfig $config,
42
			Logger $logger) {
43
		$this->albumBusinessLayer = $albumBusinessLayer;
44
		$this->artistBusinessLayer = $artistBusinessLayer;
45
		$this->trackBusinessLayer = $trackBusinessLayer;
46
		$this->logger = $logger;
47
		$this->apiKey = $config->getSystemValue('music.lastfm_api_key');
48
	}
49
50
	/**
51
	 * @throws BusinessLayerException if artist with the given ID is not found
52
	 */
53
	public function getArtistInfo(int $artistId, string $userId) : array {
54
		$artist = $this->artistBusinessLayer->find($artistId, $userId);
55
56
		if ($artist->getName() === null) {
57
			return $this->errorResponse("Can't get details for an unknown artist");
58
		} else {
59
			$result = $this->getInfoFromLastFm([
60
				'method' => 'artist.getInfo',
61
				'artist' => $artist->getName()
62
			]);
63
64
			// add ID to those similar artists which can be found from the library
65
			$similar = $result['artist']['similar']['artist'] ?? null;
66
			if ($similar !== null) {
67
				$result['artist']['similar']['artist'] = \array_map(function ($lastfmArtist) use ($userId) {
68
					$matching = $this->artistBusinessLayer->findAllByName($lastfmArtist['name'], $userId);
69
					if (!empty($matching)) {
70
						$lastfmArtist['id'] = $matching[0]->getId();
71
					}
72
					return $lastfmArtist;
73
				}, $similar);
74
			}
75
76
			return $result;
77
		}
78
	}
79
80
	/**
81
	 * @throws BusinessLayerException if album with the given ID is not found
82
	 */
83
	public function getAlbumInfo(int $albumId, string $userId) : array {
84
		$album = $this->albumBusinessLayer->find($albumId, $userId);
85
86
		if ($album->getName() === null) {
87
			return $this->errorResponse("Can't get details for an unknown album");
88
		} else {
89
			return $this->getInfoFromLastFm([
90
				'method' => 'album.getInfo',
91
				'artist' => $album->getAlbumArtistName(),
92
				'album' => $album->getName()
93
			]);
94
		}
95
	}
96
97
	/**
98
	 * @throws BusinessLayerException if track with the given ID is not found
99
	 */
100
	public function getTrackInfo(int $trackId, string $userId) : array {
101
		$track = $this->trackBusinessLayer->find($trackId, $userId);
102
		return $this->findTrackInfo($track->getTitle(), $track->getArtistName());
0 ignored issues
show
Bug introduced by
It seems like $track->getArtistName() can also be of type null; however, parameter $artistName of OCA\Music\Service\LastfmService::findTrackInfo() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

102
		return $this->findTrackInfo($track->getTitle(), /** @scrutinizer ignore-type */ $track->getArtistName());
Loading history...
103
	}
104
105
	/**
106
	 * @throws BusinessLayerException if track with the given ID is not found
107
	 */
108
	public function findTrackInfo(string $trackTitle, string $artistName) : array {
109
		return $this->getInfoFromLastFm([
110
				'method' => 'track.getInfo',
111
				'artist' => $artistName,
112
				'track' => $trackTitle
113
		]);
114
	}
115
116
	/**
117
	 * Get artists from the user's library similar to the given artist
118
	 * @param bool $includeNotPresent When true, the result may include also artists which
119
	 *                                are not found from the user's music library. Such
120
	 *                                artists have many fields including `id` set as null.
121
	 * @return Artist[]
122
	 * @throws BusinessLayerException if artist with the given ID is not found
123
	 */
124
	public function getSimilarArtists(int $artistId, string $userId, $includeNotPresent=false) : array {
125
		$artist = $this->artistBusinessLayer->find($artistId, $userId);
126
127
		$similarOnLastfm = $this->getInfoFromLastFm([
128
			'method' => 'artist.getSimilar',
129
			'artist' => $artist->getName()
130
		]);
131
132
		$result = [];
133
		$similarArr = $similarOnLastfm['similarartists']['artist'] ?? null;
134
		if ($similarArr !== null) {
135
			foreach ($similarArr as $lastfmArtist) {
136
				$matchingLibArtists = $this->artistBusinessLayer->findAllByName($lastfmArtist['name'], $userId);
137
138
				if (!empty($matchingLibArtists)) {
139
					foreach ($matchingLibArtists as $matchArtist) { // loop although there really shouldn't be more than one
140
						$matchArtist->setLastfmUrl($lastfmArtist['url']);
141
					}
142
					$result = \array_merge($result, $matchingLibArtists);
143
				} elseif ($includeNotPresent) {
144
					$unfoundArtist = new Artist();
145
					$unfoundArtist->setName($lastfmArtist['name'] ?? null);
146
					$unfoundArtist->setMbid($lastfmArtist['mbid'] ?? null);
147
					$unfoundArtist->setLastfmUrl($lastfmArtist['url'] ?? null);
148
					$result[] = $unfoundArtist;
149
				}
150
			}
151
		}
152
153
		return $result;
154
	}
155
156
	/**
157
	 * Get tracks from the user's library similar to the given track
158
	 * @return Track[]
159
	 * @throws BusinessLayerException if track with the given ID is not found
160
	 */
161
	public function getSimilarTracks(int $trackId, string $userId) : array {
162
		$track = $this->trackBusinessLayer->find($trackId, $userId);
163
164
		$similarOnLastfm = $this->getInfoFromLastFm([
165
			'method' => 'track.getSimilar',
166
			'track' => $track->getTitle(),
167
			'artist' => $track->getArtistName()
168
		]);
169
170
		$result = [];
171
		$similarArr = $similarOnLastfm['similartracks']['track'] ?? null;
172
		if ($similarArr !== null) {
173
			foreach ($similarArr as $lastfmTrack) {
174
				$matchingLibTracks = $this->trackBusinessLayer->findAllByNameArtistOrAlbum(
175
					$lastfmTrack['name'], $lastfmTrack['artist']['name'], null, $userId);
176
				$result = \array_merge($result, $matchingLibTracks);
177
			}
178
		}
179
180
		return $result;
181
	}
182
183
	/**
184
	 * Get artist tracks from the user's library, sorted by their popularity on Last.fm
185
	 * @param int|string $artistIdOrName Either the ID of the artist or the artist's name written exactly
186
	 * 									like in the DB. Any integer-typed value is treated as an ID and
187
	 * 									string-typed value as a name.
188
	 * @param int $maxCount Number of tracks to request from Last.fm. Note that the function may return much
189
	 *						less tracks if the top tracks from Last.fm are not present in the user's library.
190
	 * @return Track[]
191
	 */
192
	public function getTopTracks(/*mixed*/ $artistIdOrName, string $userId, int $maxCount) : array {
193
		$foundTracks = [];
194
195
		if (\is_integer($artistIdOrName)) {
196
			$artist = $this->artistBusinessLayer->find($artistIdOrName, $userId);
197
		} else {
198
			$artist = $this->artistBusinessLayer->findAllByName($artistIdOrName, $userId, MatchMode::Exact, /*$limit=*/1)[0] ?? null;
199
		}
200
201
		if ($artist !== null) {
202
			$lastfmResult = $this->getInfoFromLastFm([
203
				'method' => 'artist.getTopTracks',
204
				'artist' => $artist->getName(),
205
				'limit' => (string)$maxCount
206
			]);
207
			$topTracksOnLastfm = $lastfmResult['toptracks']['track'] ?? null;
208
209
			if ($topTracksOnLastfm !== null) {
210
				$libTracks = $this->trackBusinessLayer->findAllByArtist($artist->getId(), $userId);
211
212
				foreach ($topTracksOnLastfm as $lastfmTrack) {
213
					foreach ($libTracks as $libTrack) {
214
						if (\mb_strtolower($lastfmTrack['name']) === \mb_strtolower($libTrack->getTitle())) {
215
							$foundTracks[] = $libTrack;
216
							break;
217
						}
218
					}
219
				}
220
			}
221
		}
222
223
		return $foundTracks;
224
	}
225
226
	private function getInfoFromLastFm(array $args) : array {
227
		if (empty($this->apiKey)) {
228
			return ['api_key_set' => false];
229
		} else {
230
			// append the standard args
231
			$args['api_key'] = $this->apiKey;
232
			$args['format'] = 'json';
233
234
			// remove args with null or empty values
235
			$args = \array_filter($args, [StringUtil::class, 'isNonEmptyString']);
236
237
			// glue arg keys and values together ...
238
			$args = \array_map(fn($key, $value) => ($key . '=' . \urlencode($value)), \array_keys($args), $args);
239
			// ... and form the final query string
240
			$queryString = '?' . \implode('&', $args);
241
242
			list('content' => $info, 'status_code' => $statusCode, 'message' => $msg) = HttpUtil::loadFromUrl(self::LASTFM_URL . $queryString);
243
244
			if ($info === false) {
245
				// When an album is not found, Last.fm returns 404 but that is not a sign of broken connection.
246
				// Interestingly, not finding an artist is still responded with the code 200.
247
				$info = ['connection_ok' => ($statusCode === 404)];
248
			} else {
249
				$info = \json_decode($info, true);
250
				$info['connection_ok'] = true;
251
			}
252
			$info['status_code'] = $statusCode;
253
			$info['status_msg'] = $msg;
254
			$info['api_key_set'] = true;
255
			return $info;
256
		}
257
	}
258
259
	private function errorResponse(string $message) : array {
260
		return [
261
			'api_key_set' => !empty($this->apiKey),
262
			'connection_ok' => 'unknown',
263
			'status_code' => -1,
264
			'status_msg' => $message
265
		];
266
	}
267
}
268