Passed
Push — master ( 516f42...c66c88 )
by Pauli
03:14
created

PlaylistApiController::getTracksFulltree()   A

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