PlaylistApiController::update()   A
last analyzed

Complexity

Conditions 5
Paths 16

Size

Total Lines 15
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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