AlbumBusinessLayer::updateFolderCover()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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