Passed
Push — master ( b806b4...5b46ab )
by Pauli
02:42
created

PlaylistFileService::extractExtM3uField()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 2
dl 0
loc 5
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 Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2020, 2021
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;
32
	private $radioStationBusinessLayer;
33
	private $trackBusinessLayer;
34
	private $logger;
35
36
	private const PARSE_LOCAL_FILES_ONLY = 1;
37
	private const PARSE_URLS_ONLY = 2;
38
	private const PARSE_LOCAL_FILES_AND_URLS = 3;
39
40
	public function __construct(
41
			PlaylistBusinessLayer $playlistBusinessLayer,
42
			RadioStationBusinessLayer $radioStationBusinessLayer,
43
			TrackBusinessLayer $trackBusinessLayer,
44
			Logger $logger) {
45
		$this->playlistBusinessLayer = $playlistBusinessLayer;
46
		$this->radioStationBusinessLayer = $radioStationBusinessLayer;
47
		$this->trackBusinessLayer = $trackBusinessLayer;
48
		$this->logger = $logger;
49
	}
50
51
	/**
52
	 * export the playlist to a file
53
	 * @param int $id playlist ID
54
	 * @param string $userId owner of the playlist
55
	 * @param Folder $userFolder home dir of the user
56
	 * @param string $folderPath target parent folder path
57
	 * @param string $collisionMode action to take on file name collision,
58
	 *								supported values:
59
	 *								- 'overwrite' The existing file will be overwritten
60
	 *								- 'keepboth' The new file is named with a suffix to make it unique
61
	 *								- 'abort' (default) The operation will fail
62
	 * @return string path of the written file
63
	 * @throws BusinessLayerException if playlist with ID not found
64
	 * @throws \OCP\Files\NotFoundException if the $folderPath is not a valid folder
65
	 * @throws \RuntimeException on name conflict if $collisionMode == 'abort'
66
	 * @throws \OCP\Files\NotPermittedException if the user is not allowed to write to the given folder
67
	 */
68
	public function exportToFile(
69
			int $id, string $userId, Folder $userFolder, string $folderPath, string $collisionMode) : string {
70
		$playlist = $this->playlistBusinessLayer->find($id, $userId);
71
		$tracks = $this->playlistBusinessLayer->getPlaylistTracks($id, $userId);
72
		$targetFolder = Util::getFolderFromRelativePath($userFolder, $folderPath);
73
74
		// Name the file according the playlist. File names cannot contain the '/' character on Linux, and in
75
		// owncloud/Nextcloud, the whole name must fit 250 characters, including the file extension. Reserve
76
		// another 5 characters to fit the postfix like " (xx)" on name collisions. If there are more than 100
77
		// exports of the same playlist with overly long name, then this function will fail but we can live
78
		// with that :).
79
		$filename = \str_replace('/', '-', $playlist->getName());
80
		$filename = Util::truncate($filename, 250 - 5 - 5);
81
		$filename .= '.m3u8';
82
		$filename = self::checkFileNameConflict($targetFolder, $filename, $collisionMode);
83
84
		$content = "#EXTM3U\n#EXTENC: UTF-8\n";
85
		foreach ($tracks as $track) {
86
			$nodes = $userFolder->getById($track->getFileId());
87
			if (\count($nodes) > 0) {
88
				$caption = self::captionForTrack($track);
89
				$content .= "#EXTINF:{$track->getLength()},$caption\n";
90
				$content .= Util::relativePath($targetFolder->getPath(), $nodes[0]->getPath()) . "\n";
91
			}
92
		}
93
		$file = $targetFolder->newFile($filename);
94
		$file->putContent($content);
95
96
		return $userFolder->getRelativePath($file->getPath());
97
	}
98
99
	/**
100
	 * export all the radio stations of a user to a file
101
	 * @param string $userId user
102
	 * @param Folder $userFolder home dir of the user
103
	 * @param string $folderPath target parent folder path
104
	 * @param string $filename target file name
105
	 * @param string $collisionMode action to take on file name collision,
106
	 *								supported values:
107
	 *								- 'overwrite' The existing file will be overwritten
108
	 *								- 'keepboth' The new file is named with a suffix to make it unique
109
	 *								- 'abort' (default) The operation will fail
110
	 * @return string path of the written file
111
	 * @throws \OCP\Files\NotFoundException if the $folderPath is not a valid folder
112
	 * @throws \RuntimeException on name conflict if $collisionMode == 'abort'
113
	 * @throws \OCP\Files\NotPermittedException if the user is not allowed to write to the given folder
114
	 */
