Passed
Push — master ( 4ba04f...598605 )
by Pauli
03:26 queued 13s
created

PlaylistFileService::exportRadioStationsToFile()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 17
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 10
c 0
b 0
f 0
nc 2
nop 5
dl 0
loc 17
rs 9.9332
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 - 2022
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='abort') : 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='abort') : 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
	 * @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
223
	 */
224
	public function parseFile(int $fileId, Folder $baseFolder) : array {
225
		$node = $baseFolder->getById($fileId)[0] ?? null;
226
		if ($node instanceof File) {
227
			return self::doParseFile($node, $baseFolder, self::PARSE_LOCAL_FILES_AND_URLS);
228
		} else {
229
			throw new \OCP\Files\NotFoundException();
230
		}
231
	}
232
233
	/**
234
	 * @param File $file The playlist file to parse
235
	 * @param Folder $baseFolder Base folder for the local files
236
	 * @param int $mode One of self::[PARSE_LOCAL_FILES_ONLY, PARSE_URLS_ONLY, PARSE_LOCAL_FILES_AND_URLS]
237
	 * @throws \UnexpectedValueException
238
	 * @return array
239
	 */
240
	private static function doParseFile(File $file, Folder $baseFolder, int $mode) : array {
241
		$mime = $file->getMimeType();
242
243
		if ($mime == 'audio/mpegurl') {
244
			$entries = self::parseM3uFile($file);
245
		} elseif ($mime == 'audio/x-scpls') {
246
			$entries = self::parsePlsFile($file);
247
		} else {
248
			throw new \UnexpectedValueException("file mime type '$mime' is not suported");
249
		}
250
251
		// find the parsed entries from the file system
252
		$trackFiles = [];
253
		$invalidPaths = [];
254
		$cwd = $baseFolder->getRelativePath($file->getParent()->getPath());
255
256
		foreach ($entries as $entry) {
257
			$path = $entry['path'];
258
259
			if (Util::startsWith($path, 'http', /*ignoreCase=*/true)) {
260
				if ($mode !== self::PARSE_LOCAL_FILES_ONLY) {
261
					$trackFiles[] = [
262
						'url' => $path,
263
						'caption' => $entry['caption']
264
					];
265
				} else {
266
					$invalidPaths[] = $path;
267
				}
268
			} else {
269
				if ($mode !== self::PARSE_URLS_ONLY) {
270
					$entryFile = self::findFile($baseFolder, $cwd, $path);
271
272
					if ($entryFile !== null) {
273
						$trackFiles[] = [
274
							'file' => $entryFile,
275
							'caption' => $entry['caption']
276
						];
277
					} else {
278
						$invalidPaths[] = $path;
279
					}
280
				} else {
281
					$invalidPaths[] = $path;
282
				}
283
			}
284
		}
285
286
		return [
287
			'files' => $trackFiles,
288
			'invalid_paths' => $invalidPaths
289
		];
290
	}
291
292
	private static function parseM3uFile(File $file) : array {
293
		// By default, files with extension .m3u8 are interpreted as UTF-8 and files with extension
294
		// .m3u as ISO-8859-1. These can be overridden with the tag '#EXTENC' in the file contents.
295
		$encoding = Util::endsWith($file->getPath(), '.m3u8', /*ignoreCase=*/true) ? 'UTF-8' : 'ISO-8859-1';
296
297
		$fp = $file->fopen('r');
298
		$entries = self::parseM3uFilePointer($fp, $encoding);
299
		\fclose($fp);
300
301
		return $entries;
302
	}
303
304
	private static function parseM3uFilePointer($fp, string $encoding) : array {
305
		$entries = [];
306
307
		$caption = null;
308
309
		while ($line = \fgets($fp)) {
310
			$line = \mb_convert_encoding($line, /** @scrutinizer ignore-type */ \mb_internal_encoding(), $encoding);
311
			$line = \trim(/** @scrutinizer ignore-type */ $line);
312
313
			if ($line === '') {
314
				// empty line => skip
315
			} elseif (Util::startsWith($line, '#')) {
316
				// comment or extended format attribute line
317
				if ($value = self::extractExtM3uField($line, 'EXTENC')) {
318
					// update the used encoding with the explicitly defined one
319
					$encoding = $value;
320
				} elseif ($value = self::extractExtM3uField($line, 'EXTINF')) {
321
					// The format should be "length,caption". Set caption to null if the field is badly formatted.
322
					$parts = \explode(',', $value, 2);
323
					$caption = $parts[1] ?? null;
324
					if (\is_string($caption)) {
325
						$caption = \trim($caption);
326
					}
327
				}
328
			} else {
329
				$entries[] = [
330
					'path' => $line,
331
					'caption' => $caption
332
				];
333
				$caption = null; // the caption has been used up
334
			}
335
		}
336
337
		return $entries;
338
	}
