PlaylistFileService   F
last analyzed

Complexity

Total Complexity 61

Size/Duplication

Total Lines 466
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 217
c 0
b 0
f 0
dl 0
loc 466
rs 3.52
wmc 61

17 Methods

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

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