PlaylistApiController::getTracksFulltree()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 6
nc 1
nop 1
dl 0
loc 8
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, 2014
12
 * @copyright Pauli Järvinen 2017 - 2025
13
 */
14
15
namespace OCA\Music\Controller;
16
17
use OCP\AppFramework\Controller;
18
use OCP\AppFramework\Http;
19
use OCP\AppFramework\Http\JSONResponse;
20
use OCP\AppFramework\Http\Response;
21
22
use OCP\Files\Folder;
23
use OCP\Files\IRootFolder;
24
use OCP\IConfig;
25
use OCP\IRequest;
26
use OCP\IURLGenerator;
27
28
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
29
use OCA\Music\AppFramework\Core\Logger;
30
use OCA\Music\AppFramework\Utility\FileExistsException;
31
use OCA\Music\BusinessLayer\AlbumBusinessLayer;
32
use OCA\Music\BusinessLayer\ArtistBusinessLayer;
33
use OCA\Music\BusinessLayer\GenreBusinessLayer;
34
use OCA\Music\BusinessLayer\PlaylistBusinessLayer;
35
use OCA\Music\BusinessLayer\TrackBusinessLayer;
36
use OCA\Music\Db\Playlist;
37
use OCA\Music\Http\ErrorResponse;
38
use OCA\Music\Http\FileResponse;
39
use OCA\Music\Service\CoverService;
40
use OCA\Music\Service\PlaylistFileService;
41
42
class PlaylistApiController extends Controller {
43
	private IURLGenerator $urlGenerator;
44
	private PlaylistBusinessLayer $playlistBusinessLayer;
45
	private ArtistBusinessLayer $artistBusinessLayer;
46
	private AlbumBusinessLayer $albumBusinessLayer;
47
	private TrackBusinessLayer $trackBusinessLayer;
48
	private GenreBusinessLayer $genreBusinessLayer;
49
	private CoverService $coverService;
50
	private PlaylistFileService $playlistFileService;
51
	private string $userId;
52
	private Folder $userFolder;
53
	private IConfig $configManager;
54
	private Logger $logger;
55
56
	public function __construct(string $appName,
57
								IRequest $request,
58
								IURLGenerator $urlGenerator,
59
								PlaylistBusinessLayer $playlistBusinessLayer,
60
								ArtistBusinessLayer $artistBusinessLayer,
61
								AlbumBusinessLayer $albumBusinessLayer,
62
								TrackBusinessLayer $trackBusinessLayer,
63
								GenreBusinessLayer $genreBusinessLayer,
64
								CoverService $coverService,
65
								PlaylistFileService $playlistFileService,
66
								string $userId,
67
								IRootFolder $rootFolder,
68
								IConfig $configManager,
69
								Logger $logger) {
70
		parent::__construct($appName, $request);
71
		$this->urlGenerator = $urlGenerator;
72
		$this->playlistBusinessLayer = $playlistBusinessLayer;
73
		$this->artistBusinessLayer = $artistBusinessLayer;
74
		$this->albumBusinessLayer = $albumBusinessLayer;
75
		$this->trackBusinessLayer = $trackBusinessLayer;
76
		$this->genreBusinessLayer = $genreBusinessLayer;
77
		$this->coverService = $coverService;
78
		$this->playlistFileService = $playlistFileService;
79
		$this->userId = $userId;
80
		$this->userFolder = $rootFolder->getUserFolder($userId);
81
		$this->configManager = $configManager;
82
		$this->logger = $logger;
83
	}
84
85
	/**
86
	 * lists all playlists
87
	 *
88
	 * @NoAdminRequired
89
	 * @NoCSRFRequired
90
	 */
91
	public function getAll(string $type = 'shiva') : JSONResponse {
92
		$playlists = $this->playlistBusinessLayer->findAll($this->userId);
93
		$result = ($type === 'shiva')
94
			? \array_map(fn($p) => $p->toShivaApi($this->urlGenerator), $playlists)
95
			: \array_map(fn($p) => $p->toApi($this->urlGenerator), $playlists);
96
		return new JSONResponse($result);
97
	}
98
99
	/**
100
	 * creates a playlist
101
	 *
102
	 * @NoAdminRequired
103
	 * @NoCSRFRequired
104
	 * 
105
	 * @param string|int|null $trackIds
106
	 */
107
	public function create(?string $name, /*mixed*/ $trackIds, ?string $comment=null) : JSONResponse {
108
		$playlist = $this->playlistBusinessLayer->create($name ?? '', $this->userId);
109
110
		// add trackIds and comment to the newly created playlist if provided
111
		if (!empty($trackIds)) {
112
			$playlist = $this->playlistBusinessLayer->addTracks(
113
					self::toIntArray($trackIds), $playlist->getId(), $this->userId);
114
		}
115
		if ($comment !== null) {
116
			$playlist = $this->playlistBusinessLayer->setComment($comment, $playlist->getId(), $this->userId);
117
		}
118
119
		return new JSONResponse($playlist->toApi($this->urlGenerator));
120
	}
121
122
	/**
123
	 * deletes a playlist
124
	 * @param  int $id playlist ID
125
	 *
126
	 * @NoAdminRequired
127
	 * @NoCSRFRequired
128
	 */
129
	public function delete(int $id) : JSONResponse {
130
		$this->playlistBusinessLayer->delete($id, $this->userId);
131
		return new JSONResponse([]);
132
	}
133
134
	/**
135
	 * lists a single playlist
136
	 * @param int $id playlist ID
137
	 * @param string|int|bool $fulltree
138
	 *
139
	 * @NoAdminRequired
140
	 * @NoCSRFRequired
141
	 */
142
	public function get(int $id, string $type = 'shiva', /*mixed*/ $fulltree = 'false') : JSONResponse {
143
		try {
144
			$playlist = $this->playlistBusinessLayer->find($id, $this->userId);
145
146
			if ($type === 'shiva') {
147
				$result = $playlist->toShivaApi($this->urlGenerator);
148
			} else {
149
				$result = $playlist->toApi($this->urlGenerator);
150
			}
151
152
			$fulltree = \filter_var($fulltree, FILTER_VALIDATE_BOOLEAN);
153
			if ($fulltree) {
154
				unset($result['trackIds']);
155
				$result['tracks'] = $this->getTracksFulltree($playlist);
156
			}
157
158
			return new JSONResponse($result);
159
		} catch (BusinessLayerException $ex) {
160
			return new ErrorResponse(Http::STATUS_NOT_FOUND, $ex->getMessage());
161
		}
162
	}
163
164
	private function getTracksFulltree(Playlist $playlist) : array {
165
		$trackIds = $playlist->getTrackIdsAsArray();
166
		$tracks = $this->trackBusinessLayer->findById($trackIds, $this->userId);
167
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $this->userId);
168
169
		return \array_map(
170
			fn($track, $index) => \array_merge($track->toShivaApi($this->urlGenerator), ['index' => $index]),
171
			$tracks, \array_keys($tracks)
172
		);
173
	}
