Passed
Push — master ( f2aded...94d37f )
by Pauli
03:52
created

TrackBusinessLayer   D

Complexity

Total Complexity 59

Size/Duplication

Total Lines 456
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 161
dl 0
loc 456
rs 4.08
c 2
b 0
f 0
wmc 59

28 Methods

Rating   Name   Duplication   Size   Complexity  
A findAllByAlbum() 0 8 3
A findAllByNameRecursive() 0 3 1
A findAllByNameArtistOrAlbum() 0 9 3
A findAllByArtist() 0 8 3
A findAllByGenre() 0 2 1
A findAllByFolder() 0 2 1
A __construct() 0 3 1
A findAllFolders() 0 8 1
A recordTrackPlayed() 0 5 2
B addExternalMountsToFoldersLut() 0 33 10
A findDirtyFileIds() 0 2 1
A getFoldersLut() 0 27 2
A findFilesWithoutScannedGenre() 0 2 1
A totalDurationByArtist() 0 2 1
A findRecentPlay() 0 2 1
A findByFileId() 0 5 2
A injectFolderPathsToTracks() 0 23 4
A addOrUpdateTrack() 0 18 1
A addMissingParentsToFoldersLut() 0 13 3
A getGenresByArtistId() 0 2 1
A countByArtist() 0 2 1
A findAllFileIds() 0 2 1
B deleteTracks() 0 58 8
A findNotRecentPlay() 0 2 1
A findFrequentPlay() 0 2 1
A countByAlbum() 0 2 1
A markTracksDirty() 0 6 2
A totalDurationOfAlbum() 0 2 1

How to fix   Complexity   

Complex Class

Complex classes like TrackBusinessLayer 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 TrackBusinessLayer, 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
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\MatchMode;
22
use OCA\Music\Db\SortBy;
23
use OCA\Music\Db\TrackMapper;
24
use OCA\Music\Db\Track;
25
use OCA\Music\Utility\ArrayUtil;
26
use OCA\Music\Utility\StringUtil;
27
28
use OCP\AppFramework\Db\DoesNotExistException;
29
use OCP\Files\FileInfo;
30
use OCP\Files\Folder;
31
32
/**
33
 * Base class functions with the actually used inherited types to help IDE and Scrutinizer:
34
 * @method Track find(int $trackId, string $userId)
35
 * @method Track[] findAll(string $userId, int $sortBy=SortBy::Name, ?int $limit=null, ?int $offset=null)
36
 * @method Track[] findAllByName(string $name, string $userId, int $matchMode=MatchMode::Exact, ?int $limit=null, ?int $offset=null)
37
 * @property TrackMapper $mapper
38
 * @phpstan-extends BusinessLayer<Track>
39
 */
