Passed
Push — feature/NC28-compatibility ( dd3edc...ce51bc )
by Pauli
03:46
created

TrackBusinessLayer::addOrUpdateTrack()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 17
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 14
nc 1
nop 12
dl 0
loc 17
rs 9.7998
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 - 2023
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::None, 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 and/or artist name
107
	 * @return Track[] Tracks matching the criteria
108
	 */
109
	public function findAllByNameAndArtistName(?string $name, ?string $artistName, 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->findAllByNameAndArtistName($name, $artistName, $userId);
118
	}
119
120
	/**
121
	 * Returns all tracks where the 'modified' time in the file system (actually in the cloud's file cache)
122
	 * is later than the 'updated' field of the entity in the database.
123
	 * @return Track[]
124
	 */
125
	public function findAllDirty(string $userId) : array {
126
		$tracks = $this->findAll($userId);
127
		return \array_filter($tracks, function (Track $track) {
128
			$dbModTime = new \DateTime($track->getUpdated());
129
			return ($dbModTime->getTimestamp() < $track->getFileModTime());
130
		});
131
	}
132
133
	/**
134
	 * Find most frequently played tracks
135
	 * @return Track[]
136
	 */
137
	public function findFrequentPlay(string $userId, ?int $limit=null, ?int $offset=null) : array {
138
		return $this->mapper->findFrequentPlay($userId, $limit, $offset);
139
	}
140
141
	/**
142
	 * Find most recently played tracks
143
	 * @return Track[]
144
	 */
145
	public function findRecentPlay(string $userId, ?int $limit=null, ?int $offset=null) : array {
146
		return $this->mapper->findRecentPlay($userId, $limit, $offset);
147
	}
148
149
	/**
150
	 * Find least recently played tracks
151
	 * @return Track[]
152
	 */
153
	public function findNotRecentPlay(string $userId, ?int $limit=null, ?int $offset=null) : array {
154
		return $this->mapper->findNotRecentPlay($userId, $limit, $offset);
155
	}
156
157
	/**
158
	 * Returns the track for a file id
159
	 * @return Track|null
160
	 */
161
	public function findByFileId(int $fileId, string $userId) : ?Track {
162
		try {
163
			return $this->mapper->findByFileId($fileId, $userId);
164
		} catch (DoesNotExistException $e) {
165
			return null;
166
		}
167
	}
168
169
	/**
170
	 * Returns file IDs of all indexed tracks of the user
171
	 * @return int[]
172
	 */
