Passed
Push — master ( 012442...4c4644 )
by Pauli
03:10 queued 15s
created

addMissingParentsToFoldersLut()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 9
c 0
b 0
f 0
nc 3
nop 1
dl 0
loc 13
rs 9.9666
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
26
use OCA\Music\Utility\Util;
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
	 * Returns all tracks of the user which should be rescanned to ensure that the library details are up-to-date.
124
	 * The track may be considered "dirty" for on of two reasons:
125
	 * - 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
126
	 * - it has been specifically marked as dirty, maybe in response to being moved to another directory
127
	 * @return Track[]
128
	 */
129
	public function findAllDirty(string $userId) : array {
130
		$tracks = $this->findAll($userId);
131
		return \array_filter($tracks, function (Track $track) {
132
			$dbModTime = new \DateTime($track->getUpdated());
133
			return ($track->getDirty() || $dbModTime->getTimestamp() < $track->getFileModTime());
134
		});
135
	}
136
137
	/**
138
	 * Find most frequently played tracks
139
	 * @return Track[]
140
	 */
141
	public function findFrequentPlay(string $userId, ?int $limit=null, ?int $offset=null) : array {
142
		return $this->mapper->findFrequentPlay($userId, $limit, $offset);
143
	}
144
145
	/**
146
	 * Find most recently played tracks
147
	 * @return Track[]
148
	 */
149
	public function findRecentPlay(string $userId, ?int $limit=null, ?int $offset=null) : array {
150
		return $this->mapper->findRecentPlay($userId, $limit, $offset);
151
	}
152
153
	/**
154
	 * Find least recently played tracks
155
	 * @return Track[]
156
	 */
157
	public function findNotRecentPlay(string $userId, ?int $limit=null, ?int $offset=null) : array {
158
		return $this->mapper->findNotRecentPlay($userId, $limit, $offset);
159
	}
160
161
	/**
162
	 * Returns the track for a file id
163
	 * @return Track|null
164
	 */
165
	public function findByFileId(int $fileId, string $userId) : ?Track {
166
		try {
167
			return $this->mapper->findByFileId($fileId, $userId);
168
		} catch (DoesNotExistException $e) {
169
			return null;
170
		}
171
	}
172
173
	/**
174
	 * Returns file IDs of all indexed tracks of the user
175
	 * @return int[]
176
	 */
177
	public function findAllFileIds(string $userId) : array {
178
		return $this->mapper->findAllFileIds($userId);
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
			$lut[$folderId] = \array_merge($folderNamesAndParents[$folderId], ['trackIds' => $trackIds]);
242
		}
243
244
		// the root folder should have null parent; here we also ensure it's included
245
		$rootFolderId = $musicFolder->getId();
246
		$rootTracks = $lut[$rootFolderId]['trackIds'] ?? [];
247
		$lut[$rootFolderId] = ['name' => '', 'parent' => null, 'trackIds' => $rootTracks];
248
249
		// External mounts and shared files/folders need some special handling. But if there are any, they should be found
250
		// right under the top-level folder.
251
		$this->addExternalMountsToFoldersLut($lut, $userId, $musicFolder);
252
253
		// Add the intermediate folders which do not directly contain any tracks
254
		$this->addMissingParentsToFoldersLut($lut);
255
256
		return $lut;
257
	}
258
259
	/**
260
	 * Add externally mounted folders and shared files and folders to the folder LUT if there are any under the $musicFolder
261
	 * 
262
	 * @param array $lut (in|out) Keys are folder IDs and values are arrays like ['name' : string, 'parent' : int, 'trackIds' : int[]]
263
	 */
264
	private function addExternalMountsToFoldersLut(array &$lut, string $userId, Folder $musicFolder) : void {
265
		$nodesUnderRoot = $musicFolder->getDirectoryListing();
266
		$homeStorageId = $musicFolder->getStorage()->getId();
267
		$rootFolderId = $musicFolder->getId();
268
269
		foreach ($nodesUnderRoot as $node) {
270
			if ($node->getStorage()->getId() != $homeStorageId) {
271
				// shared file/folder or external mount
272
				if ($node->getType() == FileInfo::TYPE_FOLDER) {
273
					// The mount point folders are always included in the result. At this time, we don't know if
274
					// they actually contain any tracks, unless they have direct track children. If there are direct tracks,
275
					// then the parent ID is incorrectly set and needs to be overridden.
276
					$trackIds = $lut[$node->getId()]['trackIds'] ?? [];
277
					$lut[$node->getId()] = ['name' => $node->getName(), 'parent' => $rootFolderId, 'trackIds' => $trackIds];
278
279
				} else if ($node->getMimePart() == 'audio') {
280
					// shared audio file, check if it's actually a scanned file in our library
281
					$sharedTrack = $this->findByFileId($node->getId(), $userId);
282
					if ($sharedTrack !== null) {
283
284
						$trackId = $sharedTrack->getId();
285
						foreach ($lut as $folderId => &$entry) {
286
							$trackIdIdx = \array_search($trackId, $entry['trackIds']);
287
							if ($trackIdIdx !== false) {
288
								// move the track from it's actual parent (in other user's storage) to our root
289
								unset($entry['trackIds'][$trackIdIdx]);
290
								$lut[$rootFolderId]['trackIds'][] = $trackId;
291
292
								// remove the former parent folder if it has no more tracks and it's not one of the mount point folders
293
								if (\count($entry['trackIds']) == 0 && empty(\array_filter($nodesUnderRoot, fn($n) => $n->getId() == $folderId))) {
294
									unset($lut[$folderId]);
295
								}
296
								break;
297
							}
298
						}
299
					}
300
				}
301
			}
302
		}
303
	}