174
175
	/**
176
	 * generate a smart playlist according to the given rules
177
 	 * @param string|int|bool|null $historyStrict
178
	 *
179
	 * @NoAdminRequired
180
	 * @NoCSRFRequired
181
	 */
182
	public function generate(
183
			?bool $useLatestParams, ?string $history, ?string $genres, ?string $artists,
184
			?int $fromYear, ?int $toYear, ?string $favorite=null, int $size=100, /*mixed*/ $historyStrict='false') : JSONResponse {
185
186
		if ($useLatestParams) {
187
			$history = $this->configManager->getUserValue($this->userId, $this->appName, 'smartlist_history') ?: null;
188
			$genres = $this->configManager->getUserValue($this->userId, $this->appName, 'smartlist_genres') ?: null;
189
			$artists = $this->configManager->getUserValue($this->userId, $this->appName, 'smartlist_artists') ?: null;
190
			$fromYear = (int)$this->configManager->getUserValue($this->userId, $this->appName, 'smartlist_from_year') ?: null;
191
			$toYear = (int)$this->configManager->getUserValue($this->userId, $this->appName, 'smartlist_to_year') ?: null;
192
			$favorite = $this->configManager->getUserValue($this->userId, $this->appName, 'smartlist_favorite') ?: null;
193
			$size = (int)$this->configManager->getUserValue($this->userId, $this->appName, 'smartlist_size', 100);
194
			$historyStrict = $this->configManager->getUserValue($this->userId, $this->appName, 'smartlist_history_strict', 'false');
195
		} else {
196
			$this->configManager->setUserValue($this->userId, $this->appName, 'smartlist_history', $history ?? '');
197
			$this->configManager->setUserValue($this->userId, $this->appName, 'smartlist_genres', $genres ?? '');
198
			$this->configManager->setUserValue($this->userId, $this->appName, 'smartlist_artists', $artists ?? '');
199
			$this->configManager->setUserValue($this->userId, $this->appName, 'smartlist_from_year', (string)$fromYear);
200
			$this->configManager->setUserValue($this->userId, $this->appName, 'smartlist_to_year', (string)$toYear);
201
			$this->configManager->setUserValue($this->userId, $this->appName, 'smartlist_favorite', $favorite ?? '');
202
			$this->configManager->setUserValue($this->userId, $this->appName, 'smartlist_size', (string)$size);
203
			$this->configManager->setUserValue($this->userId, $this->appName, 'smartlist_history_strict', $historyStrict);
204
		}
205
		$historyStrict = \filter_var($historyStrict, FILTER_VALIDATE_BOOLEAN);
206
207
		// ensure the artists and genres contain only valid IDs
208
		$genres = $this->genreBusinessLayer->findAllIds($this->userId, self::toIntArray($genres));
209
		$artists = $this->artistBusinessLayer->findAllIds($this->userId, self::toIntArray($artists));
210
211
		$playlist = $this->playlistBusinessLayer->generate(
212
				$history, $historyStrict, $genres, $artists, $fromYear, $toYear, $favorite, $size, $this->userId);
213
		$result = $playlist->toApi($this->urlGenerator);
214
215
		$result['params'] = [
216
			'history' => $history ?: null,
217
			'historyStrict' => $historyStrict,
218
			'genres' => \implode(',', $genres) ?: null,
219
			'artists' => \implode(',', $artists) ?: null,
220
			'fromYear' => $fromYear ?: null,
221
			'toYear' => $toYear ?: null,
222
			'favorite' => $favorite ?: null,
223
			'size' => $size
224
		];
225
226
		return new JSONResponse($result);
227
	}
