Passed
Push — master ( 041459...b27899 )
by Pauli
03:55
created

TrackBusinessLayer::addOrUpdateTrack()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 18
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 15
nc 1
nop 12
dl 0
loc 18
rs 9.7666
c 0
b 0
f 0

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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
	 * Optionally, limit the search to files residing (directly or indirectly) in the given folder.
161
	 * @return int[]
162
	 */
163
	public function findAllFileIds(string $userId, ?int $folderId=null) : array {
164
		$parentIds = ($folderId !== null) ? $this->findAllDescendantFolders($folderId) : null;
165
		return $this->mapper->findAllFileIds($userId, $parentIds);
166
	}
167
168
	/**
169
	 * Returns file IDs of all indexed tracks of the user which should be rescanned to ensure that the library details are up-to-date.
170
	 * The track may be considered "dirty" for one of two reasons:
171
	 * - 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
172
	 * - it has been specifically marked as dirty, maybe in response to being moved to another directory
173
	 * Optionally, limit the search to files residing (directly or indirectly) in the given folder.
174
	 * @return int[]
175
	 */
176
	public function findDirtyFileIds(string $userId, ?int $folderId=null) : array {
177
		$parentIds = ($folderId !== null) ? $this->findAllDescendantFolders($folderId) : null;
178
		return $this->mapper->findDirtyFileIds($userId, $parentIds);
179
	}
180
181
	/**
182
	 * Returns all folders of the user containing indexed tracks, along with the contained track IDs
183
	 * @return array of entries like {id: int, name: string, parent: ?int, trackIds: int[]}
184
	 */
185
	public function findAllFolders(string $userId, Folder $musicFolder) : array {
186
		// All tracks of the user, grouped by their parent folders. Some of the parent folders
187
		// may be owned by other users and are invisible to this user (in case of shared files).
188
		$trackIdsByFolder = $this->mapper->findTrackAndFolderIds($userId);
189
		$foldersLut = $this->getFoldersLut($trackIdsByFolder, $userId, $musicFolder);
190
		return \array_map(
191
			fn($id, $folderInfo) => \array_merge($folderInfo, ['id' => $id]),
192
			\array_keys($foldersLut), $foldersLut
193
		);
194
	}
195
196
	/**
197
	 * @param Track[] $tracks (in|out)
198
	 */
199
	public function injectFolderPathsToTracks(array $tracks, string $userId, Folder $musicFolder) : void {
200
		$folderIds = \array_map(fn($t) => $t->getFolderId(), $tracks);
201
		$folderIds = \array_unique($folderIds);
202
		$trackIdsByFolder = \array_fill_keys($folderIds, []); // track IDs are not actually used here so we can use empty arrays
203
204
		$foldersLut = $this->getFoldersLut($trackIdsByFolder, $userId, $musicFolder);
205
206
		// recursive helper to get folder's path and cache all parent paths on the way
207
		$getFolderPath = function(int $id, array &$foldersLut) use (&$getFolderPath) : string {
208
			// setup the path if not cached already
209
			if (!isset($foldersLut[$id]['path'])) {
210
				$parentId = $foldersLut[$id]['parent'];
211
				if ($parentId === null) {
212
					$foldersLut[$id]['path'] = '';
213
				} else {
214
					$foldersLut[$id]['path'] = $getFolderPath($parentId, $foldersLut) . '/' . $foldersLut[$id]['name'];
215
				}
216
			}
217
			return $foldersLut[$id]['path'];
218
		};
219
220
		foreach ($tracks as $track) {
221
			$track->setFolderPath($getFolderPath($track->getFolderId(), $foldersLut));
222
		}
223
	}
224
225
	/**
226
	 * Get folder info lookup table, for the given tracks. The table will contain all the predecessor folders
227
	 * between those tracks and the root music folder (inclusive).
228
	 * 
229
	 * @param array $trackIdsByFolder Keys are folder IDs and values are arrays of track IDs
230
	 * @return array Keys are folder IDs and values are arrays like ['name' : string, 'parent' : int, 'trackIds' : int[]]
231
	 */