339
340
	public static function parseM3uContent(string $content, string $encoding) {
341
		$fp = \fopen("php://temp", 'r+');
342
		\assert($fp !== false, 'Unexpected error: opening temporary stream failed');
343
344
		\fputs($fp, /** @scrutinizer ignore-type */ $content);
345
		\rewind($fp);
346
347
		$entries = self::parseM3uFilePointer($fp, $encoding);
348
349
		\fclose($fp);
350
351
		return $entries;
352
	}
353
354
	private static function parsePlsFile(File $file) : array {
355
		return self::parsePlsContent($file->getContent());
356
	}
357
358
	public static function parsePlsContent(string $content) : array {
359
		$files = [];
360
		$titles = [];
361
362
		// If the file doesn't seem to be UTF-8, then assume it to be ISO-8859-1
363
		if (!\mb_check_encoding($content, 'UTF-8')) {
364
			$content = \mb_convert_encoding($content, 'UTF-8', 'ISO-8859-1');
365
		}
366
367
		$fp = \fopen("php://temp", 'r+');
368
		\assert($fp !== false, 'Unexpected error: opening temporary stream failed');
369
370
		\fputs($fp, /** @scrutinizer ignore-type */ $content);
371
		\rewind($fp);
372
373
		// the first line should always be [playlist]
374
		if (\trim(\fgets($fp)) != '[playlist]') {
375
			throw new \UnexpectedValueException('the file is not in valid PLS format');
376
		}
377
378
		// the rest of the non-empty lines should be in format "key=value"
379
		while ($line = \fgets($fp)) {
380
			// ignore empty and malformed lines
381
			if (\strpos($line, '=') !== false) {
382
				list($key, $value) = \explode('=', $line, 2);
383
				$key = \trim($key);
384
				$value = \trim($value);
385
				// we are interested only on the File# and Title# lines
386
				if (Util::startsWith($key, 'File')) {
387
					$idx = \substr($key, \strlen('File'));
388
					$files[$idx] = $value;
389
				} elseif (Util::startsWith($key, 'Title')) {
390
					$idx = \substr($key, \strlen('Title'));
391
					$titles[$idx] = $value;
392
				}
393
			}
394
		}
395
		\fclose($fp);
396
397
		$entries = [];
398
		foreach ($files as $idx => $filePath) {
399
			$entries[] = [
400
				'path' => $filePath,
401
				'caption' => $titles[$idx] ?? null
402
			];
403
		}
404
405
		return $entries;
406
	}
407
408
	private static function checkFileNameConflict(Folder $targetFolder, string $filename, string $collisionMode) : string {
409
		if ($targetFolder->nodeExists($filename)) {
410
			switch ($collisionMode) {
411
				case 'overwrite':
412
					$targetFolder->get($filename)->delete();
413
					break;
414
				case 'keepboth':
415
					$filename = $targetFolder->getNonExistingName($filename);
416
					break;
417
				default:
418
					throw new \RuntimeException('file already exists');
419
			}
420
		}
421
		return $filename;
422
	}
423
424
	private static function captionForTrack(Track $track) : string {
425
		$title = $track->getTitle();
426
		$artist = $track->getArtistName();
427
428
		return empty($artist) ? $title : "$artist - $title";
429
	}
430
431
	private static function extractExtM3uField($line, $field) : ?string {
432
		if (Util::startsWith($line, "#$field:")) {
433
			return \trim(\substr($line, \strlen("#$field:")));
434
		} else {
435
			return null;
436
		}
437
	}
438
439
	private static function findFile(Folder $baseFolder, string $cwd, string $path) : ?File {
440
		$absPath = Util::resolveRelativePath($cwd, $path);
441
442
		try {
443
			$file = $baseFolder->get($absPath);
444
			if ($file instanceof File) {
445
				return $file;
446
			} else {
447
				return null;
448
			}
449
		} catch (\OCP\Files\NotFoundException | \OCP\Files\NotPermittedException $ex) {
450
			/* In case the file is not found and the path contains any backslashes, consider the possibility
451
			 * that the path follows the Windows convention of using backslashes as path separators.
452
			 */
453
			if (\strpos($path, '\\') !== false) {
454
				$path = \str_replace('\\', '/', $path);
455
				return self::findFile($baseFolder, $cwd, $path);
456
			} else {
457
				return null;
458
			}
459
		}
460
	}
461
462
	/**
463
	 * @throws \OCP\Files\NotFoundException if the $path does not point to a file under the $baseFolder
464
	 */
465
	private static function getFile(Folder $baseFolder, string $path) : File {
466
		$node = $baseFolder->get($path);
467
		if (!($node instanceof File)) {
468
			throw new \OCP\Files\NotFoundException();
469
		}
470
		return $node;
471
	}
472
473
}
474