173
	public function findAllFileIds(string $userId) : array {
174
		return $this->mapper->findAllFileIds($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, path: 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
		$tracksByFolder = $this->mapper->findTrackAndFolderIds($userId);
185
186
		// Get the folder names and paths for ordinary local folders directly from the DB.
187
		// This is significantly more efficient than using the Files API because we need to
188
		// run only single DB query instead of one per folder.
189
		$folderNamesAndParents = $this->mapper->findNodeNamesAndParents(
190
				\array_keys($tracksByFolder), $musicFolder->getStorage()->getId());
191
192
		// root folder has to be handled as a special case because shared files from
193
		// many folders may be shown to this user mapped under the root folder
194
		$rootFolderTracks = [];
195
196
		// Build the final results. Use the previously fetched data for the ordinary
197
		// local folders and query the data through the Files API for the more special cases.
198
		$result = [];
199
		foreach ($tracksByFolder as $folderId => $trackIds) {
200
			$entry = self::getFolderEntry($folderNamesAndParents, $folderId, $trackIds, $musicFolder);
201
202
			if ($entry) {
203
				$result[] = $entry;
204
			} else {
205
				$rootFolderTracks = \array_merge($rootFolderTracks, $trackIds);
206
			}
207
		}
208
209
		// add the library root folder
210
		$result[] = [
211
			'name' => '',
212
			'parent' => null,
213
			'trackIds' => $rootFolderTracks,
214
			'id' => $musicFolder->getId()
215
		];
216
217
		// add the intermediate folders which do not directly contain any tracks
218
		$this->recursivelyAddMissingParentFolders($result, $result, $musicFolder);
219
220
		return $result;
221
	}
222
223
	private function recursivelyAddMissingParentFolders(array $foldersToProcess, array &$alreadyFoundFolders, Folder $musicFolder) : void {
224
225
		$parentIds = \array_unique(\array_column($foldersToProcess, 'parent'));
226
		$parentIds = Util::arrayDiff($parentIds, \array_column($alreadyFoundFolders, 'id'));
227
		$parentInfo = $this->mapper->findNodeNamesAndParents($parentIds, $musicFolder->getStorage()->getId());
228
229
		$newParents = [];
230
		foreach ($parentIds as $parentId) {
231
			if ($parentId !== null) {
232
				$parentEntry = self::getFolderEntry($parentInfo, $parentId, [], $musicFolder);
233
				if ($parentEntry !== null) {
234
					$newParents[] = $parentEntry;
235
				}
236
			}
237
		}
238
239
		$alreadyFoundFolders = \array_merge($alreadyFoundFolders, $newParents);
240
241
		if (\count($newParents)) {
242
			$this->recursivelyAddMissingParentFolders($newParents, $alreadyFoundFolders, $musicFolder);
243
		}
244
	}
245
246
	private static function getFolderEntry(array $folderNamesAndParents, int $folderId, array $trackIds, Folder $musicFolder) : ?array {
247
		$libRootId = $musicFolder->getId();
248
249
		if (isset($folderNamesAndParents[$folderId])) {
250
			// normal folder within the user home storage
251
			$entry = $folderNamesAndParents[$folderId];
252
			// special handling for the root folder
253
			if ($folderId === $libRootId) {
254
				$entry = null;
255
			}
256
		} else {
257
			// shared folder or parent folder of a shared file or an externally mounted folder
258
			$folderNode = $musicFolder->getById($folderId)[0] ?? null;
259
			if ($folderNode === null) {
260
				// other user's folder with files shared with this user (mapped under root)
261
				$entry = null;
262
			} else {
263
				$entry = [
264
					'name' => $folderNode->getName(),
265
					'parent' => $folderNode->getParent()->getId()
266
				];
267
			}
268
		}
269
270
		if ($entry) {
271
			$entry['trackIds'] = $trackIds;
272
			$entry['id'] = $folderId;
273
274
			if ($entry['id'] == $libRootId) {
275
				// the library root should be reported without a parent folder as that parent does not belong to the library
276
				$entry['parent'] = null;
277
			}
278
		}
279
280
		return $entry;
281
	}
282
283
	/**
284
	 * Returns all genre IDs associated with the given artist
285
	 * @return int[]
286
	 */
287
	public function getGenresByArtistId(int $artistId, string $userId) : array {
288
		return $this->mapper->getGenresByArtistId($artistId, $userId);
289
	}
290
291
	/**
292
	 * Returns file IDs of the tracks which do not have genre scanned. This is not the same
293
	 * thing as unknown genre, which is stored as empty string and means that the genre has
294
	 * been scanned but was not found from the track metadata.
295
	 * @return int[]
296
	 */
297
	public function findFilesWithoutScannedGenre(string $userId) : array {
298
		return $this->mapper->findFilesWithoutScannedGenre($userId);
299
	}
300
301
	public function countByArtist(int $artistId) : int {
302
		return $this->mapper->countByArtist($artistId);
303
	}
304
305
	public function countByAlbum(int $albumId) : int {
306
		return $this->mapper->countByAlbum($albumId);
307
	}
308
309
	/**
310
	 * @return integer Duration in seconds
311
	 */
312
	public function totalDurationOfAlbum(int $albumId) : int {
313
		return $this->mapper->totalDurationOfAlbum($albumId);
314
	}
315
316
	/**
317
	 * @return integer Duration in seconds
318
	 */
319
	public function totalDurationByArtist(int $artistId) : int {
320
		return $this->mapper->totalDurationByArtist($artistId);
321
	}
322
323
	/**
324
	 * Update "last played" timestamp and increment the total play count of the track.
325
	 */
326
	public function recordTrackPlayed(int $trackId, string $userId, ?\DateTime $timeOfPlay = null) : void {
327
		$timeOfPlay = $timeOfPlay ?? new \DateTime();
328
329
		if (!$this->mapper->recordTrackPlayed($trackId, $userId, $timeOfPlay)) {
330
			throw new BusinessLayerException("Track with ID $trackId was not found");
331
		}
332
	}
333
334
	/**
335
	 * Adds a track if it does not exist already or updates an existing track
336
	 * @param string $title the title of the track
337
	 * @param int|null $number the number of the track
338
	 * @param int|null $discNumber the number of the disc
339
	 * @param int|null $year the year of the release
340
	 * @param int $genreId the genre id of the track
341
	 * @param int $artistId the artist id of the track
342
	 * @param int $albumId the album id of the track
343
	 * @param int $fileId the file id of the track
344
	 * @param string $mimetype the mimetype of the track
345
	 * @param string $userId the name of the user
346
	 * @param int $length track length in seconds
347
	 * @param int $bitrate track bitrate in bits (not kbits)
348
	 * @return Track The added/updated track
349
	 */
350
	public function addOrUpdateTrack(
351
			$title, $number, $discNumber, $year, $genreId, $artistId, $albumId,
352
			$fileId, $mimetype, $userId, $length=null, $bitrate=null) {
353
		$track = new Track();
354
		$track->setTitle(Util::truncate($title, 256)); // some DB setups can't truncate automatically to column max size
355
		$track->setNumber($number);
356
		$track->setDisk($discNumber);
357
		$track->setYear($year);
358
		$track->setGenreId($genreId);
359
		$track->setArtistId($artistId);
360
		$track->setAlbumId($albumId);
361
		$track->setFileId($fileId);
362
		$track->setMimetype($mimetype);
363
		$track->setUserId($userId);
364
		$track->setLength($length);
365
		$track->setBitrate($bitrate);
366
		return $this->mapper->insertOrUpdate($track);
367
	}
368
369
	/**
370
	 * Deletes a track
371
	 * @param int[] $fileIds file IDs of the tracks to delete
372
	 * @param string[]|null $userIds the target users; if omitted, the tracks matching the
373
	 *                      $fileIds are deleted from all users
374
	 * @return array|false  False is returned if no such track was found; otherwise array of six arrays
375
	 *         (named 'deletedTracks', 'remainingAlbums', 'remainingArtists', 'obsoleteAlbums',
376
	 *         'obsoleteArtists', and 'affectedUsers'). These contain the track, album, artist, and
377
	 *         user IDs of the deleted tracks. The 'obsolete' entities are such which no longer
378
	 *         have any tracks while 'remaining' entities have some left.
379
	 */
380
	public function deleteTracks(array $fileIds, ?array $userIds=null) {
381
		$tracks = ($userIds !== null)
382
			? $this->mapper->findByFileIds($fileIds, $userIds)
383
			: $this->mapper->findAllByFileIds($fileIds);
384
385
		if (\count($tracks) === 0) {
386
			$result = false;
387
		} else {
388
			// delete all the matching tracks
389
			$trackIds = Util::extractIds($tracks);
390
			$this->deleteById($trackIds);
391
392
			// find all distinct albums, artists, and users of the deleted tracks
393
			$artists = [];
394
			$albums = [];
395
			$users = [];
396
			foreach ($tracks as $track) {
397
				$artists[$track->getArtistId()] = 1;
398
				$albums[$track->getAlbumId()] = 1;
399
				$users[$track->getUserId()] = 1;
400
			}
401
			$artists = \array_keys($artists);
402
			$albums = \array_keys($albums);
403
			$users = \array_keys($users);
404
405
			// categorize each artist as 'remaining' or 'obsolete'
406
			$remainingArtists = [];
407
			$obsoleteArtists = [];
408
			foreach ($artists as $artistId) {
409
				if ($this->mapper->countByArtist($artistId) === 0) {
410
					$obsoleteArtists[] = $artistId;
411
				} else {
412
					$remainingArtists[] = $artistId;
413
				}
414
			}
415
416
			// categorize each album as 'remaining' or 'obsolete'
417
			$remainingAlbums = [];
418
			$obsoleteAlbums = [];
419
			foreach ($albums as $albumId) {
420
				if ($this->mapper->countByAlbum($albumId) === 0) {
421
					$obsoleteAlbums[] = $albumId;
422
				} else {
423
					$remainingAlbums[] = $albumId;
424
				}
425
			}
426
427
			$result = [
428
				'deletedTracks'    => $trackIds,
429
				'remainingAlbums'  => $remainingAlbums,
430
				'remainingArtists' => $remainingArtists,
431
				'obsoleteAlbums'   => $obsoleteAlbums,
432
				'obsoleteArtists'  => $obsoleteArtists,
433
				'affectedUsers'    => $users
434
			];
435
		}
436
437
		return $result;
438
	}
439
}
440