Passed
Push — master ( f2aded...94d37f )
by Pauli
03:52
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
	 * @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