Passed
Push — master ( 0cee60...124be7 )
by Pauli
14:22
created

PlaylistFileService::sanitizeFileName()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 22
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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