304
305
	/**
306
	 * Add any missing intermediary folder to the LUT. For this function to work correctly, the pre-condition is that the LUT contains
307
	 * a root node which is predecessor of all other contained nodes and has 'parent' set as null.
308
	 * 
309
	 * @param array $lut (in|out) Keys are folder IDs and values are arrays like ['name' : string, 'parent' : int, 'trackIds' : int[]]
310
	 */
311
	private function addMissingParentsToFoldersLut(array &$lut) : void {
312
		$foldersToProcess = $lut;
313
314
		while (\count($foldersToProcess)) {
315
			$parentIds = \array_unique(\array_column($foldersToProcess, 'parent'));
316
			// do not process root even if it's included in $foldersToProcess
317
			$parentIds = \array_filter($parentIds, fn($i) => $i !== null);
318
			$parentIds = Util::arrayDiff($parentIds, \array_keys($lut));
319
			$parentFolders = $this->mapper->findNodeNamesAndParents($parentIds);
320
321
			$foldersToProcess = [];
322
			foreach ($parentFolders as $folderId => $nameAndParent) {
323
				$foldersToProcess[] = $lut[$folderId] = \array_merge($nameAndParent, ['trackIds' => []]);
324
			}
325
		}
326
	}
327
328
	/**
329
	 * Returns all genre IDs associated with the given artist
330
	 * @return int[]
331
	 */
332
	public function getGenresByArtistId(int $artistId, string $userId) : array {
333
		return $this->mapper->getGenresByArtistId($artistId, $userId);
334
	}
335
336
	/**
337
	 * Returns file IDs of the tracks which do not have genre scanned. This is not the same
338
	 * thing as unknown genre, which is stored as empty string and means that the genre has
339
	 * been scanned but was not found from the track metadata.
340
	 * @return int[]
341
	 */
342
	public function findFilesWithoutScannedGenre(string $userId) : array {
343
		return $this->mapper->findFilesWithoutScannedGenre($userId);
344
	}
345
346
	public function countByArtist(int $artistId) : int {
347
		return $this->mapper->countByArtist($artistId);
348
	}
349
350
	public function countByAlbum(int $albumId) : int {
351
		return $this->mapper->countByAlbum($albumId);
352
	}
353
354
	/**
355
	 * @return integer Duration in seconds
356
	 */
357
	public function totalDurationOfAlbum(int $albumId) : int {
358
		return $this->mapper->totalDurationOfAlbum($albumId);
359
	}
360
361
	/**
362
	 * @return integer Duration in seconds
363
	 */
364
	public function totalDurationByArtist(int $artistId) : int {
365
		return $this->mapper->totalDurationByArtist($artistId);
366
	}
367
368
	/**
369
	 * Update "last played" timestamp and increment the total play count of the track.
370
	 */
371
	public function recordTrackPlayed(int $trackId, string $userId, ?\DateTime $timeOfPlay = null) : void {
372
		$timeOfPlay = $timeOfPlay ?? new \DateTime();
373
374
		if (!$this->mapper->recordTrackPlayed($trackId, $userId, $timeOfPlay)) {
375
			throw new BusinessLayerException("Track with ID $trackId was not found");
376
		}
377
	}
378
379
	/**
380
	 * Adds a track if it does not exist already or updates an existing track
381
	 * @param string $title the title of the track
382
	 * @param int|null $number the number of the track
383
	 * @param int|null $discNumber the number of the disc
384
	 * @param int|null $year the year of the release
385
	 * @param int $genreId the genre id of the track
386
	 * @param int $artistId the artist id of the track
387
	 * @param int $albumId the album id of the track
388
	 * @param int $fileId the file id of the track
389
	 * @param string $mimetype the mimetype of the track
390
	 * @param string $userId the name of the user
391
	 * @param int $length track length in seconds
392
	 * @param int $bitrate track bitrate in bits (not kbits)
393
	 * @return Track The added/updated track
394
	 */
