Passed
Push — master ( 4db594...476cd7 )
by Pauli
04:01
created

PlaylistFileService   F

Complexity

Total Complexity 63

Size/Duplication

Total Lines 483
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 230
c 2
b 0
f 0
dl 0
loc 483
rs 3.36
wmc 63

18 Methods

Rating   Name   Duplication   Size   Complexity  
A importFromFile() 0 29 5
A importRadioStationsFromFile() 0 19 3
A exportToFile() 0 29 3
A parseFile() 0 13 4
A exportRadioStationsToFile() 0 17 2
A __construct() 0 11 1
A parseM3uFile() 0 15 2
A parseM3uContent() 0 14 1
B parseM3uFilePointer() 0 34 7
A extractExtM3uField() 0 5 2
A parseWplFile() 0 20 3
B parsePlsContent() 0 48 8
B doParseFile() 0 51 9
A getFile() 0 6 2
A captionForTrack() 0 5 2
A checkFileNameConflict() 0 14 4
A findFile() 0 20 4
A parsePlsFile() 0 2 1

How to fix   Complexity   

Complex Class

Complex classes like PlaylistFileService often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use PlaylistFileService, and based on these observations, apply Extract Interface, too.

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 Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2020 - 2025
11
 */
12
13
namespace OCA\Music\Utility;
14
15
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
16
use OCA\Music\AppFramework\Core\Logger;
17
use OCA\Music\BusinessLayer\PlaylistBusinessLayer;
18
use OCA\Music\BusinessLayer\RadioStationBusinessLayer;
19
use OCA\Music\BusinessLayer\TrackBusinessLayer;
20
use OCA\Music\Db\SortBy;
21
use OCA\Music\Db\Track;
22
23
use OCP\Files\File;
24
use OCP\Files\Folder;
25
26
/**
27
 * Class responsible of exporting playlists to file and importing playlist
28
 * contents from file.
29
 */