115
	public function exportRadioStationsToFile(
116
			string $userId, Folder $userFolder, string $folderPath, string $filename, string $collisionMode) : string {
117
		$targetFolder = Util::getFolderFromRelativePath($userFolder, $folderPath);
118
119
		$filename = self::checkFileNameConflict($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
	 * @return array with three keys:
141
	 * 			- 'playlist': The Playlist entity after the modification
142
	 * 			- 'imported_count': An integer showing the number of tracks imported
143
	 * 			- 'failed_count': An integer showing the number of tracks in the file which could not be imported
144
	 * @throws BusinessLayerException if playlist with ID not found
145
	 * @throws \OCP\Files\NotFoundException if the $filePath is not a valid file
146
	 * @throws \UnexpectedValueException if the $filePath points to a file of unsupported type
147
	 */
148
	public function importFromFile(int $id, string $userId, Folder $userFolder, string $filePath) : array {
149
		$parsed = self::doParseFile(self::getFile($userFolder, $filePath), $userFolder, self::PARSE_LOCAL_FILES_ONLY);
150
		$trackFilesAndCaptions = $parsed['files'];
151
		$invalidPaths = $parsed['invalid_paths'];
152
153
		$trackIds = [];
154
		foreach ($trackFilesAndCaptions as $trackFileAndCaption) {
155
			$trackFile = $trackFileAndCaption['file'];
156
			if ($track = $this->trackBusinessLayer->findByFileId($trackFile->getId(), $userId)) {
157
				$trackIds[] = $track->getId();
158
			} else {
159
				$invalidPaths[] = $trackFile->getPath();
160
			}
161
		}
162
163
		$playlist = $this->playlistBusinessLayer->addTracks($trackIds, $id, $userId);
164
165
		if (\count($invalidPaths) > 0) {
166
			$this->logger->log('Some files were not found from the user\'s music library: '
167
								. \json_encode($invalidPaths, JSON_PARTIAL_OUTPUT_ON_ERROR), 'warn');
168
		}
169
170
		return [
171
			'playlist' => $playlist,
172
			'imported_count' => \count($trackIds),
173
			'failed_count' => \count($invalidPaths)
174
		];
175
	}
176
177
	/**
178
	 * import stream URLs from a playlist file and store them as internet radio stations
179
	 * @param string $userId user
180
	 * @param Folder $userFolder user home dir
181
	 * @param string $filePath path of the file to import
182
	 * @return array with two keys:
183
	 * 			- 'stations': Array of RadioStation objects imported from the file
184
	 * 			- 'failed_count': An integer showing the number of entries in the file which were not valid URLs
185
	 * @throws \OCP\Files\NotFoundException if the $filePath is not a valid file
186
	 * @throws \UnexpectedValueException if the $filePath points to a file of unsupported type
187
	 */
188
	public function importRadioStationsFromFile(string $userId, Folder $userFolder, string $filePath) : array {
189
		$parsed = self::doParseFile(self::getFile($userFolder, $filePath), $userFolder, self::PARSE_URLS_ONLY);
190
		$trackFilesAndCaptions = $parsed['files'];
191
		$invalidPaths = $parsed['invalid_paths'];
192
193
		$stations = [];
194
		foreach ($trackFilesAndCaptions as $trackFileAndCaption) {
195
			$stations[] = $this->radioStationBusinessLayer->create(
196
					$userId, $trackFileAndCaption['caption'], $trackFileAndCaption['url']);
197
		}
198
199
		if (\count($invalidPaths) > 0) {
200
			$this->logger->log('Some entries in the file were not valid streaming URLs: '
201
					. \json_encode($invalidPaths, JSON_PARTIAL_OUTPUT_ON_ERROR), 'warn');
202
		}
203
204
		return [
205
			'stations' => $stations,
206
			'failed_count' => \count($invalidPaths)
207
		];
208
	}
209
210
	/**
211
	 * Parse a playlist file and return the contained files
212
	 * @param int $fileId playlist file ID
213
	 * @param Folder $baseFolder ancestor folder of the playlist and the track files (e.g. user folder)
214
	 * @throws \OCP\Files\NotFoundException if the $fileId is not a valid file under the $baseFolder
215
	 * @throws \UnexpectedValueException if the $filePath points to a file of unsupported type
216
	 * @return array
217
	 */
218
	public function parseFile(int $fileId, Folder $baseFolder) : array {
219
		$node = $baseFolder->getById($fileId)[0] ?? null;
220
		if ($node instanceof File) {
221
			return self::doParseFile($node, $baseFolder, self::PARSE_LOCAL_FILES_AND_URLS);
222
		} else {
223
			throw new \OCP\Files\NotFoundException();
224
		}
225
	}
226
227
	/**
228
	 * @param File $file The playlist file to parse
229
	 * @param Folder $baseFolder Base folder for the local files
230
	 * @param int $mode One of self::[PARSE_LOCAL_FILES_ONLY, PARSE_URLS_ONLY, PARSE_LOCAL_FILES_AND_URLS]
231
	 * @throws \UnexpectedValueException
232
	 * @return array
233
	 */
234
	private static function doParseFile(File $file, Folder $baseFolder, int $mode) : array {
235
		$mime = $file->getMimeType();
236
237
		if ($mime == 'audio/mpegurl') {
238
			$entries = self::parseM3uFile($file);
239
		} elseif ($mime == 'audio/x-scpls') {
240
			$entries = self::parsePlsFile($file);
241
		} else {
242
			throw new \UnexpectedValueException("file mime type '$mime' is not suported");
243
		}
244
245
		// find the parsed entries from the file system
246
		$trackFiles = [];
247
		$invalidPaths = [];
248
		$cwd = $baseFolder->getRelativePath($file->getParent()->getPath());
249
250
		foreach ($entries as $entry) {
251
			$path = $entry['path'];
252
253
			if (Util::startsWith($path, 'http', /*ignoreCase=*/true)) {
254
				if ($mode !== self::PARSE_LOCAL_FILES_ONLY) {
255
					$trackFiles[] = [
256
						'url' => $path,
257
						'caption' => $entry['caption']
258
					];
259
				} else {
260
					$invalidPaths[] = $path;
261
				}
262
			} else {
263
				if ($mode !== self::PARSE_URLS_ONLY) {
264
					$entryFile = self::findFile($baseFolder, $cwd, $path);
265
266
					if ($entryFile !== null) {
267
						$trackFiles[] = [
268
							'file' => $entryFile,
269
							'caption' => $entry['caption']
270
						];
271
					} else {
272
						$invalidPaths[] = $path;
273
					}
274
				} else {
275
					$invalidPaths[] = $path;
276
				}
277
			}
278
		}
279
280
		return [
281
			'files' => $trackFiles,
282
			'invalid_paths' => $invalidPaths
283
		];
284
	}
285
286
	private static function parseM3uFile(File $file) : array {
287
		$entries = [];
288
289
		// By default, files with extension .m3u8 are interpreted as UTF-8 and files with extension
290
		// .m3u as ISO-8859-1. These can be overridden with the tag '#EXTENC' in the file contents.
291
		$encoding = Util::endsWith($file->getPath(), '.m3u8', /*ignoreCase=*/true) ? 'UTF-8' : 'ISO-8859-1';
292
293
		$caption = null;
294
295
		$fp = $file->fopen('r');
296
		while ($line = \fgets($fp)) {
297
			$line = \mb_convert_encoding($line, /** @scrutinizer ignore-type */ \mb_internal_encoding(), $encoding);
298
			$line = \trim(/** @scrutinizer ignore-type */ $line);
299
300
			if ($line === '') {
301
				// empty line => skip
302
			} elseif (Util::startsWith($line, '#')) {
303
				// comment or extended format attribute line
304
				if ($value = self::extractExtM3uField($line, 'EXTENC')) {
305
					// update the used encoding with the explicitly defined one
306
					$encoding = $value;
307
				} elseif ($value = self::extractExtM3uField($line, 'EXTINF')) {
308
					// The format should be "length,caption". Set caption to null if the field is badly formatted.
309
					$parts = \explode(',', $value, 2);
310
					$caption = $parts[1] ?? null;
311
					if (\is_string($caption)) {
312
						$caption = \trim($caption);
313
					}
314
				}
315
			} else {
316
				$entries[] = [
317
					'path' => $line,
318
					'caption' => $caption
319
				];
320
				$caption = null; // the caption has been used up
321
			}
322
		}
323
		\fclose($fp);
324
325
		return $entries;
326
	}
327
328
	private static function parsePlsFile(File $file) : array {
329
		$files = [];
330
		$titles = [];
331
332
		$content = $file->getContent();
333
334
		// If the file doesn't seem to be UTF-8, then assume it to be ISO-8859-1
335
		if (!\mb_check_encoding($content, 'UTF-8')) {
336
			$content = \mb_convert_encoding($content, 'UTF-8', 'ISO-8859-1');
337
		}
338
339
		$fp = \fopen("php://temp", 'r+');
340
		\assert($fp !== false, 'Unexpected error: opening temporary stream failed');
341
342
		\fputs($fp, /** @scrutinizer ignore-type */ $content);
343
		\rewind($fp);
344
345
		// the first line should always be [playlist]
346
		if (\trim(\fgets($fp)) != '[playlist]') {
347
			throw new \UnexpectedValueException('the file is not in valid PLS format');
348
		}
349
350
		// the rest of the non-empty lines should be in format "key=value"
351
		while ($line = \fgets($fp)) {
352
			// ignore empty and malformed lines
353
			if (\strpos($line, '=') !== false) {
354
				list($key, $value) = \explode('=', $line, 2);
355
				$key = \trim($key);
356
				$value = \trim($value);
357
				// we are interested only on the File# and Title# lines
358
				if (Util::startsWith($key, 'File')) {
359
					$idx = \substr($key, \strlen('File'));
360
					$files[$idx] = $value;
361
				} elseif (Util::startsWith($key, 'Title')) {
362
					$idx = \substr($key, \strlen('Title'));
363
					$titles[$idx] = $value;
364
				}
365
			}
366
		}
367
		\fclose($fp);
368
369
		$entries = [];
370
		foreach ($files as $idx => $file) {
371
			$entries[] = [
372
				'path' => $file,
373
				'caption' => $titles[$idx] ?? null
374
			];
375
		}
376
377
		return $entries;
378
	}
379
380
	private static function checkFileNameConflict(Folder $targetFolder, string $filename, string $collisionMode) : string {
381
		if ($targetFolder->nodeExists($filename)) {
382
			switch ($collisionMode) {
383
				case 'overwrite':
384
					$targetFolder->get($filename)->delete();
385
					break;
386
				case 'keepboth':
387
					$filename = $targetFolder->getNonExistingName($filename);
388
					break;
389
				default:
390
					throw new \RuntimeException('file already exists');
391
			}
392
		}
393
		return $filename;
394
	}
395
396
	private static function captionForTrack(Track $track) : string {
397
		$title = $track->getTitle();
398
		$artist = $track->getArtistName();
399
400
		return empty($artist) ? $title : "$artist - $title";
401
	}
402
403
	private static function extractExtM3uField($line, $field) : ?string {
404
		if (Util::startsWith($line, "#$field:")) {
405
			return \trim(\substr($line, \strlen("#$field:")));
406
		} else {
407
			return null;
408
		}
409
	}
410
411
	private static function findFile(Folder $baseFolder, string $cwd, string $path) : ?File {
412
		$absPath = Util::resolveRelativePath($cwd, $path);
413
414
		try {
415
			$file = $baseFolder->get($absPath);
416
			if ($file instanceof File) {
417
				return $file;
418
			} else {
419
				return null;
420
			}
421
		} catch (\OCP\Files\NotFoundException | \OCP\Files\NotPermittedException $ex) {
422
			/* In case the file is not found and the path contains any backslashes, consider the possibility
423
			 * that the path follows the Windows convention of using backslashes as path separators.
424
			 */
425
			if (\strpos($path, '\\') !== false) {
426
				$path = \str_replace('\\', '/', $path);
427
				return self::findFile($baseFolder, $cwd, $path);
428
			} else {
429
				return null;
430
			}
431
		}
432
	}
433
434
	/**
435
	 * @throws \OCP\Files\NotFoundException if the $path does not point to a file under the $baseFolder
436
	 */
437
	private static function getFile(Folder $baseFolder, string $path) : File {
438
		$node = $baseFolder->get($path);
439
		if (!($node instanceof File)) {
440
			throw new \OCP\Files\NotFoundException();
441
		}
442
		return $node;
443
	}
444
445
}
446