Passed
Push — master ( 2447d4...95d3a4 )
by Pauli
02:17
created

PlaylistFileService::doParseFile()   B

Complexity

Conditions 7
Paths 11

Size

Total Lines 46
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 56

Importance

Changes 4
Bugs 0 Features 0
Metric Value
cc 7
eloc 30
c 4
b 0
f 0
nc 11
nop 3
dl 0
loc 46
ccs 0
cts 26
cp 0
crap 56
rs 8.5066
1
<?php
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
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\TrackBusinessLayer;
19
use \OCA\Music\Db\Track;
20
21
use \OCP\Files\File;
22
use \OCP\Files\Folder;
23
24
/**
25
 * Class responsible of exporting playlists to file and importing playlist
26
 * contents from file.
27
 */
28
class PlaylistFileService {
29
	private $playlistBusinessLayer;
30
	private $trackBusinessLayer;
31
	private $logger;
32
33
	public function __construct(
34
			PlaylistBusinessLayer $playlistBusinessLayer,
35
			TrackBusinessLayer $trackBusinessLayer,
36
			Logger $logger) {
37
		$this->playlistBusinessLayer = $playlistBusinessLayer;
38
		$this->trackBusinessLayer = $trackBusinessLayer;
39
		$this->logger = $logger;
40
	}
41
42
	/**
43
	 * export the playlist to a file
44
	 * @param int $id playlist ID
45
	 * @param string $userId owner of the playlist
46
	 * @param Folder $userFolder home dir of the user
47
	 * @param string $folderPath target parent folder path
48
	 * @param string $collisionMode action to take on file name collision,
49
	 *								supported values:
50
	 *								- 'overwrite' The existing file will be overwritten
51
	 *								- 'keepboth' The new file is named with a suffix to make it unique
52
	 *								- 'abort' (default) The operation will fail
53
	 * @return string path of the written file
54
	 * @throws BusinessLayerException if playlist with ID not found
55
	 * @throws \OCP\Files\NotFoundException if the $folderPath is not a valid folder
56
	 * @throws \RuntimeException on name conflict if $collisionMode == 'abort'
57
	 * @throws \OCP\Files\NotPermittedException if the user is not allowed to write to the given folder
58
	 */
59
	public function exportToFile($id, $userId, $userFolder, $folderPath, $collisionMode) {
60
		$playlist = $this->playlistBusinessLayer->find($id, $userId);
61
		$tracks = $this->playlistBusinessLayer->getPlaylistTracks($id, $userId);
62
		$targetFolder = Util::getFolderFromRelativePath($userFolder, $folderPath);
63
64
		// Name the file according the playlist. File names cannot contain the '/' character on Linux, and in
65
		// owncloud/Nextcloud, the whole name must fit 250 characters, including the file extension. Reserve
66
		// another 5 characters to fit the postfix like " (xx)" on name collisions. If there are more than 100
67
		// exports of the same playlist with overly long name, then this function will fail but we can live
68
		// with that :).
69
		$filename = \str_replace('/', '-', $playlist->getName());
70
		$filename = Util::truncate($filename, 250 - 5 - 5);
71
		$filename .= '.m3u8';
72
73
		if ($targetFolder->nodeExists($filename)) {
74
			switch ($collisionMode) {
75
				case 'overwrite':
76
					$targetFolder->get($filename)->delete();
77
					break;
78
				case 'keepboth':
79
					$filename = $targetFolder->getNonExistingName($filename);
80
					break;
81
				default:
82
					throw new \RuntimeException('file already exists');
83
			}
84
		}
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 .= Util::relativePath($targetFolder->getPath(), $nodes[0]->getPath()) . "\n";
93
			}
94
		}
95
		$file = $targetFolder->newFile($filename);
96
		$file->putContent($content);
97
98
		return $userFolder->getRelativePath($file->getPath());
99
	}
100
	