30
class PlaylistFileService {
31
	private PlaylistBusinessLayer $playlistBusinessLayer;
32
	private RadioStationBusinessLayer $radioStationBusinessLayer;
33
	private TrackBusinessLayer $trackBusinessLayer;
34
	private StreamTokenService $tokenService;
35
	private Logger $logger;
36
37
	private const PARSE_LOCAL_FILES_ONLY = 1;
38
	private const PARSE_URLS_ONLY = 2;
39
	private const PARSE_LOCAL_FILES_AND_URLS = 3;
40
41
	public function __construct(
42
			PlaylistBusinessLayer $playlistBusinessLayer,
43
			RadioStationBusinessLayer $radioStationBusinessLayer,
44
			TrackBusinessLayer $trackBusinessLayer,
45
			StreamTokenService $tokenService,
46
			Logger $logger) {
47
		$this->playlistBusinessLayer = $playlistBusinessLayer;
48
		$this->radioStationBusinessLayer = $radioStationBusinessLayer;
49
		$this->trackBusinessLayer = $trackBusinessLayer;
50
		$this->tokenService = $tokenService;
51
		$this->logger = $logger;
52
	}
53
54
	/**
55
	 * export the playlist to a file
56
	 * @param int $id playlist ID
57
	 * @param string $userId owner of the playlist
58
	 * @param Folder $userFolder home dir of the user
59
	 * @param string $folderPath target parent folder path
60
	 * @param string $collisionMode action to take on file name collision,
61
	 *								supported values:
62
	 *								- 'overwrite' The existing file will be overwritten
63
	 *								- 'keepboth' The new file is named with a suffix to make it unique
64
	 *								- 'abort' (default) The operation will fail
65
	 * @return string path of the written file
66
	 * @throws BusinessLayerException if playlist with ID not found
67
	 * @throws \OCP\Files\NotFoundException if the $folderPath is not a valid folder
68
	 * @throws \RuntimeException on name conflict if $collisionMode == 'abort'
69
	 * @throws \OCP\Files\NotPermittedException if the user is not allowed to write to the given folder
70
	 */
71
	public function exportToFile(
72
			int $id, string $userId, Folder $userFolder, string $folderPath, string $collisionMode='abort') : string {
73
		$playlist = $this->playlistBusinessLayer->find($id, $userId);
74
		$tracks = $this->playlistBusinessLayer->getPlaylistTracks($id, $userId);
75
		$targetFolder = Util::getFolderFromRelativePath($userFolder, $folderPath);
76
77
		// Name the file according the playlist. File names cannot contain the '/' character on Linux, and in
78
		// owncloud/Nextcloud, the whole name must fit 250 characters, including the file extension. Reserve
79
		// another 5 characters to fit the postfix like " (xx)" on name collisions. If there are more than 100
80
		// exports of the same playlist with overly long name, then this function will fail but we can live
81
		// with that :).
82
		$filename = \str_replace('/', '-', $playlist->getName());
83
		$filename = Util::truncate($filename, 250 - 5 - 5);
84
		$filename .= '.m3u8';
85
		$filename = self::checkFileNameConflict($targetFolder, $filename, $collisionMode);
86
87
		$content = "#EXTM3U\n#EXTENC: UTF-8\n";
88
		foreach ($tracks as $track) {
89
			$nodes = $userFolder->getById($track->getFileId());
90
			if (\count($nodes) > 0) {
91
				$caption = self::captionForTrack($track);
92
				$content .= "#EXTINF:{$track->getLength()},$caption\n";
93
				$content .= Util::relativePath($targetFolder->getPath(), $nodes[0]->getPath()) . "\n";
94
			}
95
		}
96
		$file = $targetFolder->newFile($filename);
97
		$file->putContent($content);
98
99
		return $userFolder->getRelativePath($file->getPath());
100
	}
101
102
	/**
103
	 * export all the radio stations of a user to a file
104
	 * @param string $userId user
105
	 * @param Folder $userFolder home dir of the user
106
	 * @param string $folderPath target parent folder path
107
	 * @param string $filename target file name
108
	 * @param string $collisionMode action to take on file name collision,
109
	 *								supported values:
110
	 *								- 'overwrite' The existing file will be overwritten
111
	 *								- 'keepboth' The new file is named with a suffix to make it unique
112
	 *								- 'abort' (default) The operation will fail
113
	 * @return string path of the written file
114
	 * @throws \OCP\Files\NotFoundException if the $folderPath is not a valid folder
115
	 * @throws \RuntimeException on name conflict if $collisionMode == 'abort'
116
	 * @throws \OCP\Files\NotPermittedException if the user is not allowed to write to the given folder
117
	 */
118
	public function exportRadioStationsToFile(
119
			string $userId, Folder $userFolder, string $folderPath, string $filename, string $collisionMode='abort') : string {
120
		$targetFolder = Util::getFolderFromRelativePath($userFolder, $folderPath);
121
122
		$filename = self::checkFileNameConflict($targetFolder, $filename, $collisionMode);
123
124
		$stations = $this->radioStationBusinessLayer->findAll($userId, SortBy::Name);
125
126
		$content = "#EXTM3U\n#EXTENC: UTF-8\n";
127
		foreach ($stations as $station) {
128
			$content .= "#EXTINF:1,{$station->getName()}\n";
129
			$content .= $station->getStreamUrl() . "\n";
130
		}
131
		$file = $targetFolder->newFile($filename);
132
		$file->putContent($content);
133
134
		return $userFolder->getRelativePath($file->getPath());
135
	}
136
137
	/**
138
	 * import playlist contents from a file
139
	 * @param int $id playlist ID
140
	 * @param string $userId owner of the playlist
141
	 * @param Folder $userFolder user home dir
142
	 * @param string $filePath path of the file to import
143
	 * @parma string $mode one of the following:
144
	 * 						- 'append' (dafault) Append the imported tracks after the existing tracks on the list
145
	 * 						- 'overwrite' Replace any previous tracks on the list with the imported tracks
146
	 * @return array with three keys:
147
	 * 			- 'playlist': The Playlist entity after the modification
148
	 * 			- 'imported_count': An integer showing the number of tracks imported
149
	 * 			- 'failed_count': An integer showing the number of tracks in the file which could not be imported
150
	 * @throws BusinessLayerException if playlist with ID not found
151
	 * @throws \OCP\Files\NotFoundException if the $filePath is not a valid file
152
	 * @throws \UnexpectedValueException if the $filePath points to a file of unsupported type
153
	 */
154
	public function importFromFile(int $id, string $userId, Folder $userFolder, string $filePath, string $mode='append') : array {
155
		$parsed = self::doParseFile(self::getFile($userFolder, $filePath), $userFolder, self::PARSE_LOCAL_FILES_ONLY);
156
		$trackFilesAndCaptions = $parsed['files'];
157
		$invalidPaths = $parsed['invalid_paths'];
158
159
		$trackIds = [];
160
		foreach ($trackFilesAndCaptions as $trackFileAndCaption) {
161
			$trackFile = $trackFileAndCaption['file'];
162
			if ($track = $this->trackBusinessLayer->findByFileId($trackFile->getId(), $userId)) {
163
				$trackIds[] = $track->getId();
164
			} else {
165
				$invalidPaths[] = $trackFile->getPath();
166
			}
167
		}
168
169
		if ($mode === 'overwrite') {
170
			$this->playlistBusinessLayer->removeAllTracks($id, $userId);
171
		}
172
		$playlist = $this->playlistBusinessLayer->addTracks($trackIds, $id, $userId);
173
174
		if (\count($invalidPaths) > 0) {
175
			$this->logger->log('Some files were not found from the user\'s music library: '
176
								. \json_encode($invalidPaths, JSON_PARTIAL_OUTPUT_ON_ERROR), 'warn');
177
		}
178
179
		return [
180
			'playlist' => $playlist,
181
			'imported_count' => \count($trackIds),
182
			'failed_count' => \count($invalidPaths)
183
		];
184
	}
185
186
	/**
187
	 * import stream URLs from a playlist file and store them as internet radio stations
188
	 * @param string $userId user
189
	 * @param Folder $userFolder user home dir
190
	 * @param string $filePath path of the file to import
191
	 * @return array with two keys:
192
	 * 			- 'stations': Array of RadioStation objects imported from the file
193
	 * 			- 'failed_count': An integer showing the number of entries in the file which were not valid URLs
194
	 * @throws \OCP\Files\NotFoundException if the $filePath is not a valid file
195
	 * @throws \UnexpectedValueException if the $filePath points to a file of unsupported type
196
	 */
197
	public function importRadioStationsFromFile(string $userId, Folder $userFolder, string $filePath) : array {
198
		$parsed = self::doParseFile(self::getFile($userFolder, $filePath), $userFolder, self::PARSE_URLS_ONLY);
199
		$trackFilesAndCaptions = $parsed['files'];
200
		$invalidPaths = $parsed['invalid_paths'];
201
202
		$stations = [];
203
		foreach ($trackFilesAndCaptions as $trackFileAndCaption) {
204
			$stations[] = $this->radioStationBusinessLayer->create(
205
					$userId, $trackFileAndCaption['caption'], $trackFileAndCaption['url']);
206
		}
207
208
		if (\count($invalidPaths) > 0) {
209
			$this->logger->log('Some entries in the file were not valid streaming URLs: '
210
					. \json_encode($invalidPaths, JSON_PARTIAL_OUTPUT_ON_ERROR), 'warn');
211
		}
212
213
		return [
214
			'stations' => $stations,
215
			'failed_count' => \count($invalidPaths)
216
		];
217
	}
218
219
	/**
220
	 * Parse a playlist file and return the contained files
221
	 * @param int $fileId playlist file ID
222
	 * @param Folder $baseFolder ancestor folder of the playlist and the track files (e.g. user folder)
223
	 * @throws \OCP\Files\NotFoundException if the $fileId is not a valid file under the $baseFolder
224
	 * @throws \UnexpectedValueException if the $filePath points to a file of unsupported type
225
	 * @return array ['files' => array, 'invalid_paths' => string[]]
226
	 */
227
	public function parseFile(int $fileId, Folder $baseFolder) : array {
228
		$node = $baseFolder->getById($fileId)[0] ?? null;
229
		if ($node instanceof File) {
230
			$parsed = self::doParseFile($node, $baseFolder, self::PARSE_LOCAL_FILES_AND_URLS);
231
			// add security tokens for the external URL entries
232
			foreach ($parsed['files'] as &$entry) {
233
				if (isset($entry['url'])) {
234
					$entry['token'] = $this->tokenService->tokenForUrl($entry['url']);
235
				}
236
			}
237
			return $parsed;
238
		} else {
239
			throw new \OCP\Files\NotFoundException();
240
		}
241
	}
242
243
	/**
244
	 * @param File $file The playlist file to parse
245
	 * @param Folder $baseFolder Base folder for the local files
246
	 * @param int $mode One of self::[PARSE_LOCAL_FILES_ONLY, PARSE_URLS_ONLY, PARSE_LOCAL_FILES_AND_URLS]
247
	 * @throws \UnexpectedValueException
248
	 * @return array ['files' => array, 'invalid_paths' => string[]]
249
	 */
250
	private static function doParseFile(File $file, Folder $baseFolder, int $mode) : array {
251
		$mime = $file->getMimeType();
252
253
		if ($mime == 'audio/mpegurl') {
254
			$entries = self::parseM3uFile($file);
255
		} elseif ($mime == 'audio/x-scpls') {
256
			$entries = self::parsePlsFile($file);
257
		} elseif ($mime == 'application/vnd.ms-wpl') {
258
			$entries = self::parseWplFile($file);
259
		} else {
260
			throw new \UnexpectedValueException("file mime type '$mime' is not supported");
261
		}
262
263
		// find the parsed entries from the file system
264
		$trackFiles = [];
265
		$invalidPaths = [];
266
		$cwd = $baseFolder->getRelativePath($file->getParent()->getPath());
267
268
		foreach ($entries as $entry) {
269
			$path = $entry['path'];
270
271
			if (Util::startsWith($path, 'http', /*ignoreCase=*/true)) {
272
				if ($mode !== self::PARSE_LOCAL_FILES_ONLY) {
273
					$trackFiles[] = [
274
						'url' => $path,
275
						'caption' => $entry['caption']
276
					];
277
				} else {
278
					$invalidPaths[] = $path;
279
				}
280
			} else {
281
				if ($mode !== self::PARSE_URLS_ONLY) {
282
					$entryFile = self::findFile($baseFolder, $cwd, $path);
283
284
					if ($entryFile !== null) {
285
						$trackFiles[] = [
286
							'file' => $entryFile,
287
							'caption' => $entry['caption']
288
						];
289
					} else {
290
						$invalidPaths[] = $path;
291
					}
292
				} else {
293
					$invalidPaths[] = $path;
294
				}
295
			}
296
		}
297
298
		return [
299
			'files' => $trackFiles,
300
			'invalid_paths' => $invalidPaths
301
		];
302
	}
303
304
	private static function parseM3uFile(File $file) : array {
305
		/* Files with extension .m3u8 are always treated as UTF-8.
306
		 * Files with extension .m3u are analyzed and treated as UTF-8 if they seem to be valid UTF-8;
307
		 * otherwise they are treated as ISO-8859-1 which was the original encoding used when that file
308
		 * type was introduced. There's no any kind of official standard to follow here.
309
		 */
310
		if (Util::endsWith($file->getPath(), '.m3u8', /*ignoreCase=*/true)) {
311
			$fp = $file->fopen('r');
312
			$entries = self::parseM3uFilePointer($fp, 'UTF-8');
313
			\fclose($fp);
314
		} else {
315
			$entries = self::parseM3uContent($file->getContent());
316
		}
317
318
		return $entries;
319
	}
320
321
	public static function parseM3uContent(string $content) {
322
		$fp = \fopen("php://temp", 'r+');
323
		\assert($fp !== false, 'Unexpected error: opening temporary stream failed');
324
325
		\fputs($fp, /** @scrutinizer ignore-type */ $content);
326
		\rewind($fp);
327
328
		$encoding = \mb_detect_encoding($content, ['UTF-8', 'ISO-8859-1']);
329
		\assert(\is_string($encoding), 'Unexpected error: can\'t detect encoding'); // shouldn't fail since any byte stream is valid ISO-8859-1
330
		$entries = self::parseM3uFilePointer($fp, $encoding);
331
332
		\fclose($fp);
333
334
		return $entries;
335
	}
336
337
	private static function parseM3uFilePointer($fp, string $encoding) : array {
338
		$entries = [];
339
340
		$caption = null;
341
342
		while ($line = \fgets($fp)) {
343
			$line = \mb_convert_encoding($line, /** @scrutinizer ignore-type */ \mb_internal_encoding(), $encoding);
344
			$line = \trim(/** @scrutinizer ignore-type */ $line);
345
346
			if ($line === '') {
347
				// empty line => skip
348
			} elseif (Util::startsWith($line, '#')) {
349
				// comment or extended format attribute line
350
				if ($value = self::extractExtM3uField($line, 'EXTENC')) {
351
					// update the used encoding with the explicitly defined one
352
					$encoding = $value;
353
				} elseif ($value = self::extractExtM3uField($line, 'EXTINF')) {
354
					// The format should be "length,caption". Set caption to null if the field is badly formatted.
355
					$parts = \explode(',', $value, 2);
356
					$caption = $parts[1] ?? null;
357
					if (\is_string($caption)) {
358
						$caption = \trim($caption);
359
					}
360
				}
361
			} else {
362
				$entries[] = [
363
					'path' => $line,
364
					'caption' => $caption
365
				];
366
				$caption = null; // the caption has been used up
367
			}
368
		}
369
370
		return $entries;
371
	}
372
373
	private static function parsePlsFile(File $file) : array {
374
		return self::parsePlsContent($file->getContent());
375
	}
376
377
	public static function parsePlsContent(string $content) : array {
378
		$files = [];
379
		$titles = [];
380
381
		// If the file doesn't seem to be UTF-8, then assume it to be ISO-8859-1
382
		if (!\mb_check_encoding($content, 'UTF-8')) {
383
			$content = \mb_convert_encoding($content, 'UTF-8', 'ISO-8859-1');
384
		}
385
386
		$fp = \fopen("php://temp", 'r+');
387
		\assert($fp !== false, 'Unexpected error: opening temporary stream failed');
388
389
		\fputs($fp, /** @scrutinizer ignore-type */ $content);
390
		\rewind($fp);
391
392
		// the first line should always be [playlist]
393
		if (\trim(\fgets($fp)) != '[playlist]') {
394
			throw new \UnexpectedValueException('the file is not in valid PLS format');
395
		}
396
397
		// the rest of the non-empty lines should be in format "key=value"
398
		while ($line = \fgets($fp)) {
399
			// ignore empty and malformed lines
400
			if (\strpos($line, '=') !== false) {
401
				list($key, $value) = \explode('=', $line, 2);
402
				$key = \trim($key);
403
				$value = \trim($value);
404
				// we are interested only on the File# and Title# lines
405
				if (Util::startsWith($key, 'File')) {
406
					$idx = \substr($key, \strlen('File'));
407
					$files[$idx] = $value;
408
				} elseif (Util::startsWith($key, 'Title')) {
409
					$idx = \substr($key, \strlen('Title'));
410
					$titles[$idx] = $value;
411
				}
412
			}
413
		}
414
		\fclose($fp);
415
416
		$entries = [];
417
		foreach ($files as $idx => $filePath) {
418
			$entries[] = [
419
				'path' => $filePath,
420
				'caption' => $titles[$idx] ?? null
421
			];
422
		}
423
424
		return $entries;
425
	}
426
427
	public static function parseWplFile(File $file) : array {
428
		$entries = [];
429
430
		$rootNode = \simplexml_load_string($file->getContent(), \SimpleXMLElement::class, LIBXML_NOCDATA);
431
		if ($rootNode === false) {
432
			throw new \UnexpectedValueException('the file is not in valid WPL format');
433
		}
434
435
		$mediaNodes = $rootNode->xpath('body/seq/media');
436
437
		foreach ($mediaNodes as $node) {
438
			$path = (string)$node->attributes()['src'];
439
			$path = \str_replace('\\', '/', $path); // WPL is a Windows format and uses backslashes as directory separators
440
			$entries[] = [
441
				'path' => $path,
442
				'caption' => null
443
			];
444
		}
445
446
		return $entries;
447
	}
448
449
	private static function checkFileNameConflict(Folder $targetFolder, string $filename, string $collisionMode) : string {
450
		if ($targetFolder->nodeExists($filename)) {
451
			switch ($collisionMode) {
452
				case 'overwrite':
453
					$targetFolder->get($filename)->delete();
454
					break;
455
				case 'keepboth':
456
					$filename = $targetFolder->getNonExistingName($filename);
457
					break;
458
				default:
459
					throw new \RuntimeException('file already exists');
460
			}
461
		}
462
		return $filename;
463
	}
464
465
	private static function captionForTrack(Track $track) : string {
466
		$title = $track->getTitle();
467
		$artist = $track->getArtistName();
468
469
		return empty($artist) ? $title : "$artist - $title";
470
	}
471
472
	private static function extractExtM3uField($line, $field) : ?string {
473
		if (Util::startsWith($line, "#$field:")) {
474
			return \trim(\substr($line, \strlen("#$field:")));
475
		} else {
476
			return null;
477
		}
478
	}
479
480
	private static function findFile(Folder $baseFolder, string $cwd, string $path) : ?File {
481
		$absPath = Util::resolveRelativePath($cwd, $path);
482
483
		try {
484
			/** @throws \OCP\Files\NotFoundException | \OCP\Files\NotPermittedException */
485
			$file = $baseFolder->get($absPath);
486
			if ($file instanceof File) {
487
				return $file;
488
			} else {
489
				return null;
490
			}
491
		} catch (\OCP\Files\NotFoundException | \OCP\Files\NotPermittedException $ex) {
492
			/* In case the file is not found and the path contains any backslashes, consider the possibility
493
			 * that the path follows the Windows convention of using backslashes as path separators.
494
			 */
495
			if (\strpos($path, '\\') !== false) {
496
				$path = \str_replace('\\', '/', $path);
497
				return self::findFile($baseFolder, $cwd, $path);
498
			} else {
499
				return null;
500
			}
501
		}
502
	}
503
504
	/**
505
	 * @throws \OCP\Files\NotFoundException if the $path does not point to a file under the $baseFolder
506
	 */
507
	private static function getFile(Folder $baseFolder, string $path) : File {
508
		$node = $baseFolder->get($path);
509
		if (!($node instanceof File)) {
510
			throw new \OCP\Files\NotFoundException();
511
		}
512
		return $node;
513
	}
514
515
}
516