Passed
Push — feature/909_Ampache_API_improv... ( f93b1c...7380bc )
by Pauli
02:41
created

AlbumBusinessLayer::findAllStarred()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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