228
229
	/**
230
	 * get cover image for a playlist
231
	 * @param int $id playlist ID
232
	 *
233
	 * @NoAdminRequired
234
	 * @NoCSRFRequired
235
	 */
236
	public function getCover(int $id) : Response {
237
		try {
238
			$playlist = $this->playlistBusinessLayer->find($id, $this->userId);
239
			$cover = $this->coverService->getCover($playlist, $this->userId, $this->userFolder);
240
241
			if ($cover !== null) {
242
				return new FileResponse($cover);
243
			} else {
244
				return new ErrorResponse(Http::STATUS_NOT_FOUND, 'The playlist has no cover art');
245
			}
246
		} catch (BusinessLayerException $ex) {
247
			return new ErrorResponse(Http::STATUS_NOT_FOUND, $ex->getMessage());
248
		}
249
	}
250
251
	/**
252
	 * update a playlist
253
	 * @param int $id playlist ID
254
	 *
255
	 * @NoAdminRequired
256
	 * @NoCSRFRequired
257
	 */
258
	public function update(int $id, ?string $name = null, ?string $comment = null, ?string $trackIds = null) : JSONResponse {
259
		$result = null;
260
		if ($name !== null) {
261
			$result = $this->modifyPlaylist('rename', [$name, $id, $this->userId]);
262
		}
263
		if ($comment !== null) {
264
			$result = $this->modifyPlaylist('setComment', [$comment, $id, $this->userId]);
265
		}
266
		if ($trackIds !== null) {
267
			$result = $this->modifyPlaylist('setTracks', [self::toIntArray($trackIds), $id, $this->userId]);
268
		}
269
		if ($result === null) {
270
			$result = new ErrorResponse(Http::STATUS_BAD_REQUEST, "at least one of the args ['name', 'comment', 'trackIds'] must be given");
271
		}
272
		return $result;
273
	}
