Issues (40)

lib/BusinessLayer/AlbumBusinessLayer.php (1 issue)

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