40
class TrackBusinessLayer extends BusinessLayer {
41
	private Logger $logger;
42
43
	public function __construct(TrackMapper $trackMapper, Logger $logger) {
44
		parent::__construct($trackMapper);
45
		$this->logger = $logger;
46
	}
47
48
	/**
49
	 * Returns all tracks filtered by artist (both album and track artists are considered)
50
	 * @param int|int[] $artistId
51
	 * @return Track[]
52
	 */
53
	public function findAllByArtist(/*mixed*/ $artistId, string $userId, ?int $limit=null, ?int $offset=null) : array {
54
		if (empty($artistId)) {
55
			return [];
56
		} else {
57
			if (!\is_array($artistId)) {
58
				$artistId = [$artistId];
59
			}
60
			return $this->mapper->findAllByArtist($artistId, $userId, $limit, $offset);
61
		}
62
	}
63
64
	/**
65
	 * Returns all tracks filtered by album. Optionally, filter also by the performing artist.
66
	 * @param int|int[] $albumId
67
	 * @return Track[]
68
	 */
69
	public function findAllByAlbum(/*mixed*/ $albumId, string $userId, ?int $artistId=null, ?int $limit=null, ?int $offset=null) : array {
70
		if (empty($albumId)) {
71
			return [];
72
		} else {
73
			if (!\is_array($albumId)) {
74
				$albumId = [$albumId];
75
			}
76
			return $this->mapper->findAllByAlbum($albumId, $userId, $artistId, $limit, $offset);
77
		}
78
	}
79
80
	/**
81
	 * Returns all tracks filtered by parent folder
82
	 * @return Track[]
83
	 */
84
	public function findAllByFolder(int $folderId, string $userId, ?int $limit=null, ?int $offset=null) : array {
85
		return $this->mapper->findAllByFolder($folderId, $userId, $limit, $offset);
86
	}
87
88
	/**
89
	 * Returns all tracks filtered by genre
90
	 * @return Track[]
91
	 */
92
	public function findAllByGenre(int $genreId, string $userId, ?int $limit=null, ?int $offset=null) : array {
93
		return $this->mapper->findAllByGenre($genreId, $userId, $limit, $offset);
94
	}
95
96
	/**
97
	 * Returns all tracks filtered by name (of track/album/artist)
98
	 * @param string $name the name of the track/album/artist
99
	 * @param string $userId the name of the user
100
	 * @return Track[]
101
	 */
102
	public function findAllByNameRecursive(string $name, string $userId, ?int $limit=null, ?int $offset=null) : array {
103
		$name = \trim($name);
104
		return $this->mapper->findAllByNameRecursive($name, $userId, $limit, $offset);
105
	}
106
107
	/**
108
	 * Returns all tracks specified by name, artist name, and/or album name
109
	 * @return Track[] Tracks matching the criteria
110
	 */
111
	public function findAllByNameArtistOrAlbum(?string $name, ?string $artistName, ?string $albumName, string $userId) : array {
112
		if ($name !== null) {
113
			$name = \trim($name);
114
		}
115
		if ($artistName !== null) {
116
			$artistName = \trim($artistName);
117
		}
118
119
		return $this->mapper->findAllByNameArtistOrAlbum($name, $artistName, $albumName, $userId);
120
	}
121
122
	/**
123
	 * Find most frequently played tracks
124
	 * @return Track[]
125
	 */
126
	public function findFrequentPlay(string $userId, ?int $limit=null, ?int $offset=null) : array {
127
		return $this->mapper->findFrequentPlay($userId, $limit, $offset);
128
	}
129
130
	/**
131
	 * Find most recently played tracks
132
	 * @return Track[]
133
	 */
134
	public function findRecentPlay(string $userId, ?int $limit=null, ?int $offset=null) : array {
135
		return $this->mapper->findRecentPlay($userId, $limit, $offset);
136
	}
137
138
	/**
139
	 * Find least recently played tracks
140
	 * @return Track[]
141
	 */
142
	public function findNotRecentPlay(string $userId, ?int $limit=null, ?int $offset=null) : array {
143
		return $this->mapper->findNotRecentPlay($userId, $limit, $offset);
144
	}
145
146
	/**
147
	 * Returns the track for a file id
148
	 * @return Track|null
149
	 */
150
	public function findByFileId(int $fileId, string $userId) : ?Track {
151
		try {
152
			return $this->mapper->findByFileId($fileId, $userId);
153
		} catch (DoesNotExistException $e) {
154
			return null;
155
		}
156
	}
157
158
	/**
159
	 * Returns file IDs of all indexed tracks of the user
160
	 * @return int[]
161
	 */
162
	public function findAllFileIds(string $userId) : array {
163
		return $this->mapper->findAllFileIds($userId);
164
	}
165
166
	/**
167
	 * Returns file IDs of all indexed tracks of the user which should be rescanned to ensure that the library details are up-to-date.
168
	 * The track may be considered "dirty" for one of two reasons:
169
	 * - its 'modified' time in the file system (actually in the cloud's file cache) is later than the 'updated' field of the entity in the database
170
	 * - it has been specifically marked as dirty, maybe in response to being moved to another directory
171
	 * @return int[]
172
	 */
173
	public function findDirtyFileIds(string $userId) : array {
174
		return $this->mapper->findDirtyFileIds($userId);
175
	}
176
177
	/**
178
	 * Returns all folders of the user containing indexed tracks, along with the contained track IDs
179
	 * @return array of entries like {id: int, name: string, parent: ?int, trackIds: int[]}
180
	 */
181
	public function findAllFolders(string $userId, Folder $musicFolder) : array {
182
		// All tracks of the user, grouped by their parent folders. Some of the parent folders
183
		// may be owned by other users and are invisible to this user (in case of shared files).
184
		$trackIdsByFolder = $this->mapper->findTrackAndFolderIds($userId);
185
		$foldersLut = $this->getFoldersLut($trackIdsByFolder, $userId, $musicFolder);
186
		return \array_map(
187
			fn($id, $folderInfo) => \array_merge($folderInfo, ['id' => $id]),
188
			\array_keys($foldersLut), $foldersLut
189
		);
190
	}
191
192
	/**
193
	 * @param Track[] $tracks (in|out)
194
	 */
195
	public function injectFolderPathsToTracks(array $tracks, string $userId, Folder $musicFolder) : void {
196
		$folderIds = \array_map(fn($t) => $t->getFolderId(), $tracks);
197
		$folderIds = \array_unique($folderIds);
198
		$trackIdsByFolder = \array_fill_keys($folderIds, []); // track IDs are not actually used here so we can use empty arrays
199
200
		$foldersLut = $this->getFoldersLut($trackIdsByFolder, $userId, $musicFolder);
201
202
		// recursive helper to get folder's path and cache all parent paths on the way
203
		$getFolderPath = function(int $id, array &$foldersLut) use (&$getFolderPath) : string {
204
			// setup the path if not cached already
205
			if (!isset($foldersLut[$id]['path'])) {
206
				$parentId = $foldersLut[$id]['parent'];
207
				if ($parentId === null) {
208
					$foldersLut[$id]['path'] = '';
209
				} else {
210
					$foldersLut[$id]['path'] = $getFolderPath($parentId, $foldersLut) . '/' . $foldersLut[$id]['name'];
211
				}
212
			}
213
			return $foldersLut[$id]['path'];
214
		};
215
216
		foreach ($tracks as $track) {
217
			$track->setFolderPath($getFolderPath($track->getFolderId(), $foldersLut));
218
		}
219
	}
220
221
	/**
222
	 * Get folder info lookup table, for the given tracks. The table will contain all the predecessor folders
223
	 * between those tracks and the root music folder (inclusive).
224
	 * 
225
	 * @param array $trackIdsByFolder Keys are folder IDs and values are arrays of track IDs
226
	 * @return array Keys are folder IDs and values are arrays like ['name' : string, 'parent' : int, 'trackIds' : int[]]
227
	 */
228
	private function getFoldersLut(array $trackIdsByFolder, string $userId, Folder $musicFolder) : array {
229
		// Get the folder names and direct parent folder IDs directly from the DB.
230
		// This is significantly more efficient than using the Files API because we need to
231
		// run only single DB query instead of one per folder.
232
		$folderNamesAndParents = $this->mapper->findNodeNamesAndParents(\array_keys($trackIdsByFolder));
233
234
		// Compile the look-up-table entries from our two intermediary arrays
235
		$lut = [];
236
		foreach ($trackIdsByFolder as $folderId => $trackIds) {
237
			// $folderId is not found from $folderNamesAndParents if it's a dummy ID created as placeholder on a malformed playlist
238
			$nameAndParent = $folderNamesAndParents[$folderId] ?? ['name' => '', 'parent' => null];
239
			$lut[$folderId] = \array_merge($nameAndParent, ['trackIds' => $trackIds]);
240
		}
241
242
		// the root folder should have null parent; here we also ensure it's included
243
		$rootFolderId = $musicFolder->getId();
244
		$rootTracks = $lut[$rootFolderId]['trackIds'] ?? [];
245
		$lut[$rootFolderId] = ['name' => '', 'parent' => null, 'trackIds' => $rootTracks];
246
247
		// External mounts and shared files/folders need some special handling. But if there are any, they should be found
248
		// right under the top-level folder.
249
		$this->addExternalMountsToFoldersLut($lut, $userId, $musicFolder);
250
251
		// Add the intermediate folders which do not directly contain any tracks
252
		$this->addMissingParentsToFoldersLut($lut);
253
254
		return $lut;
255
	}
256
257
	/**
258
	 * Add externally mounted folders and shared files and folders to the folder LUT if there are any under the $musicFolder
259
	 * 
260
	 * @param array $lut (in|out) Keys are folder IDs and values are arrays like ['name' : string, 'parent' : int, 'trackIds' : int[]]
261
	 */
262
	private function addExternalMountsToFoldersLut(array &$lut, string $userId, Folder $musicFolder) : void {
263
		$nodesUnderRoot = $musicFolder->getDirectoryListing();
264
		$homeStorageId = $musicFolder->getStorage()->getId();
265
		$rootFolderId = $musicFolder->getId();
266
267
		foreach ($nodesUnderRoot as $node) {
268
			if ($node->getStorage()->getId() != $homeStorageId) {
269
				// shared file/folder or external mount
270
				if ($node->getType() == FileInfo::TYPE_FOLDER) {
271
					// The mount point folders are always included in the result. At this time, we don't know if
272
					// they actually contain any tracks, unless they have direct track children. If there are direct tracks,
273
					// then the parent ID is incorrectly set and needs to be overridden.
274
					$trackIds = $lut[$node->getId()]['trackIds'] ?? [];
275
					$lut[$node->getId()] = ['name' => $node->getName(), 'parent' => $rootFolderId, 'trackIds' => $trackIds];
276
277
				} else if ($node->getMimePart() == 'audio') {
278
					// shared audio file, check if it's actually a scanned file in our library
279
					$sharedTrack = $this->findByFileId($node->getId(), $userId);
280
					if ($sharedTrack !== null) {
281
282
						$trackId = $sharedTrack->getId();
283
						foreach ($lut as $folderId => &$entry) {
284
							$trackIdIdx = \array_search($trackId, $entry['trackIds']);
285
							if ($trackIdIdx !== false) {
286
								// move the track from it's actual parent (in other user's storage) to our root
287
								unset($entry['trackIds'][$trackIdIdx]);
288
								$lut[$rootFolderId]['trackIds'][] = $trackId;
289
290
								// remove the former parent folder if it has no more tracks and it's not one of the mount point folders
291
								if (\count($entry['trackIds']) == 0 && empty(\array_filter($nodesUnderRoot, fn($n) => $n->getId() == $folderId))) {
292
									unset($lut[$folderId]);
293
								}
294
								break;
295
							}
296
						}
297
					}
298
				}
299
			}
300
		}
301
	}
302
303
	/**
304
	 * Add any missing intermediary folder to the LUT. For this function to work correctly, the pre-condition is that the LUT contains
305
	 * a root node which is predecessor of all other contained nodes and has 'parent' set as null.
306
	 * 
307
	 * @param array $lut (in|out) Keys are folder IDs and values are arrays like ['name' : string, 'parent' : int, 'trackIds' : int[]]
308
	 */
309
	private function addMissingParentsToFoldersLut(array &$lut) : void {
310
		$foldersToProcess = $lut;
311
312
		while (\count($foldersToProcess)) {
313
			$parentIds = \array_unique(\array_column($foldersToProcess, 'parent'));
314
			// do not process root even if it's included in $foldersToProcess
315
			$parentIds = \array_filter($parentIds, fn($i) => $i !== null);
316
			$parentIds = ArrayUtil::diff($parentIds, \array_keys($lut));
317
			$parentFolders = $this->mapper->findNodeNamesAndParents($parentIds);
318
319
			$foldersToProcess = [];
320
			foreach ($parentFolders as $folderId => $nameAndParent) {
321
				$foldersToProcess[] = $lut[$folderId] = \array_merge($nameAndParent, ['trackIds' => []]);
322
			}
323
		}
324
	}
325
326
	/**
327
	 * Returns all genre IDs associated with the given artist
328
	 * @return int[]
329
	 */
330
	public function getGenresByArtistId(int $artistId, string $userId) : array {
331
		return $this->mapper->getGenresByArtistId($artistId, $userId);
332
	}
333
334
	/**
335
	 * Returns file IDs of the tracks which do not have genre scanned. This is not the same
336
	 * thing as unknown genre, which is stored as empty string and means that the genre has
337
	 * been scanned but was not found from the track metadata.
338
	 * @return int[]
339
	 */
340
	public function findFilesWithoutScannedGenre(string $userId) : array {
341
		return $this->mapper->findFilesWithoutScannedGenre($userId);
342
	}
343
344
	public function countByArtist(int $artistId) : int {
345
		return $this->mapper->countByArtist($artistId);
346
	}
347
348
	public function countByAlbum(int $albumId) : int {
349
		return $this->mapper->countByAlbum($albumId);
350
	}
351
352
	/**
353
	 * @return integer Duration in seconds
354
	 */
355
	public function totalDurationOfAlbum(int $albumId) : int {
356
		return $this->mapper->totalDurationOfAlbum($albumId);
357
	}
358
359
	/**
360
	 * @return integer Duration in seconds
361
	 */
362
	public function totalDurationByArtist(int $artistId) : int {
363
		return $this->mapper->totalDurationByArtist($artistId);
364
	}
365
366
	/**
367
	 * Update "last played" timestamp and increment the total play count of the track.
368
	 */
369
	public function recordTrackPlayed(int $trackId, string $userId, ?\DateTime $timeOfPlay = null) : void {
370
		$timeOfPlay = $timeOfPlay ?? new \DateTime();
371
372
		if (!$this->mapper->recordTrackPlayed($trackId, $userId, $timeOfPlay)) {
373
			throw new BusinessLayerException("Track with ID $trackId was not found");
374
		}
375
	}
376
377
	/**
378
	 * Adds a track if it does not exist already or updates an existing track
379
	 * @param string $title the title of the track
380
	 * @param int|null $number the number of the track
381
	 * @param int|null $discNumber the number of the disc
382
	 * @param int|null $year the year of the release
383
	 * @param int $genreId the genre id of the track
384
	 * @param int $artistId the artist id of the track
385
	 * @param int $albumId the album id of the track
386
	 * @param int $fileId the file id of the track
387
	 * @param string $mimetype the mimetype of the track
388
	 * @param string $userId the name of the user
389
	 * @param int $length track length in seconds
390
	 * @param int $bitrate track bitrate in bits (not kbits)
391
	 * @return Track The added/updated track
392
	 */
393
	public function addOrUpdateTrack(
394
			string $title, ?int $number, ?int $discNumber, ?int $year, int $genreId, int $artistId, int $albumId,
395
			int $fileId, string $mimetype, string $userId, ?int $length=null, ?int $bitrate=null) : Track {
396
		$track = new Track();
397
		$track->setTitle(StringUtil::truncate($title, 256)); // some DB setups can't truncate automatically to column max size
398
		$track->setNumber($number);
399
		$track->setDisk($discNumber);
400
		$track->setYear($year);
401
		$track->setGenreId($genreId);
402
		$track->setArtistId($artistId);
403
		$track->setAlbumId($albumId);
404
		$track->setFileId($fileId);
405
		$track->setMimetype($mimetype);
406
		$track->setUserId($userId);
407
		$track->setLength($length);
408
		$track->setBitrate($bitrate);
409
		$track->setDirty(0);
410
		return $this->mapper->insertOrUpdate($track);
411
	}
412
413
	/**
414
	 * Deletes tracks
415
	 * @param int[] $fileIds file IDs of the tracks to delete
416
	 * @param string[]|null $userIds the target users; if omitted, the tracks matching the
417
	 *                      $fileIds are deleted from all users
418
	 * @return array|false  False is returned if no such track was found; otherwise array of six arrays
419
	 *         (named 'deletedTracks', 'remainingAlbums', 'remainingArtists', 'obsoleteAlbums',
420
	 *         'obsoleteArtists', and 'affectedUsers'). These contain the track, album, artist, and
421
	 *         user IDs of the deleted tracks. The 'obsolete' entities are such which no longer
422
	 *         have any tracks while 'remaining' entities have some left.
423
	 */
424
	public function deleteTracks(array $fileIds, ?array $userIds=null) {
425
		$tracks = ($userIds !== null)
426
			? $this->mapper->findByFileIds($fileIds, $userIds)
427
			: $this->mapper->findAllByFileIds($fileIds);
428
429
		if (\count($tracks) === 0) {
430
			$result = false;
431
		} else {
432
			// delete all the matching tracks
433
			$trackIds = ArrayUtil::extractIds($tracks);
434
			$this->deleteById($trackIds);
435
436
			// find all distinct albums, artists, and users of the deleted tracks
437
			$artists = [];
438
			$albums = [];
439
			$users = [];
440
			foreach ($tracks as $track) {
441
				$artists[$track->getArtistId()] = 1;
442
				$albums[$track->getAlbumId()] = 1;
443
				$users[$track->getUserId()] = 1;
444
			}
445
			$artists = \array_keys($artists);
446
			$albums = \array_keys($albums);
447
			$users = \array_keys($users);
448
449
			// categorize each artist as 'remaining' or 'obsolete'
450
			$remainingArtists = [];
451
			$obsoleteArtists = [];
452
			foreach ($artists as $artistId) {
453
				if ($this->mapper->countByArtist($artistId) === 0) {
454
					$obsoleteArtists[] = $artistId;
455
				} else {
456
					$remainingArtists[] = $artistId;
457
				}
458
			}
459
460
			// categorize each album as 'remaining' or 'obsolete'
461
			$remainingAlbums = [];
462
			$obsoleteAlbums = [];
463
			foreach ($albums as $albumId) {
464
				if ($this->mapper->countByAlbum($albumId) === 0) {
465
					$obsoleteAlbums[] = $albumId;
466
				} else {
467
					$remainingAlbums[] = $albumId;
468
				}
469
			}
470
471
			$result = [
472
				'deletedTracks'    => $trackIds,
473
				'remainingAlbums'  => $remainingAlbums,
474
				'remainingArtists' => $remainingArtists,
475
				'obsoleteAlbums'   => $obsoleteAlbums,
476
				'obsoleteArtists'  => $obsoleteArtists,
477
				'affectedUsers'    => $users
478
			];
479
		}
480
481
		return $result;
482
	}
483
484
	/**
485
	 * Marks tracks as dirty, ultimately requesting the user to rescan them
486
	 * @param int[] $fileIds file IDs of the tracks to mark as dirty
487
	 * @param string[]|null $userIds the target users; if omitted, the tracks matching the
488
	 *                      $fileIds are marked for all users
489
	 */
490
	public function markTracksDirty(array $fileIds, ?array $userIds=null) : void {
491
		// be prepared for huge number of file IDs
492
		$chunkMaxSize = self::MAX_SQL_ARGS - \count($userIds ?? []);
493
		$idChunks = \array_chunk($fileIds, $chunkMaxSize);
494
		foreach ($idChunks as $idChunk) {
495
			$this->mapper->markTracksDirty($idChunk, $userIds);
496
		}
497
	}
498
}
499