232
	private function getFoldersLut(array $trackIdsByFolder, string $userId, Folder $musicFolder) : array {
233
		// Get the folder names and direct parent folder IDs directly from the DB.
234
		// This is significantly more efficient than using the Files API because we need to
235
		// run only single DB query instead of one per folder.
236
		$folderNamesAndParents = $this->mapper->findNodeNamesAndParents(\array_keys($trackIdsByFolder));
237
238
		// Compile the look-up-table entries from our two intermediary arrays
239
		$lut = [];
240
		foreach ($trackIdsByFolder as $folderId => $trackIds) {
241
			// $folderId is not found from $folderNamesAndParents if it's a dummy ID created as placeholder on a malformed playlist
242
			$nameAndParent = $folderNamesAndParents[$folderId] ?? ['name' => '', 'parent' => null];
243
			$lut[$folderId] = \array_merge($nameAndParent, ['trackIds' => $trackIds]);
244
		}
245
246
		// the root folder should have null parent; here we also ensure it's included
247
		$rootFolderId = $musicFolder->getId();
248
		$rootTracks = $lut[$rootFolderId]['trackIds'] ?? [];
249
		$lut[$rootFolderId] = ['name' => '', 'parent' => null, 'trackIds' => $rootTracks];
250
251
		// External mounts and shared files/folders need some special handling. But if there are any, they should be found
252
		// right under the top-level folder.
253
		$this->addExternalMountsToFoldersLut($lut, $userId, $musicFolder);
254
255
		// Add the intermediate folders which do not directly contain any tracks
256
		$this->addMissingParentsToFoldersLut($lut);
257
258
		return $lut;
259
	}
260
261
	/**
262
	 * Add externally mounted folders and shared files and folders to the folder LUT if there are any under the $musicFolder
263
	 * 
264
	 * @param array $lut (in|out) Keys are folder IDs and values are arrays like ['name' : string, 'parent' : int, 'trackIds' : int[]]
265
	 */
266
	private function addExternalMountsToFoldersLut(array &$lut, string $userId, Folder $musicFolder) : void {
267
		$nodesUnderRoot = $musicFolder->getDirectoryListing();
268
		$homeStorageId = $musicFolder->getStorage()->getId();
269
		$rootFolderId = $musicFolder->getId();
270
271
		foreach ($nodesUnderRoot as $node) {
272
			if ($node->getStorage()->getId() != $homeStorageId) {
273
				// shared file/folder or external mount
274
				if ($node->getType() == FileInfo::TYPE_FOLDER) {
275
					// The mount point folders are always included in the result. At this time, we don't know if
276
					// they actually contain any tracks, unless they have direct track children. If there are direct tracks,
277
					// then the parent ID is incorrectly set and needs to be overridden.
278
					$trackIds = $lut[$node->getId()]['trackIds'] ?? [];
279
					$lut[$node->getId()] = ['name' => $node->getName(), 'parent' => $rootFolderId, 'trackIds' => $trackIds];
280
281
				} else if ($node->getMimePart() == 'audio') {
282
					// shared audio file, check if it's actually a scanned file in our library
283
					$sharedTrack = $this->findByFileId($node->getId(), $userId);
284
					if ($sharedTrack !== null) {
285
286
						$trackId = $sharedTrack->getId();
287
						foreach ($lut as $folderId => &$entry) {
288
							$trackIdIdx = \array_search($trackId, $entry['trackIds']);
289
							if ($trackIdIdx !== false) {
290
								// move the track from it's actual parent (in other user's storage) to our root
291
								unset($entry['trackIds'][$trackIdIdx]);
292
								$lut[$rootFolderId]['trackIds'][] = $trackId;
293
294
								// remove the former parent folder if it has no more tracks and it's not one of the mount point folders
295
								if (\count($entry['trackIds']) == 0 && empty(\array_filter($nodesUnderRoot, fn($n) => $n->getId() == $folderId))) {
296
									unset($lut[$folderId]);
297
								}
298
								break;
299
							}
300
						}
301
					}
302
				}
303
			}
304
		}
305
	}
306
307
	/**
308
	 * Add any missing intermediary folder to the LUT. For this function to work correctly, the pre-condition is that the LUT contains
309
	 * a root node which is predecessor of all other contained nodes and has 'parent' set as null.
310
	 * 
311
	 * @param array $lut (in|out) Keys are folder IDs and values are arrays like ['name' : string, 'parent' : int, 'trackIds' : int[]]
312
	 */