395
	public function addOrUpdateTrack(
396
			$title, $number, $discNumber, $year, $genreId, $artistId, $albumId,
397
			$fileId, $mimetype, $userId, $length=null, $bitrate=null) {
398
		$track = new Track();
399
		$track->setTitle(Util::truncate($title, 256)); // some DB setups can't truncate automatically to column max size
400
		$track->setNumber($number);
401
		$track->setDisk($discNumber);
402
		$track->setYear($year);
403
		$track->setGenreId($genreId);
404
		$track->setArtistId($artistId);
405
		$track->setAlbumId($albumId);
406
		$track->setFileId($fileId);
407
		$track->setMimetype($mimetype);
408
		$track->setUserId($userId);
409
		$track->setLength($length);
410
		$track->setBitrate($bitrate);
411
		$track->setDirty(0);
412
		return $this->mapper->insertOrUpdate($track);
413
	}
414
415
	/**
416
	 * Deletes tracks
417
	 * @param int[] $fileIds file IDs of the tracks to delete
418
	 * @param string[]|null $userIds the target users; if omitted, the tracks matching the
419
	 *                      $fileIds are deleted from all users
420
	 * @return array|false  False is returned if no such track was found; otherwise array of six arrays
421
	 *         (named 'deletedTracks', 'remainingAlbums', 'remainingArtists', 'obsoleteAlbums',
422
	 *         'obsoleteArtists', and 'affectedUsers'). These contain the track, album, artist, and
423
	 *         user IDs of the deleted tracks. The 'obsolete' entities are such which no longer
424
	 *         have any tracks while 'remaining' entities have some left.
425
	 */
426
	public function deleteTracks(array $fileIds, ?array $userIds=null) {
427
		$tracks = ($userIds !== null)
428
			? $this->mapper->findByFileIds($fileIds, $userIds)
429
			: $this->mapper->findAllByFileIds($fileIds);
430
431
		if (\count($tracks) === 0) {
432
			$result = false;
433
		} else {
434
			// delete all the matching tracks
435
			$trackIds = Util::extractIds($tracks);
436
			$this->deleteById($trackIds);
437
438
			// find all distinct albums, artists, and users of the deleted tracks
439
			$artists = [];
440
			$albums = [];
441
			$users = [];
442
			foreach ($tracks as $track) {
443
				$artists[$track->getArtistId()] = 1;
444
				$albums[$track->getAlbumId()] = 1;
445
				$users[$track->getUserId()] = 1;
446
			}
447
			$artists = \array_keys($artists);
448
			$albums = \array_keys($albums);
449
			$users = \array_keys($users);
450
451
			// categorize each artist as 'remaining' or 'obsolete'
452
			$remainingArtists = [];
453
			$obsoleteArtists = [];
454
			foreach ($artists as $artistId) {
455
				if ($this->mapper->countByArtist($artistId) === 0) {
456
					$obsoleteArtists[] = $artistId;
457
				} else {
458
					$remainingArtists[] = $artistId;
459
				}
460
			}
461
462
			// categorize each album as 'remaining' or 'obsolete'
463
			$remainingAlbums = [];
464
			$obsoleteAlbums = [];
465
			foreach ($albums as $albumId) {
466
				if ($this->mapper->countByAlbum($albumId) === 0) {
467
					$obsoleteAlbums[] = $albumId;
468
				} else {
469
					$remainingAlbums[] = $albumId;
470
				}
471
			}
472
473
			$result = [
474
				'deletedTracks'    => $trackIds,
475
				'remainingAlbums'  => $remainingAlbums,
476
				'remainingArtists' => $remainingArtists,
477
				'obsoleteAlbums'   => $obsoleteAlbums,
478
				'obsoleteArtists'  => $obsoleteArtists,
479
				'affectedUsers'    => $users
480
			];
481
		}
482
483
		return $result;
484
	}
485
486
	/**
487
	 * Marks tracks as dirty, ultimately requesting the user to rescan them
488
	 * @param int[] $fileIds file IDs of the tracks to mark as dirty
489
	 * @param string[]|null $userIds the target users; if omitted, the tracks matching the
490
	 *                      $fileIds are marked for all users
491
	 */
492
	public function markTracksDirty(array $fileIds, ?array $userIds=null) : void {
493
		// be prepared for huge number of file IDs
494
		$chunkMaxSize = self::MAX_SQL_ARGS - \count($userIds ?? []);
495
		$idChunks = \array_chunk($fileIds, $chunkMaxSize);
496
		foreach ($idChunks as $idChunk) {
497
			$this->mapper->markTracksDirty($idChunk, $userIds);
498
		}
499
	}
500
}
501