Passed
Push — master ( c7cb4e...ce9a66 )
by Pauli
03:03
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 - 2024
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\TrackMapper;
22
use OCA\Music\Db\Track;
23
24
use OCA\Music\Utility\Util;
25
26
use OCP\AppFramework\Db\DoesNotExistException;
27
use OCP\Files\Folder;
28
29
/**
30
 * Base class functions with the actually used inherited types to help IDE and Scrutinizer:
31
 * @method Track find(int $trackId, string $userId)
32
 * @method Track[] findAll(string $userId, int $sortBy=SortBy::Name, int $limit=null, int $offset=null)
33
 * @method Track[] findAllByName(string $name, string $userId, int $matchMode=MatchMode::Exact, int $limit=null, int $offset=null)
34
 * @phpstan-extends BusinessLayer<Track>
35
 */
36
class TrackBusinessLayer extends BusinessLayer {
37
	protected $mapper; // eclipse the definition from the base class, to help IDE and Scrutinizer to know the actual type
38
	private $logger;
39
40
	public function __construct(TrackMapper $trackMapper, Logger $logger) {
41
		parent::__construct($trackMapper);
42
		$this->mapper = $trackMapper;
43
		$this->logger = $logger;
44
	}
45
46
	/**
47
	 * Returns all tracks filtered by artist (both album and track artists are considered)
48
	 * @param int|int[] $artistId
49
	 * @return Track[]
50
	 */
51
	public function findAllByArtist(/*mixed*/ $artistId, string $userId, ?int $limit=null, ?int $offset=null) : array {
52
		if (empty($artistId)) {
53
			return [];
54
		} else {
55
			if (!\is_array($artistId)) {
56
				$artistId = [$artistId];
57
			}
58
			return $this->mapper->findAllByArtist($artistId, $userId, $limit, $offset);
59
		}
60
	}
61
62
	/**
63
	 * Returns all tracks filtered by album. Optionally, filter also by the performing artist.
64
	 * @param int|int[] $albumId
65
	 * @return Track[]
66
	 */
67
	public function findAllByAlbum(/*mixed*/ $albumId, string $userId, ?int $artistId=null, ?int $limit=null, ?int $offset=null) : array {
68
		if (empty($albumId)) {
69
			return [];
70
		} else {
71
			if (!\is_array($albumId)) {
72
				$albumId = [$albumId];
73
			}
74
			return $this->mapper->findAllByAlbum($albumId, $userId, $artistId, $limit, $offset);
75
		}
76
	}
77
78
	/**
79
	 * Returns all tracks filtered by parent folder
80
	 * @return Track[]
81
	 */
82
	public function findAllByFolder(int $folderId, string $userId, ?int $limit=null, ?int $offset=null) : array {
83
		return $this->mapper->findAllByFolder($folderId, $userId, $limit, $offset);
84
	}
85
86
	/**
87
	 * Returns all tracks filtered by genre
88
	 * @return Track[]
89
	 */
90
	public function findAllByGenre(int $genreId, string $userId, ?int $limit=null, ?int $offset=null) : array {
91
		return $this->mapper->findAllByGenre($genreId, $userId, $limit, $offset);
92
	}
93
94
	/**
95
	 * Returns all tracks filtered by name (of track/album/artist)
96
	 * @param string $name the name of the track/album/artist
97
	 * @param string $userId the name of the user
98
	 * @return Track[]
99
	 */
100
	public function findAllByNameRecursive(string $name, string $userId, ?int $limit=null, ?int $offset=null) : array {
101
		$name = \trim($name);
102
		return $this->mapper->findAllByNameRecursive($name, $userId, $limit, $offset);
103
	}
104
105
	/**
106
	 * Returns all tracks specified by name, artist name, and/or album name
107
	 * @return Track[] Tracks matching the criteria
108
	 */
109
	public function findAllByNameArtistOrAlbum(?string $name, ?string $artistName, ?string $albumName, string $userId) : array {
110
		if ($name !== null) {
111
			$name = \trim($name);
112
		}
113
		if ($artistName !== null) {
114
			$artistName = \trim($artistName);
115
		}
116
117
		return $this->mapper->findAllByNameArtistOrAlbum($name, $artistName, $albumName, $userId);
118
	}
119
120
	/**
121
	 * Returns all tracks of the user which should be rescanned to ensure that the library details are up-to-date.
122
	 * The track may be considered "dirty" for on of two reasons:
123
	 * - 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
124
	 * - it has been specifically marked as dirty, maybe in response to being moved to another directory
125
	 * @return Track[]
126
	 */
127
	public function findAllDirty(string $userId) : array {
128
		$tracks = $this->findAll($userId);
129
		return \array_filter($tracks, function (Track $track) {
130
			$dbModTime = new \DateTime($track->getUpdated());
131
			return ($track->getDirty() || $dbModTime->getTimestamp() < $track->getFileModTime());
132
		});
133
	}
134
135
	/**
136
	 * Find most frequently played tracks
137
	 * @return Track[]
138
	 */
139
	public function findFrequentPlay(string $userId, ?int $limit=null, ?int $offset=null) : array {
140
		return $this->mapper->findFrequentPlay($userId, $limit, $offset);
141
	}
142
143
	/**
144
	 * Find most recently played tracks
145
	 * @return Track[]
146
	 */
147
	public function findRecentPlay(string $userId, ?int $limit=null, ?int $offset=null) : array {
148
		return $this->mapper->findRecentPlay($userId, $limit, $offset);
149
	}
150
151
	/**
152
	 * Find least recently played tracks
153
	 * @return Track[]
154
	 */
155
	public function findNotRecentPlay(string $userId, ?int $limit=null, ?int $offset=null) : array {
156
		return $this->mapper->findNotRecentPlay($userId, $limit, $offset);
157
	}
158
159
	/**
160
	 * Returns the track for a file id
161
	 * @return Track|null
162
	 */
163
	public function findByFileId(int $fileId, string $userId) : ?Track {
164
		try {
165
			return $this->mapper->findByFileId($fileId, $userId);
166
		} catch (DoesNotExistException $e) {
167
			return null;
168
		}
169
	}
170
171
	/**
172
	 * Returns file IDs of all indexed tracks of the user
173
	 * @return int[]
174
	 */
175
	public function findAllFileIds(string $userId) : array {
176
		return $this->mapper->findAllFileIds($userId);
177
	}
178
179
	/**
180
	 * Returns all folders of the user containing indexed tracks, along with the contained track IDs
181
	 * @return array of entries like {id: int, name: string, path: string, parent: ?int, trackIds: int[]}
182
	 */
183
	public function findAllFolders(string $userId, Folder $musicFolder) : array {
184
		// All tracks of the user, grouped by their parent folders. Some of the parent folders
185
		// may be owned by other users and are invisible to this user (in case of shared files).
186
		$tracksByFolder = $this->mapper->findTrackAndFolderIds($userId);
187
188
		// Get the folder names and paths for ordinary local folders directly from the DB.
189
		// This is significantly more efficient than using the Files API because we need to
190
		// run only single DB query instead of one per folder.
191
		$folderNamesAndParents = $this->mapper->findNodeNamesAndParents(
192
				\array_keys($tracksByFolder), $musicFolder->getStorage()->getId());
193
194
		// root folder has to be handled as a special case because shared files from
195
		// many folders may be shown to this user mapped under the root folder
196
		$rootFolderTracks = [];
197
198
		// Build the final results. Use the previously fetched data for the ordinary
199
		// local folders and query the data through the Files API for the more special cases.
200
		$result = [];
201
		foreach ($tracksByFolder as $folderId => $trackIds) {
202
			$entry = self::getFolderEntry($folderNamesAndParents, $folderId, $trackIds, $musicFolder);
203
204
			if ($entry) {
205
				$result[] = $entry;
206
			} else {
207
				$rootFolderTracks = \array_merge($rootFolderTracks, $trackIds);
208
			}
209
		}
210
211
		// add the library root folder
212
		$result[] = [
213
			'name' => '',
214
			'parent' => null,
215
			'trackIds' => $rootFolderTracks,
216
			'id' => $musicFolder->getId()
217
		];
218
219
		// add the intermediate folders which do not directly contain any tracks
220
		$this->recursivelyAddMissingParentFolders($result, $result, $musicFolder);
221
222
		return $result;
223
	}
224
225
	private function recursivelyAddMissingParentFolders(array $foldersToProcess, array &$alreadyFoundFolders, Folder $musicFolder) : void {
226
227
		$parentIds = \array_unique(\array_column($foldersToProcess, 'parent'));
228
		$parentIds = Util::arrayDiff($parentIds, \array_column($alreadyFoundFolders, 'id'));
229
		$parentInfo = $this->mapper->findNodeNamesAndParents($parentIds, $musicFolder->getStorage()->getId());
230
231
		$newParents = [];
232
		foreach ($parentIds as $parentId) {
233
			if ($parentId !== null) {
234
				$parentEntry = self::getFolderEntry($parentInfo, $parentId, [], $musicFolder);
235
				if ($parentEntry !== null) {
236
					$newParents[] = $parentEntry;
237
				}
238
			}
239
		}
240
241
		$alreadyFoundFolders = \array_merge($alreadyFoundFolders, $newParents);
242
243
		if (\count($newParents)) {
244
			$this->recursivelyAddMissingParentFolders($newParents, $alreadyFoundFolders, $musicFolder);
245
		}
246
	}
247
248
	private static function getFolderEntry(array $folderNamesAndParents, int $folderId, array $trackIds, Folder $musicFolder) : ?array {
249
		$libRootId = $musicFolder->getId();
250
251
		if (isset($folderNamesAndParents[$folderId])) {
252
			// normal folder within the user home storage
253
			$entry = $folderNamesAndParents[$folderId];
254
			// special handling for the root folder
255
			if ($folderId === $libRootId) {
256
				$entry = null;
257
			}
258
		} else {
259
			// shared folder or parent folder of a shared file or an externally mounted folder
260
			$folderNode = $musicFolder->getById($folderId)[0] ?? null;
261
			if ($folderNode === null) {
262
				// other user's folder with files shared with this user (mapped under root)
263
				$entry = null;
264
			} else {
265
				$entry = [
266
					'name' => $folderNode->getName(),
267
					'parent' => $folderNode->getParent()->getId()
268
				];
269
			}
270
		}
271
272
		if ($entry) {
273
			$entry['trackIds'] = $trackIds;
274
			$entry['id'] = $folderId;
275
276
			if ($entry['id'] == $libRootId) {
277
				// the library root should be reported without a parent folder as that parent does not belong to the library
278
				$entry['parent'] = null;
279
			}
280
		}
281
282
		return $entry;
283
	}
284
285
	/**
286
	 * Returns all genre IDs associated with the given artist
287
	 * @return int[]
288
	 */
289
	public function getGenresByArtistId(int $artistId, string $userId) : array {
290
		return $this->mapper->getGenresByArtistId($artistId, $userId);
291
	}
292
293
	/**
294
	 * Returns file IDs of the tracks which do not have genre scanned. This is not the same
295
	 * thing as unknown genre, which is stored as empty string and means that the genre has
296
	 * been scanned but was not found from the track metadata.
297
	 * @return int[]
298
	 */
299
	public function findFilesWithoutScannedGenre(string $userId) : array {
300
		return $this->mapper->findFilesWithoutScannedGenre($userId);
301
	}
302
303
	public function countByArtist(int $artistId) : int {
304
		return $this->mapper->countByArtist($artistId);
305
	}
306
307
	public function countByAlbum(int $albumId) : int {
308
		return $this->mapper->countByAlbum($albumId);
309
	}
310
311
	/**
312
	 * @return integer Duration in seconds
313
	 */
314
	public function totalDurationOfAlbum(int $albumId) : int {
315
		return $this->mapper->totalDurationOfAlbum($albumId);
316
	}
317
318
	/**
319
	 * @return integer Duration in seconds
320
	 */
321
	public function totalDurationByArtist(int $artistId) : int {
322
		return $this->mapper->totalDurationByArtist($artistId);
323
	}
324
325
	/**
326
	 * Update "last played" timestamp and increment the total play count of the track.
327
	 */
328
	public function recordTrackPlayed(int $trackId, string $userId, ?\DateTime $timeOfPlay = null) : void {
329
		$timeOfPlay = $timeOfPlay ?? new \DateTime();
330
331
		if (!$this->mapper->recordTrackPlayed($trackId, $userId, $timeOfPlay)) {
332
			throw new BusinessLayerException("Track with ID $trackId was not found");
333
		}
334
	}
335
336
	/**
337
	 * Adds a track if it does not exist already or updates an existing track
338
	 * @param string $title the title of the track
339
	 * @param int|null $number the number of the track
340
	 * @param int|null $discNumber the number of the disc
341
	 * @param int|null $year the year of the release
342
	 * @param int $genreId the genre id of the track
343
	 * @param int $artistId the artist id of the track
344
	 * @param int $albumId the album id of the track
345
	 * @param int $fileId the file id of the track
346
	 * @param string $mimetype the mimetype of the track
347
	 * @param string $userId the name of the user
348
	 * @param int $length track length in seconds
349
	 * @param int $bitrate track bitrate in bits (not kbits)
350
	 * @return Track The added/updated track
351
	 */
352
	public function addOrUpdateTrack(
353
			$title, $number, $discNumber, $year, $genreId, $artistId, $albumId,
354
			$fileId, $mimetype, $userId, $length=null, $bitrate=null) {
355
		$track = new Track();
356
		$track->setTitle(Util::truncate($title, 256)); // some DB setups can't truncate automatically to column max size
357
		$track->setNumber($number);
358
		$track->setDisk($discNumber);
359
		$track->setYear($year);
360
		$track->setGenreId($genreId);
361
		$track->setArtistId($artistId);
362
		$track->setAlbumId($albumId);
363
		$track->setFileId($fileId);
364
		$track->setMimetype($mimetype);
365
		$track->setUserId($userId);
366
		$track->setLength($length);
367
		$track->setBitrate($bitrate);
368
		$track->setDirty(0);
369
		return $this->mapper->insertOrUpdate($track);
370
	}
371
372
	/**
373
	 * Deletes tracks
374
	 * @param int[] $fileIds file IDs of the tracks to delete
375
	 * @param string[]|null $userIds the target users; if omitted, the tracks matching the
376
	 *                      $fileIds are deleted from all users
377
	 * @return array|false  False is returned if no such track was found; otherwise array of six arrays
378
	 *         (named 'deletedTracks', 'remainingAlbums', 'remainingArtists', 'obsoleteAlbums',
379
	 *         'obsoleteArtists', and 'affectedUsers'). These contain the track, album, artist, and
380
	 *         user IDs of the deleted tracks. The 'obsolete' entities are such which no longer
381
	 *         have any tracks while 'remaining' entities have some left.
382
	 */
383
	public function deleteTracks(array $fileIds, ?array $userIds=null) {
384
		$tracks = ($userIds !== null)
385
			? $this->mapper->findByFileIds($fileIds, $userIds)
386
			: $this->mapper->findAllByFileIds($fileIds);
387
388
		if (\count($tracks) === 0) {
389
			$result = false;
390
		} else {
391
			// delete all the matching tracks
392
			$trackIds = Util::extractIds($tracks);
393
			$this->deleteById($trackIds);
394
395
			// find all distinct albums, artists, and users of the deleted tracks
396
			$artists = [];
397
			$albums = [];
398
			$users = [];
399
			foreach ($tracks as $track) {
400
				$artists[$track->getArtistId()] = 1;
401
				$albums[$track->getAlbumId()] = 1;
402
				$users[$track->getUserId()] = 1;
403
			}
404
			$artists = \array_keys($artists);
405
			$albums = \array_keys($albums);
406
			$users = \array_keys($users);
407
408
			// categorize each artist as 'remaining' or 'obsolete'
409
			$remainingArtists = [];
410
			$obsoleteArtists = [];
411
			foreach ($artists as $artistId) {
412
				if ($this->mapper->countByArtist($artistId) === 0) {
413
					$obsoleteArtists[] = $artistId;
414
				} else {
415
					$remainingArtists[] = $artistId;
416
				}
417
			}
418
419
			// categorize each album as 'remaining' or 'obsolete'
420
			$remainingAlbums = [];
421
			$obsoleteAlbums = [];
422
			foreach ($albums as $albumId) {
423
				if ($this->mapper->countByAlbum($albumId) === 0) {
424
					$obsoleteAlbums[] = $albumId;
425
				} else {
426
					$remainingAlbums[] = $albumId;
427
				}
428
			}
429
430
			$result = [
431
				'deletedTracks'    => $trackIds,
432
				'remainingAlbums'  => $remainingAlbums,
433
				'remainingArtists' => $remainingArtists,
434
				'obsoleteAlbums'   => $obsoleteAlbums,
435
				'obsoleteArtists'  => $obsoleteArtists,
436
				'affectedUsers'    => $users
437
			];
438
		}
439
440
		return $result;
441
	}
442
443
	/**
444
	 * Marks tracks as dirty, ultimately requesting the user to rescan them
445
	 * @param int[] $fileIds file IDs of the tracks to mark as dirty
446
	 * @param string[]|null $userIds the target users; if omitted, the tracks matching the
447
	 *                      $fileIds are marked for all users
448
	 */
449
	public function markTracksDirty(array $fileIds, ?array $userIds=null) : void {
450
		// be prepared for huge number of file IDs
451
		$chunkMaxSize = self::MAX_SQL_ARGS - \count($userIds ?? []);
452
		$idChunks = \array_chunk($fileIds, $chunkMaxSize);
453
		foreach ($idChunks as $idChunk) {
454
			$this->mapper->markTracksDirty($idChunk, $userIds);
455
		}
456
	}
457
}
458