313
	private function addMissingParentsToFoldersLut(array &$lut) : void {
314
		$foldersToProcess = $lut;
315
316
		while (\count($foldersToProcess)) {
317
			$parentIds = \array_unique(\array_column($foldersToProcess, 'parent'));
318
			// do not process root even if it's included in $foldersToProcess
319
			$parentIds = \array_filter($parentIds, fn($i) => $i !== null);
320
			$parentIds = ArrayUtil::diff($parentIds, \array_keys($lut));
321
			$parentFolders = $this->mapper->findNodeNamesAndParents($parentIds);
322
323
			$foldersToProcess = [];
324
			foreach ($parentFolders as $folderId => $nameAndParent) {
325
				$foldersToProcess[] = $lut[$folderId] = \array_merge($nameAndParent, ['trackIds' => []]);
326
			}
327
		}
328
	}
329
330
	/**
331
	 * Find all direct and indirect sub folders of the given folder. The result will include also the start folder.
332
	 * NOTE: This does not return the mounted or shared folders even in case the $folderId points to user home directory.
333
	 * @return int[]
334
	 */
335
	private function findAllDescendantFolders(int $folderId) : array {
336
		$descendants = [];
337
		$foldersToProcess = [$folderId];
338
339
		while(\count($foldersToProcess)) {
340
			$descendants = \array_merge($descendants, $foldersToProcess);
341
			$foldersToProcess = $this->mapper->findSubFolderIds($foldersToProcess);
342
		}
343
344
		return $descendants;
345
	}
346
347
	/**
348
	 * Returns all genre IDs associated with the given artist
349
	 * @return int[]
350
	 */
351
	public function getGenresByArtistId(int $artistId, string $userId) : array {
352
		return $this->mapper->getGenresByArtistId($artistId, $userId);
353
	}
354
355
	/**
356
	 * Returns file IDs of the tracks which do not have genre scanned. This is not the same
357
	 * thing as unknown genre, which is stored as empty string and means that the genre has
358
	 * been scanned but was not found from the track metadata.
359
	 * @return int[]
360
	 */
361
	public function findFilesWithoutScannedGenre(string $userId) : array {
362
		return $this->mapper->findFilesWithoutScannedGenre($userId);
363
	}
364
365
	public function countByArtist(int $artistId) : int {
366
		return $this->mapper->countByArtist($artistId);
367
	}
368
369
	public function countByAlbum(int $albumId) : int {
370
		return $this->mapper->countByAlbum($albumId);
371
	}
372
373
	/**
374
	 * @return integer Duration in seconds
375
	 */
376
	public function totalDurationOfAlbum(int $albumId) : int {
377
		return $this->mapper->totalDurationOfAlbum($albumId);
378
	}
379
380
	/**
381
	 * @return integer Duration in seconds
382
	 */
383
	public function totalDurationByArtist(int $artistId) : int {
384
		return $this->mapper->totalDurationByArtist($artistId);
385
	}
386
387
	/**
388
	 * Update "last played" timestamp and increment the total play count of the track.
389
	 */
390
	public function recordTrackPlayed(int $trackId, string $userId, ?\DateTime $timeOfPlay = null) : void {
391
		$timeOfPlay = $timeOfPlay ?? new \DateTime();
392
393
		if (!$this->mapper->recordTrackPlayed($trackId, $userId, $timeOfPlay)) {
394
			throw new BusinessLayerException("Track with ID $trackId was not found");
395
		}
396
	}
397
398
	/**
399
	 * Adds a track if it does not exist already or updates an existing track
400
	 * @param string $title the title of the track
401
	 * @param int|null $number the number of the track
402
	 * @param int|null $discNumber the number of the disc
403
	 * @param int|null $year the year of the release
404
	 * @param int $genreId the genre id of the track
405
	 * @param int $artistId the artist id of the track
406
	 * @param int $albumId the album id of the track
407
	 * @param int $fileId the file id of the track
408
	 * @param string $mimetype the mimetype of the track
409
	 * @param string $userId the name of the user
410
	 * @param int $length track length in seconds
411
	 * @param int $bitrate track bitrate in bits (not kbits)
412
	 * @return Track The added/updated track
413
	 */