274
275
	/**
276
	 * insert or append tracks to a playlist
277
	 * @param int $id playlist ID
278
	 * @param string|int|null $track Comma-separated list of track IDs
279
	 * @param ?int $index Insertion position within the playlist, or null to append
280
	 *
281
	 * @NoAdminRequired
282
	 * @NoCSRFRequired
283
	 */
284
	public function addTracks(int $id, /*mixed*/ $track, ?int $index = null) : JSONResponse {
285
		return $this->modifyPlaylist('addTracks', [self::toIntArray($track), $id, $this->userId, $index]);
286
	}
287
288
	/**
289
	 * removes tracks from a playlist
290
	 * @param int $id playlist ID
291
	 * @param string|int|null $index Comma-separated list of track indices within the playlist
292
	 *
293
	 * @NoAdminRequired
294
	 * @NoCSRFRequired
295
	 */
296
	public function removeTracks(int $id, /*mixed*/ $index) : JSONResponse {
297
		return $this->modifyPlaylist('removeTracks', [self::toIntArray($index), $id, $this->userId]);
298
	}
299
300
	/**
301
	 * moves single track on playlist to a new position
302
	 * @param int $id playlist ID
303
	 *
304
	 * @NoAdminRequired
305
	 * @NoCSRFRequired
306
	 */
307
	public function reorder(int $id, ?int $fromIndex, ?int $toIndex) : JSONResponse {
308
		if ($fromIndex === null || $toIndex === null) {
309
			return new ErrorResponse(Http::STATUS_BAD_REQUEST, "Arguments 'fromIndex' and 'toIndex' are required");
310
		} else {
311
			return $this->modifyPlaylist('moveTrack', [$fromIndex, $toIndex, $id, $this->userId]);
312
		}
313
	}
314
315
	/**
316
	 * export the playlist to a file
317
	 * @param int $id playlist ID
318
	 * @param string $path parent folder path
319
	 * @param ?string $filename target file name, omit to use the playlist name
320
	 * @param string $oncollision action to take on file name collision,
321
	 *								supported values:
322
	 *								- 'overwrite' The existing file will be overwritten
323
	 *								- 'keepboth' The new file is named with a suffix to make it unique
324
	 *								- 'abort' (default) The operation will fail
325
	 *
326
	 * @NoAdminRequired
327
	 * @NoCSRFRequired
328
	 */
329
	public function exportToFile(int $id, string $path, ?string $filename=null, string $oncollision='abort') : JSONResponse {
330
		try {
331
			$exportedFilePath = $this->playlistFileService->exportToFile(
332
					$id, $this->userId, $this->userFolder, $path, $filename, $oncollision);
333
			return new JSONResponse(['wrote_to_file' => $exportedFilePath]);
334
		} catch (BusinessLayerException $ex) {
335
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'playlist not found');
336
		} catch (\OCP\Files\NotFoundException $ex) {
337
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'folder not found');
338
		} catch (FileExistsException $ex) {
339
			return new ErrorResponse(Http::STATUS_CONFLICT, 'file already exists', ['path' => $ex->getPath(), 'suggested_name' => $ex->getAltName()]);
340
		} catch (\OCP\Files\NotPermittedException $ex) {
341
			return new ErrorResponse(Http::STATUS_FORBIDDEN, 'user is not allowed to write to the target file');
342
		}
343
	}
344
345
	/**
346
	 * import playlist contents from a file
347
	 * @param int $id playlist ID
348
	 * @param string $filePath path of the file to import
349
	 *
350
	 * @NoAdminRequired
351
	 * @NoCSRFRequired
352
	 */
