AlbumBusinessLayer   F
last analyzed

Complexity

Total Complexity 60

Size/Duplication

Total Lines 413
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 137
c 0
b 0
f 0
dl 0
loc 413
rs 3.6
wmc 60

28 Methods

Rating   Name   Duplication   Size   Complexity  
A findAllByGenre() 0 3 1
A find() 0 3 1
A injectAlbumsToTracks() 0 22 5
A findAllByArtist() 0 3 1
A findAllByAlbumArtist() 0 11 3
A removeCovers() 0 2 1
B findAllByYearRange() 0 25 8
A findAllStarred() 0 3 1
A countByAlbumArtist() 0 2 1
A findAllAdvanced() 0 5 1
A findCovers() 0 11 4
A findAllByName() 0 5 1
A findAlbumOwner() 0 7 2
A findFrequentPlay() 0 4 1
A albumCoverIsOneOfFiles() 0 3 2
B findAll() 0 6 7
A findAlbumsWithCoversForTracks() 0 15 4
A findAllByNameRecursive() 0 4 1
A __construct() 0 5 1
A findNotRecentPlay() 0 4 1
A findRecentPlay() 0 4 1
A setCover() 0 2 1
A findById() 0 6 2
A addOrUpdateAlbum() 0 13 1
A updateFolderCover() 0 2 1
A countByArtist() 0 2 1
A injectExtraFields() 0 29 5
A findAllRated() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like AlbumBusinessLayer often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use AlbumBusinessLayer, and based on these observations, apply Extract Interface, too.

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