414
	public function addOrUpdateTrack(
415
			string $title, ?int $number, ?int $discNumber, ?int $year, int $genreId, int $artistId, int $albumId,
416
			int $fileId, string $mimetype, string $userId, ?int $length=null, ?int $bitrate=null) : Track {
417
		$track = new Track();
418
		$track->setTitle(StringUtil::truncate($title, 256)); // some DB setups can't truncate automatically to column max size
419
		$track->setNumber($number);
420
		$track->setDisk($discNumber);
421
		$track->setYear($year);
422
		$track->setGenreId($genreId);
423
		$track->setArtistId($artistId);
424
		$track->setAlbumId($albumId);
425
		$track->setFileId($fileId);
426
		$track->setMimetype($mimetype);
427
		$track->setUserId($userId);
428
		$track->setLength($length);
429
		$track->setBitrate($bitrate);
430
		$track->setDirty(0);
431
		return $this->mapper->insertOrUpdate($track);
432
	}
433
434
	/**
435
	 * Deletes tracks
436
	 * @param int[] $fileIds file IDs of the tracks to delete
437
	 * @param string[]|null $userIds the target users; if omitted, the tracks matching the
438
	 *                      $fileIds are deleted from all users
439
	 * @return array|false  False is returned if no such track was found; otherwise array of six arrays
440
	 *         (named 'deletedTracks', 'remainingAlbums', 'remainingArtists', 'obsoleteAlbums',
441
	 *         'obsoleteArtists', and 'affectedUsers'). These contain the track, album, artist, and
442
	 *         user IDs of the deleted tracks. The 'obsolete' entities are such which no longer
443
	 *         have any tracks while 'remaining' entities have some left.
444
	 */
445
	public function deleteTracks(array $fileIds, ?array $userIds=null) {
446
		$tracks = ($userIds !== null)
447
			? $this->mapper->findByFileIds($fileIds, $userIds)
448
			: $this->mapper->findAllByFileIds($fileIds);
449
450
		if (\count($tracks) === 0) {
451
			$result = false;
452
		} else {
453
			// delete all the matching tracks
454
			$trackIds = ArrayUtil::extractIds($tracks);
455
			$this->deleteById($trackIds);
456
457
			// find all distinct albums, artists, and users of the deleted tracks
458
			$artists = [];
459
			$albums = [];
460
			$users = [];
461
			foreach ($tracks as $track) {
462
				$artists[$track->getArtistId()] = 1;
463
				$albums[$track->getAlbumId()] = 1;
464
				$users[$track->getUserId()] = 1;
465
			}
466
			$artists = \array_keys($artists);
467
			$albums = \array_keys($albums);
468
			$users = \array_keys($users);
469
470
			// categorize each artist as 'remaining' or 'obsolete'
471
			$remainingArtists = [];
472
			$obsoleteArtists = [];
473
			foreach ($artists as $artistId) {
474
				if ($this->mapper->countByArtist($artistId) === 0) {
475
					$obsoleteArtists[] = $artistId;
476
				} else {
477
					$remainingArtists[] = $artistId;
478
				}
479
			}
480
481
			// categorize each album as 'remaining' or 'obsolete'
482
			$remainingAlbums = [];
483
			$obsoleteAlbums = [];
484
			foreach ($albums as $albumId) {
485
				if ($this->mapper->countByAlbum($albumId) === 0) {
486
					$obsoleteAlbums[] = $albumId;
487
				} else {
488
					$remainingAlbums[] = $albumId;
489
				}
490
			}
491
492
			$result = [
493
				'deletedTracks'    => $trackIds,
494
				'remainingAlbums'  => $remainingAlbums,
495
				'remainingArtists' => $remainingArtists,
496
				'obsoleteAlbums'   => $obsoleteAlbums,
497
				'obsoleteArtists'  => $obsoleteArtists,
498
				'affectedUsers'    => $users
499
			];
500
		}
501
502
		return $result;
503
	}
504
505
	/**
506
	 * Marks tracks as dirty, ultimately requesting the user to rescan them
507
	 * @param int[] $fileIds file IDs of the tracks to mark as dirty
508
	 * @param string[]|null $userIds the target users; if omitted, the tracks matching the
509
	 *                      $fileIds are marked for all users
510
	 */
511
	public function markTracksDirty(array $fileIds, ?array $userIds=null) : void {
512
		// be prepared for huge number of file IDs
513
		$chunkMaxSize = self::MAX_SQL_ARGS - \count($userIds ?? []);
514
		$idChunks = \array_chunk($fileIds, $chunkMaxSize);
515
		foreach ($idChunks as $idChunk) {
516
			$this->mapper->markTracksDirty($idChunk, $userIds);
517
		}
518
	}
519
}
520