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

TrackBusinessLayer::countByAlbum()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 2
rs 10
c 0
b 0
f 0
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