Passed
Push — master ( 6a02c7...30b0d4 )
by Pauli
02:20
created

recursivelyAddMissingParentFolders()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 17
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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