101
	/**
102
	 * export the playlist to a file
103
	 * @param int $id playlist ID
104
	 * @param string $userId owner of the playlist
105
	 * @param Folder $userFolder user home dir
106
	 * @param string $filePath path of the file to import
107
	 * @return array with three keys:
108
	 * 			- 'playlist': The Playlist entity after the modification
109
	 * 			- 'imported_count': An integer showing the number of tracks imported
110
	 * 			- 'failed_count': An integer showing the number of tracks in the file which could not be imported
111
	 * @throws BusinessLayerException if playlist with ID not found
112
	 * @throws \OCP\Files\NotFoundException if the $filePath is not a valid file
113
	 * @throws \UnexpectedValueException if the $filePath points to a file of unsupported type
114
	 */
115
	public function importFromFile($id, $userId, $userFolder, $filePath) {
116
		$parsed = $this->doParseFile($userFolder->get($filePath), $userFolder, /*allowUrls=*/false);
117
		$trackFilesAndCaptions = $parsed['files'];
118
		$invalidPaths = $parsed['invalid_paths'];
119
120
		$trackIds = [];
121
		foreach ($trackFilesAndCaptions as $trackFileAndCaption) {
122
			$trackFile = $trackFileAndCaption['file'];
123
			if ($track = $this->trackBusinessLayer->findByFileId($trackFile->getId(), $userId)) {
124
				$trackIds[] = $track->getId();
125
			} else {
126
				$invalidPaths[] = $trackFile->getPath();
127
			}
128
		}
129
130
		$playlist = $this->playlistBusinessLayer->addTracks($trackIds, $id, $userId);
131
132
		if (\count($invalidPaths) > 0) {
133
			$this->logger->log('Some files were not found from the user\'s music library: '
134
								. \json_encode($invalidPaths, JSON_PARTIAL_OUTPUT_ON_ERROR), 'warn');
135
		}
136
137
		return [
138
			'playlist' => $playlist,
139
			'imported_count' => \count($trackIds),
140
			'failed_count' => \count($invalidPaths)
141
		];
142
	}
143
144
	/**
145
	 * Parse a playlist file and return the contained files
146
	 * @param int $fileId playlist file ID
147
	 * @param Folder $baseFolder ancestor folder of the playlist and the track files (e.g. user folder)
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
	 * @return array
151
	 */
152
	public function parseFile($fileId, $baseFolder) {
153
		$nodes = $baseFolder->getById($fileId);
154
		if (\count($nodes) > 0) {
155
			return $this->doParseFile($nodes[0], $baseFolder, /*allowUrls=*/true);
156
		} else {
157
			throw new \OCP\Files\NotFoundException();
158
		}
159
	}
160
161
	private function doParseFile(File $file, $baseFolder, $allowUrls) {
162
		$mime = $file->getMimeType();
163
164
		if ($mime == 'audio/mpegurl') {
165
			$entries = $this->parseM3uFile($file);
166
		} elseif ($mime == 'audio/x-scpls') {
167
			$entries = $this->parsePlsFile($file);
168
		} else {
169
			throw new \UnexpectedValueException("file mime type '$mime' is not suported");
170
		}
171
172
		// find the parsed entries from the file system
173
		$trackFiles = [];
174
		$invalidPaths = [];
175
		$cwd = $baseFolder->getRelativePath($file->getParent()->getPath());
176
177
		foreach ($entries as $entry) {
178
			$path = $entry['path'];
179
180
			if (Util::startsWith($path, 'http')) {
181
				if ($allowUrls) {
182
					$trackFiles[] = [
183
						'url' => $path,
184
						'caption' => $entry['caption']
185
					];
186
				} else {
187
					$invalidPaths[] = $path;
188
				}
189
			}
190
			else {
191
				$path = Util::resolveRelativePath($cwd, $path);
192
193
				try {
194
					$trackFiles[] = [
195
						'file' => $baseFolder->get($path),
196
						'caption' => $entry['caption']
197
					];
198
				} catch (\OCP\Files\NotFoundException $ex) {
199
					$invalidPaths[] = $path;
200
				}
201
			}
202
		}
203
204
		return [
205
			'files' => $trackFiles,
206
			'invalid_paths' => $invalidPaths
207
		];
208
	}
