Passed
Push — master ( c7cb4e...ce9a66 )
by Pauli
03:03
created

TrackBusinessLayer::recordTrackPlayed()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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