353
	public function importFromFile(int $id, string $filePath) : JSONResponse {
354
		try {
355
			$result = $this->playlistFileService->importFromFile($id, $this->userId, $this->userFolder, $filePath);
356
			$result['playlist'] = $result['playlist']->toApi($this->urlGenerator);
357
			return new JSONResponse($result);
358
		} catch (BusinessLayerException $ex) {
359
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'playlist not found');
360
		} catch (\OCP\Files\NotFoundException $ex) {
361
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'playlist file not found');
362
		} catch (\UnexpectedValueException $ex) {
363
			return new ErrorResponse(Http::STATUS_UNSUPPORTED_MEDIA_TYPE, $ex->getMessage());
364
		}
365
	}
366
367
	/**
368
	 * read and parse a playlist file
369
	 * @param int $fileId ID of the file to parse
370
	 *
371
	 * @NoAdminRequired
372
	 * @NoCSRFRequired
373
	 */
374
	public function parseFile(int $fileId) : JSONResponse {
375
		try {
376
			$result = $this->playlistFileService->parseFile($fileId, $this->userFolder);
377
378
			// Make a lookup table of all the file IDs in the user library to avoid having to run
379
			// a DB query for each track in the playlist to check if it is in the library. This
380
			// could make a difference in case of a huge playlist.
381
			$libFileIds = $this->trackBusinessLayer->findAllFileIds($this->userId);
382
			$libFileIds = \array_flip($libFileIds);
383
384
			$bogusUrlId = -1;
385
386
			// compose the final result
387
			$result['files'] = \array_map(function ($fileInfo) use ($libFileIds, &$bogusUrlId) {
388
				if (isset($fileInfo['url'])) {
389
					$fileInfo['id'] = $bogusUrlId--;
390
					$fileInfo['mimetype'] = null;
391
					$fileInfo['external'] = true;
392
					return $fileInfo;
393
				} else {
394
					$file = $fileInfo['file'];
395
					return [
396
						'id' => $file->getId(),
397
						'name' => $file->getName(),
398
						'path' => $this->userFolder->getRelativePath($file->getParent()->getPath()),
399
						'mimetype' => $file->getMimeType(),
400
						'caption' => $fileInfo['caption'],
401
						'in_library' => isset($libFileIds[$file->getId()]),
402
						'external' => false
403
					];
404
				}
405
			}, $result['files']);
406
			return new JSONResponse($result);
407
		} catch (\OCP\Files\NotFoundException $ex) {
408
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'playlist file not found');
409
		} catch (\UnexpectedValueException $ex) {
410
			return new ErrorResponse(Http::STATUS_UNSUPPORTED_MEDIA_TYPE, $ex->getMessage());
411
		}
412
	}
413
414
	/**
415
	 * Modify playlist by calling a supplied method from PlaylistBusinessLayer
416
	 * @param string $funcName  Name of a function to call from PlaylistBusinessLayer
417
	 * @param array $funcParams Parameters to pass to the function 'funcName'
418
	 * @return JSONResponse JSON representation of the modified playlist
419
	 */
420
	private function modifyPlaylist(string $funcName, array $funcParams) : JSONResponse {
421
		try {
422
			$playlist = \call_user_func_array([$this->playlistBusinessLayer, $funcName], $funcParams);
423
			return new JSONResponse($playlist->toApi($this->urlGenerator));
424
		} catch (BusinessLayerException $ex) {
425
			return new ErrorResponse(Http::STATUS_NOT_FOUND, $ex->getMessage());
426
		}
427
	}
428
429
	/**
430
	 * Get integer array passed as parameter to the Playlist API
431
	 * @param string|int|null $listAsString Comma-separated integer values in string, or a single integer
432
	 * @return int[]
433
	 */
434
	private static function toIntArray(/*mixed*/ $listAsString) : array {
435
		if ($listAsString === null || $listAsString === '') {
436
			return [];
437
		} else {
438
			return \array_map('intval', \explode(',', (string)$listAsString));
439
		}
440
	}
441
}
442