209
210
	private function parseM3uFile(File $file) {
211
		$entries = [];
212
213
		// By default, files with extension .m3u8 are interpreted as UTF-8 and files with extension
214
		// .m3u as ISO-8859-1. These can be overridden with the tag '#EXTENC' in the file contents.
215
		$encoding = Util::endsWith($file->getPath(), '.m3u8', /*ignoreCase=*/true) ? 'UTF-8' : 'ISO-8859-1';
216
217
		$caption = null;
218
219
		$fp = $file->fopen('r');
220
		while ($line = \fgets($fp)) {
221
			$line = \mb_convert_encoding($line, \mb_internal_encoding(), $encoding);
222
			$line = \trim($line);
223
224
			if ($line === '') {
225
				// empty line => skip
226
			}
227
			elseif (Util::startsWith($line, '#')) {
228
				// comment or extended format attribute line
229
				if ($value = self::extractExtM3uField($line, 'EXTENC')) {
230
					// update the used encoding with the explicitly defined one
231
					$encoding = $value;
232
				}
233
				elseif ($value = self::extractExtM3uField($line, 'EXTINF')) {
234
					// The format should be "length,caption". Set caption to null if the field is badly formatted.
235
					$parts = \explode(',', $value, 2);
236
					$caption = $parts[1] ?? null;
237
					if (\is_string($caption)) {
238
						$caption = \trim($caption);
239
					}
240
				}
241
			}
242
			else {
243
				$entries[] = [
244
					'path' => $line,
245
					'caption' => $caption
246
				];
247
				$caption = null; // the caption has been used up
248
			}
249
		}
250
		\fclose($fp);
251
252
		return $entries;
253
	}
254
255
	private function parsePlsFile(File $file) {
256
		$files = [];
257
		$titles = [];
258
259
		$content = $file->getContent();
260
261
		// If the file doesn't seem to be UTF-8, then assume it to be ISO-8859-1
262
		if (!\mb_check_encoding($content, 'UTF-8')) {
263
			$content = \mb_convert_encoding($content, 'UTF-8', 'ISO-8859-1');
264
		}
265
266
		$fp = \fopen("php://temp", 'r+');
267
		\assert($fp !== false, 'Unexpected error: opening temporary stream failed');
268
269
		\fputs($fp, $content);
270
		\rewind($fp);
271
272
		// the first line should always be [playlist]
273
		if (\trim(\fgets($fp)) != '[playlist]') {
274
			throw new \UnexpectedValueException('the file is not in valid PLS format');
275
		}
276
277
		// the rest of the non-empty lines should be in format "key=value"
278
		while ($line = \fgets($fp)) {
279
			$line = \trim($line);
280
			// ignore empty and malformed lines
281
			if (\strpos($line, '=') !== false) {
282
				list($key, $value) = \explode('=', $line, 2);
283
				// we are interested only on the File# and Title# lines
284
				if (Util::startsWith($key, 'File')) {
285
					$idx = \substr($key, \strlen('File'));
286
					$files[$idx] = $value;
287
				}
288
				elseif (Util::startsWith($key, 'Title')) {
289
					$idx = \substr($key, \strlen('Title'));
290
					$titles[$idx] = $value;
291
				}
292
			}
293
		}
294
		\fclose($fp);
295
296
		$entries = [];
297
		foreach ($files as $idx => $file) {
298
			$entries[] = [
299
				'path' => $file,
300
				'caption' => $titles[$idx] ?? null
301
			];
302
		}
303
304
		return $entries;
305
	}
306
307
	private static function captionForTrack(Track $track) {
308
		$title = $track->getTitle();
309
		$artist = $track->getArtistName();
310
311
		return empty($artist) ? $title : "$artist - $title";
312
	}
313
314
	private static function extractExtM3uField($line, $field) {
315
		if (Util::startsWith($line, "#$field:")) {
316
			return \trim(\substr($line, \strlen("#$field:")));
317
		} else {
318
			return null;
319
		}
320
	}
321
}
322