Passed
Push — feature/909_Ampache_API_improv... ( e0770e...e4874b )
by Pauli
02:36
created

AlbumBusinessLayer::findAllRated()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 2
c 1
b 0
f 0
nc 1
nop 3
dl 0
loc 3
rs 10
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 Morris Jobke <[email protected]>
10
 * @author Pauli Järvinen <[email protected]>
11
 * @copyright Morris Jobke 2013, 2014
12
 * @copyright Pauli Järvinen 2016 - 2023
13
 */
14
15
namespace OCA\Music\BusinessLayer;
16
17
use OCA\Music\AppFramework\BusinessLayer\BusinessLayer;
18
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
19
use OCA\Music\AppFramework\Core\Logger;
20
21
use OCA\Music\Db\AlbumMapper;
22
use OCA\Music\Db\Album;
23
use OCA\Music\Db\Entity;
24
use OCA\Music\Db\MatchMode;
25
use OCA\Music\Db\SortBy;
26
use OCA\Music\Db\Track;
27
28
use OCA\Music\Utility\Util;
29
30
/**
31
 * Base class functions with the actually used inherited types to help IDE and Scrutinizer:
32
 * @phpstan-extends BusinessLayer<Album>
33
 */
34
class AlbumBusinessLayer extends BusinessLayer {
35
	protected $mapper; // eclipse the definition from the base class, to help IDE and Scrutinizer to know the actual type
36
	private $logger;
37
38
	public function __construct(AlbumMapper $albumMapper, Logger $logger) {
39
		parent::__construct($albumMapper);
40
		$this->mapper = $albumMapper;
41
		$this->logger = $logger;
42
	}
43
44
	/**
45
	 * {@inheritdoc}
46
	 * @see BusinessLayer::find()
47
	 * @return Album
48
	 */
49
	public function find(int $albumId, string $userId) : Entity {
50
		$album = parent::find($albumId, $userId);
51
		return $this->injectExtraFields([$album], $userId)[0];
52
	}
53
54
	/**
55
	 * {@inheritdoc}
56
	 * @see BusinessLayer::findById()
57
	 * @return Album[]
58
	 */
59
	public function findById(array $ids, string $userId=null, bool $preserveOrder=false) : array {
60
		$albums = parent::findById($ids, $userId, $preserveOrder);
61
		if ($userId !== null) {
62
			return $this->injectExtraFields($albums, $userId);
63
		} else {
64
			return $albums; // can't inject the extra fields without a user
65
		}
66
	}
67
68
	/**
69
	 * {@inheritdoc}
70
	 * @see BusinessLayer::findAll()
71
	 * @return Album[]
72
	 */
73
	public function findAll(string $userId, int $sortBy=SortBy::None, ?int $limit=null, ?int $offset=null,
74
							?string $createdMin=null, ?string $createdMax=null, ?string $updatedMin=null, ?string $updatedMax=null) : array {
75
		$albums = parent::findAll($userId, $sortBy, $limit, $offset, $createdMin, $createdMax, $updatedMin, $updatedMax);
76
		$effectivelyLimited = ($limit !== null && $limit < \count($albums));
77
		$everyAlbumIncluded = (!$effectivelyLimited && !$offset && !$createdMin && !$createdMax && !$updatedMin && !$updatedMax);
0 ignored issues
show
Bug Best Practice introduced by
The expression $offset of type integer|null is loosely compared to false; this is ambiguous if the integer can be 0. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
78
		return $this->injectExtraFields($albums, $userId, $everyAlbumIncluded);
79
	}
80
81
	/**
82
	 * Returns all albums filtered by name of album or artist
83
	 * @return Album[]
84
	 */
85
	public function findAllByNameRecursive(string $name, string $userId, ?int $limit=null, ?int $offset=null) : array {
86
		$name = \trim($name);
87
		$albums = $this->mapper->findAllByNameRecursive($name, $userId, $limit, $offset);
88
		return $this->injectExtraFields($albums, $userId);
89
	}
90
91
	/**
92
	 * Returns all albums filtered by artist (both album and track artists are considered)
93
	 * @return Album[] albums
94
	 */
95
	public function findAllByArtist(int $artistId, string $userId, ?int $limit=null, ?int $offset=null) : array {
96
		$albums = $this->mapper->findAllByArtist($artistId, $userId, $limit, $offset);
97
		return $this->injectExtraFields($albums, $userId);
98
	}
99
100
	/**
101
	 * Returns all albums filtered by album artist
102
	 * @param int|int[] $artistId
103
	 * @return Album[] albums
104
	 */
105
	public function findAllByAlbumArtist(/*mixed*/ $artistId, string $userId, ?int $limit=null, ?int $offset=null) : array {
106
		if (empty($artistId)) {
107
			return [];
108
		} else {
109
			if (!\is_array($artistId)) {
110
				$artistId = [$artistId];
111
			}
112
			$albums = $this->mapper->findAllByAlbumArtist($artistId, $userId, $limit, $offset);
113
			$albums = $this->injectExtraFields($albums, $userId);
114
			\usort($albums, ['\OCA\Music\Db\Album', 'compareYearAndName']);
115
			return $albums;
116
		}
117
	}
118
119
	/**
120
	 * Returns all albums filtered by genre
121
	 * @param int $genreId the genre to include
122
	 * @param string $userId the name of the user
123
	 * @param int|null $limit
124
	 * @param int|null $offset
125
	 * @return Album[] albums
126
	 */
127
	public function findAllByGenre(int $genreId, string $userId, ?int $limit=null, ?int $offset=null) : array {
128
		$albums = $this->mapper->findAllByGenre($genreId, $userId, $limit, $offset);
129
		return $this->injectExtraFields($albums, $userId);
130
	}
131
132
	/**
133
	 * Returns all albums filtered by release year
134
	 * @param int $fromYear
135
	 * @param int $toYear
136
	 * @param string $userId the name of the user
137
	 * @param int|null $limit
138
	 * @param int|null $offset
139
	 * @return Album[] albums
140
	 */
141
	public function findAllByYearRange(
142
			int $fromYear, int $toYear, string $userId, ?int $limit=null, ?int $offset=null) : array {
143
		$reverseOrder = false;
144
		if ($fromYear > $toYear) {
145
			$reverseOrder = true;
146
			Util::swap($fromYear, $toYear);
147
		}
148
149
		// Implement all the custom logic of this function here, without special Mapper function
150
		$albums = \array_filter($this->findAll($userId), function ($album) use ($fromYear, $toYear) {
151
			$years = $album->getYears();
152
			return (!empty($years) && \min($years) <= $toYear && \max($years) >= $fromYear);
153
		});
154
155
		\usort($albums, function ($album1, $album2) use ($reverseOrder) {
156
			return $reverseOrder
157
				? $album2->yearToAPI() - $album1->yearToAPI()
158
				: $album1->yearToAPI() - $album2->yearToAPI();
159
		});
160
161
		if ($limit !== null || $offset !== null) {
162
			$albums = \array_slice($albums, $offset ?: 0, $limit);
163
		}
164
165
		return $albums;
166
	}
167
168
	/**
169
	 * {@inheritdoc}
170
	 * @see BusinessLayer::findAllByName()
171
	 * @return Album[]
172
	 */
173
	public function findAllByName(
174
			?string $name, string $userId, int $matchMode=MatchMode::Exact, ?int $limit=null, ?int $offset=null,
175
			?string $createdMin=null, ?string $createdMax=null, ?string $updatedMin=null, ?string $updatedMax=null) : array {
176
		$albums = parent::findAllByName($name, $userId, $matchMode, $limit, $offset, $createdMin, $createdMax, $updatedMin, $updatedMax);
177
		return $this->injectExtraFields($albums, $userId);
178
	}
179
180
	/**
181
	 * {@inheritdoc}
182
	 * @see BusinessLayer::findAllStarred()
183
	 * @return Album[]
184
	 */
185
	public function findAllStarred(string $userId, ?int $limit=null, ?int $offset=null) : array {
186
		$albums = parent::findAllStarred($userId, $limit, $offset);
187
		return $this->injectExtraFields($albums, $userId);
188
	}
189
190
	/**
191
	 * {@inheritdoc}
192
	 * @see BusinessLayer::findAllRated()
193
	 * @return Album[]
194
	 */
195
	public function findAllRated(string $userId, ?int $limit=null, ?int $offset=null) : array {
196
		$albums = $this->mapper->findAllRated($userId, $limit, $offset);
197
		return $this->injectExtraFields($albums, $userId);
198
	}
199
200
	/**
201
	 * {@inheritdoc}
202
	 * @see BusinessLayer::findAllByName()
203
	 * @return Album[]
204
	 */
205
	public function findAllAdvanced(string $conjunction, array $rules, string $userId, ?int $limit=null, ?int $offset=null) : array {
206
		$albums = parent::findAllAdvanced($conjunction, $rules, $userId, $limit, $offset);
207
		return $this->injectExtraFields($albums, $userId);
208
	}
209
210
	/**
211
	 * Find most frequently played albums, judged by the total play count of the contained tracks
212
	 * @return Album[]
213
	 */
214
	public function findFrequentPlay(string $userId, ?int $limit=null, ?int $offset=null) : array {
215
		$countsPerAlbum = $this->mapper->getAlbumTracksPlayCount($userId, $limit, $offset);
216
		$ids = \array_keys($countsPerAlbum);
217
		return $this->findById($ids, $userId, /*preserveOrder=*/true);
218
	}
219
220
	/**
221
	 * Find most recently played albums
222
	 * @return Album[]
223
	 */
224
	public function findRecentPlay(string $userId, ?int $limit=null, ?int $offset=null) : array {
225
		$playTimePerAlbum = $this->mapper->getLatestAlbumPlayTimes($userId, $limit, $offset);
226
		$ids = \array_keys($playTimePerAlbum);
227
		return $this->findById($ids, $userId, /*preserveOrder=*/true);
228
	}
229
230
	/**
231
	 * Find least recently played albums
232
	 * @return Album[]
233
	 */
234
	public function findNotRecentPlay(string $userId, ?int $limit=null, ?int $offset=null) : array {
235
		$playTimePerAlbum = $this->mapper->getFurthestAlbumPlayTimes($userId, $limit, $offset);
236
		$ids = \array_keys($playTimePerAlbum);
237
		return $this->findById($ids, $userId, /*preserveOrder=*/true);
238
	}
239
240
	/**
241
	 * Add performing artists, release years, genres, and disk counts to the given album objects
242
	 * @param Album[] $albums
243
	 * @param string $userId
244
	 * @param bool $allAlbums Set to true if $albums contains all albums of the user.
245
	 *                        This has now effect on the outcome but helps in optimizing
246
	 *                        the database query.
247
	 * @return Album[]
248
	 */
249
	private function injectExtraFields(array $albums, string $userId, bool $allAlbums = false) : array {
250
		if (\count($albums) > 0) {
251
			// In case we are injecting data to a lot of albums, do not limit the
252
			// SQL SELECTs to only those albums. Very large amount of SQL host parameters
253
			// could cause problems with SQLite (see #239) and probably it would be bad for
254
			// performance also on other DBMSs. For the proper operation of this function,
255
			// it doesn't matter if we fetch data for some extra albums.
256
			$albumIds = ($allAlbums || \count($albums) >= self::MAX_SQL_ARGS)
257
					? null : Util::extractIds($albums);
258
259
			$artists = $this->mapper->getPerformingArtistsByAlbumId($albumIds, $userId);
260
			$years = $this->mapper->getYearsByAlbumId($albumIds, $userId);
261
			$diskCounts = $this->mapper->getDiscCountByAlbumId($albumIds, $userId);
262
			$genres = $this->mapper->getGenresByAlbumId($albumIds, $userId);
263
264
			foreach ($albums as &$album) {
265
				$albumId = $album->getId();
266
				$album->setArtistIds($artists[$albumId] ?? []);
267
				$album->setNumberOfDisks($diskCounts[$albumId] ?? 1);
268
				$album->setGenres($genres[$albumId] ?? null);
269
				$album->setYears($years[$albumId] ?? null);
270
			}
271
		}
272
		return $albums;
273
	}
274
275
	/**
276
	 * Returns the count of albums where the given Artist is featured in
277
	 * @param integer $artistId
278
	 * @return integer
279
	 */
280
	public function countByArtist(int $artistId) : int {
281
		return $this->mapper->countByArtist($artistId);
282
	}
283
284
	/**
285
	 * Returns the count of albums where the given artist is the album artist
286
	 * @param integer $artistId
287
	 * @return integer
288
	 */
289
	public function countByAlbumArtist(int $artistId) : int {
290
		return $this->mapper->countByAlbumArtist($artistId);
291
	}
292
293
	public function findAlbumOwner(int $albumId) : string {
294
		$entities = $this->findById([$albumId]);
295
		if (\count($entities) != 1) {
296
			throw new BusinessLayerException(
297
					'Expected to find one album but got ' . \count($entities));
298
		} else {
299
			return $entities[0]->getUserId();
300
		}
301
	}
302
303
	/**
304
	 * Adds an album if it does not exist already or updates an existing album
305
	 * @param string|null $name the name of the album
306
	 * @param integer $albumArtistId
307
	 * @param string $userId
308
	 * @return Album The added/updated album
309
	 */
310
	public function addOrUpdateAlbum(?string $name, int $albumArtistId, string $userId) : Album {
311
		$album = new Album();
312
		$album->setName(Util::truncate($name, 256)); // some DB setups can't truncate automatically to column max size
313
		$album->setUserId($userId);
314
		$album->setAlbumArtistId($albumArtistId);
315
316
		// Generate hash from the set of fields forming the album identity to prevent duplicates.
317
		// The uniqueness of album name is evaluated in case-insensitive manner.
318
		$lowerName = \mb_strtolower($album->getName() ?? '');
319
		$hash = \hash('md5', "$lowerName|$albumArtistId");
320
		$album->setHash($hash);
321
322
		return $this->mapper->updateOrInsert($album);
323
	}
324
325
	/**
326
	 * Check if given file is used as cover for the given album
327
	 * @param int $albumId
328
	 * @param int[] $fileIds
329
	 * @return boolean
330
	 */
331
	public function albumCoverIsOneOfFiles(int $albumId, array $fileIds) : bool {
332
		$albums = $this->findById([$albumId]);
333
		return (\count($albums) && \in_array($albums[0]->getCoverFileId(), $fileIds));
334
	}
335
336
	/**
337
	 * updates the cover for albums in the specified folder without cover
338
	 * @param integer $coverFileId the file id of the cover image
339
	 * @param integer $folderId the file id of the folder where the albums are looked from
340
	 * @return boolean True if one or more albums were influenced
341
	 */
342
	public function updateFolderCover(int $coverFileId, int $folderId) : bool {
343
		return $this->mapper->updateFolderCover($coverFileId, $folderId);
344
	}
345
346
	/**
347
	 * set cover file for a specified album
348
	 * @param int|null $coverFileId the file id of the cover image
349
	 * @param int $albumId the id of the album to be modified
350
	 */
351
	public function setCover(?int $coverFileId, int $albumId) {
352
		$this->mapper->setCover($coverFileId, $albumId);
353
	}
354
355
	/**
356
	 * removes the cover art from albums, replacement covers will be searched in a background task
357
	 * @param integer[] $coverFileIds the file IDs of the cover images
358
	 * @param string[]|null $userIds the users whose music library is targeted; all users are targeted if omitted
359
	 * @return Album[] albums which got modified, empty array if none
360
	 */
361
	public function removeCovers(array $coverFileIds, array $userIds=null) : array {
362
		return $this->mapper->removeCovers($coverFileIds, $userIds);
363
	}
364
365
	/**
366
	 * try to find cover arts for albums without covers
367
	 * @param string|null $userId target user; omit to target all users
368
	 * @return string[] users whose collections got modified
369
	 */
370
	public function findCovers(string $userId = null) : array {
371
		$affectedUsers = [];
372
		$albums = $this->mapper->getAlbumsWithoutCover($userId);
373
		foreach ($albums as $album) {
374
			if ($this->mapper->findAlbumCover($album['albumId'], $album['parentFolderId'])) {
375
				$affectedUsers[$album['userId']] = 1;
376
			}
377
		}
378
		return \array_keys($affectedUsers);
379
	}
380
381
	/**
382
	 * Given an array of track IDs, find corresponding unique album IDs, including only
383
	 * those album which have a cover art set.
384
	 * @param int[] $trackIds
385
	 * @return Album[] *Partial* albums, without any injected extra fields
386
	 */
387
	public function findAlbumsWithCoversForTracks(array $trackIds, string $userId, int $limit) : array {
388
		if (\count($trackIds) === 0) {
389
			return [];
390
		} else {
391
			$result = [];
392
			$idChunks = \array_chunk($trackIds, self::MAX_SQL_ARGS - 1);
393
			foreach ($idChunks as $idChunk) {
394
				$resultChunk = $this->mapper->findAlbumsWithCoversForTracks($idChunk, $userId, $limit);
395
				$result = \array_merge($result, $resultChunk);
396
				$limit -= \count($resultChunk);
397
				if ($limit <= 0) {
398
					break;
399
				}
400
			}
401
			return $result;
402
		}
403
	}
404
405
	/**
406
	 * Given an array of Track objects, inject the corresponding Album object to each of them
407
	 * @param Track[] $tracks (in|out)
408
	 */
409
	public function injectAlbumsToTracks(array &$tracks, string $userId) {
410
		$albumIds = [];
411
412
		// get unique album IDs
413
		foreach ($tracks as $track) {
414
			$albumIds[$track->getAlbumId()] = 1;
415
		}
416
		$albumIds = \array_keys($albumIds);
417
418
		// get the corresponding entities from the business layer
419
		if (\count($albumIds) < self::MAX_SQL_ARGS && \count($albumIds) < $this->count($userId)) {
420
			$albums = $this->findById($albumIds, $userId);
421
		} else {
422
			$albums = $this->findAll($userId);
423
		}
424
425
		// create hash tables "id => entity" for the albums for fast access
426
		$albumMap = Util::createIdLookupTable($albums);
427
428
		// finally, set the references on the tracks
429
		foreach ($tracks as &$track) {
430
			$track->setAlbum($albumMap[$track->getAlbumId()] ?? new Album());
431
		}
432
	}
433
}
434