Passed
Push — feature/909_Ampache_API_improv... ( 5a3d3f...673c0e )
by Pauli
02:49
created

TrackBusinessLayer   B

Complexity

Total Complexity 49

Size/Duplication

Total Lines 392
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 145
dl 0
loc 392
rs 8.48
c 2
b 0
f 0
wmc 49

25 Methods

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