Passed
Push — feature/playlist_improvements ( aca777...c465ed )
by Pauli
12:11
created

PlaylistFileService::doParseFile()   A

Complexity

Conditions 5
Paths 7

Size

Total Lines 31
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 5
eloc 21
c 2
b 0
f 0
nc 7
nop 1
dl 0
loc 31
ccs 0
cts 18
cp 0
crap 30
rs 9.2728
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;
0 ignored issues
show
Bug introduced by
The type OCP\Files\File was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
22
use \OCP\Files\Folder;
0 ignored issues
show
Bug introduced by
The type OCP\Files\Folder was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
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 $userFolder;
32
	private $userId;
33
	private $logger;
34
35
	public function __construct(
36
			PlaylistBusinessLayer $playlistBusinessLayer,
37
			TrackBusinessLayer $trackBusinessLayer,
38
			Folder $userFolder,
39
			$userId,
40
			Logger $logger) {
41
		$this->playlistBusinessLayer = $playlistBusinessLayer;
42
		$this->trackBusinessLayer = $trackBusinessLayer;
43
		$this->userFolder = $userFolder;
44
		$this->userId = $userId;
45
		$this->logger = $logger;
46
	}
47
48
	/**
49
	 * export the playlist to a file
50
	 * @param int $id playlist ID
51
	 * @param string $folderPath parent folder path
52
	 * @param string $collisionMode action to take on file name collision,
53
	 *								supported values:
54
	 *								- 'overwrite' The existing file will be overwritten
55
	 *								- 'keepboth' The new file is named with a suffix to make it unique
56
	 *								- 'abort' (default) The operation will fail
57
	 * @return string path of the written file
58
	 * @throws BusinessLayerException if playlist with ID not found
59
	 * @throws \OCP\Files\NotFoundException if the $folderPath is not a valid folder
60
	 * @throws \RuntimeException on name conflict if $collisionMode == 'abort'
61
	 * @throws \OCP\Files\NotPermittedException if the user is not allowed to write to the given folder
62
	 */
63
	public function exportToFile($id, $folderPath, $collisionMode) {
64
		$playlist = $this->playlistBusinessLayer->find($id, $this->userId);
65
		$tracks = $this->playlistBusinessLayer->getPlaylistTracks($id, $this->userId);
66
		$targetFolder = Util::getFolderFromRelativePath($this->userFolder, $folderPath);
67
68
		// Name the file according the playlist. File names cannot contain the '/' character on Linux, and in
69
		// owncloud/Nextcloud, the whole name must fit 250 characters, including the file extension. Reserve
70
		// another 5 characters to fit the postfix like " (xx)" on name collisions. If there are more than 100
71
		// exports of the same playlist with overly long name, then this function will fail but we can live
72
		// with that :).
73
		$filename = \str_replace('/', '-', $playlist->getName());
74
		$filename = Util::truncate($filename, 250 - 5 - 5);
75
		$filename .= '.m3u8';
76
77
		if ($targetFolder->nodeExists($filename)) {
78
			switch ($collisionMode) {
79
				case 'overwrite':
80
					$targetFolder->get($filename)->delete();
81
					break;
82
				case 'keepboth':
83
					$filename = $targetFolder->getNonExistingName($filename);
84
					break;
85
				default:
86
					throw new \RuntimeException('file already exists');
87
			}
88
		}
89
90
		$content = "#EXTM3U\n#EXTENC: UTF-8\n";
91
		foreach ($tracks as $track) {
92
			$nodes = $this->userFolder->getById($track->getFileId());
93
			if (\count($nodes) > 0) {
94
				$caption = self::captionForTrack($track);
95
				$content .= "#EXTINF:{$track->getLength()},$caption\n";
96
				$content .= Util::relativePath($targetFolder->getPath(), $nodes[0]->getPath()) . "\n";
97
			}
98
		}
99
		$file = $targetFolder->newFile($filename);
100
		$file->putContent($content);
101
102
		return $this->userFolder->getRelativePath($file->getPath());
103
	}
104
	
105
	/**
106
	 * export the playlist to a file
107
	 * @param int $id playlist ID
108
	 * @param string $filePath path of the file to import
109
	 * @return array with three keys:
110
	 * 			- 'playlist': The Playlist entity after the modification
111
	 * 			- 'imported_count': An integer showing the number of tracks imported
112
	 * 			- 'failed_count': An integer showing the number of tracks in the file which could not be imported
113
	 * @throws BusinessLayerException if playlist with ID not found
114
	 * @throws \OCP\Files\NotFoundException if the $filePath is not a valid file
115
	 * @throws \UnexpectedValueException if the $filePath points to a file of unsupported type
116
	 */
117
	public function importFromFile($id, $filePath) {
118
		$parsed = $this->doParseFile($this->userFolder->get($filePath));
119
		$trackFilesAndCaptions = $parsed['files'];
120
		$invalidPaths = $parsed['invalid_paths'];
121
122
		$trackIds = [];
123
		foreach ($trackFilesAndCaptions as $trackFileAndCaption) {
124
			$trackFile = $trackFileAndCaption['file'];
125
			if ($track = $this->trackBusinessLayer->findByFileId($trackFile->getId(), $this->userId)) {
126
				$trackIds[] = $track->getId();
127
			} else {
128
				$invalidPaths[] = $trackFile->getPath();
129
			}
130
		}
131
132
		$playlist = $this->playlistBusinessLayer->addTracks($trackIds, $id, $this->userId);
133
134
		if (\count($invalidPaths) > 0) {
135
			$this->logger->log('Some files were not found from the user\'s music library: '
136
								. \json_encode($invalidPaths, JSON_PARTIAL_OUTPUT_ON_ERROR), 'warn');
137
		}
138
139
		return [
140
			'playlist' => $playlist,
141
			'imported_count' => \count($trackIds),
142
			'failed_count' => \count($invalidPaths)
143
		];
144
	}
145
146
	/**
147
	 * 
148
	 * @param int $fileId
149
	 * @throws \OCP\Files\NotFoundException if the $filePath is not a valid file
150
	 * @throws \UnexpectedValueException if the $filePath points to a file of unsupported type
151
	 * @return array
152
	 */
153
	public function parseFile($fileId) {
154
		$nodes = $this->userFolder->getById($fileId);
155
		if (\count($nodes) > 0) {
156
			return $this->doParseFile($nodes[0]);
157
		} else {
158
			throw new \OCP\Files\NotFoundException();
0 ignored issues
show
Bug introduced by
The type OCP\Files\NotFoundException was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
159
		}
160
	}
161
162
	private function doParseFile(File $file) {
163
		$mime = $file->getMimeType();
164
165
		if ($mime == 'audio/mpegurl') {
166
			$entries = $this->parseM3uFile($file);
167
		} elseif ($mime == 'audio/x-scpls') {
168
			$entries = $this->parsePlsFile($file);
169
		} else {
170
			throw new \UnexpectedValueException("file mime type '$mime' is not suported");
171
		}
172
173
		// find the parsed entries from the file system
174
		$trackFiles = [];
175
		$invalidPaths = [];
176
		$cwd = $this->userFolder->getRelativePath($file->getParent()->getPath());
177
178
		foreach ($entries as $entry) {
179
			$path = Util::resolveRelativePath($cwd, $entry['path']);
180
			try {
181
				$trackFiles[] = [
182
					'file' => $this->userFolder->get($path),
183
					'caption' => $entry['caption']
184
				];
185
			} catch (\OCP\Files\NotFoundException $ex) {
186
				$invalidPaths[] = $path;
187
			}
188
		}
189
190
		return [
191
			'files' => $trackFiles,
192
			'invalid_paths' => $invalidPaths
193
		];
194
	}
195
196
	private function parseM3uFile(File $file) {
197
		$entries = [];
198
199
		// By default, files with extension .m3u8 are interpreted as UTF-8 and files with extension
200
		// .m3u as ISO-8859-1. These can be overridden with the tag '#EXTENC' in the file contents.
201
		$encoding = Util::endsWith($file->getPath(), '.m3u8', /*ignoreCase=*/true) ? 'UTF-8' : 'ISO-8859-1';
202
203
		$caption = null;
204
205
		$fp = $file->fopen('r');
206
		while ($line = \fgets($fp)) {
207
			$line = \mb_convert_encoding($line, \mb_internal_encoding(), $encoding);
208
			$line = \trim($line);
209
			if (Util::startsWith($line, '#')) {
210
				// comment or extended fromat attribute line
211
				if ($value = self::extractExtM3uField($line, 'EXTENC')) {
212
					// update the used encoding with the explicitly defined one
213
					$encoding = $value;
214
				}
215
				elseif ($value = self::extractExtM3uField($line, 'EXTINF')) {
216
					// The format should be "length,caption". Set caption to null if the field is badly formatted.
217
					$parts = \explode(',', $value, 2);
218
					$caption = \trim(Util::arrayGetOrDefault($parts, 1));
219
				}
220
			}
221
			else {
222
				$entries[] = [
223
					'path' => $line,
224
					'caption' => $caption
225
				];
226
				$caption = null; // the caption has been used up
227
			}
228
		}
229
		\fclose($fp);
230
231
		return $entries;
232
	}
233
234
	private function parsePlsFile(File $file) {
235
		$files = [];
236
		$titles = [];
237
238
		$content = $file->getContent();
239
240
		// If the file doesn't seem to be UTF-8, then assume it to be ISO-8859-1
241
		if (!\mb_check_encoding($content, 'UTF-8')) {
242
			$content = \mb_convert_encoding($content, 'UTF-8', 'ISO-8859-1');
243
		}
244
245
		$fp = \fopen("php://temp", 'r+');
246
		\fputs($fp, $content);
0 ignored issues
show
Bug introduced by
It seems like $fp can also be of type false; however, parameter $handle of fputs() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

246
		\fputs(/** @scrutinizer ignore-type */ $fp, $content);
Loading history...
247
		\rewind($fp);
0 ignored issues
show
Bug introduced by
It seems like $fp can also be of type false; however, parameter $handle of rewind() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

247
		\rewind(/** @scrutinizer ignore-type */ $fp);
Loading history...
248
249
		// the first line should always be [playlist]
250
		if (\trim(\fgets($fp)) != '[playlist]') {
0 ignored issues
show
Bug introduced by
It seems like $fp can also be of type false; however, parameter $handle of fgets() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

250
		if (\trim(\fgets(/** @scrutinizer ignore-type */ $fp)) != '[playlist]') {
Loading history...
251
			throw new \UnexpectedValueException('the file is not in valid PLS format');
252
		}
253
254
		// the rest of the non-empty lines should be in format "key=value"
255
		while ($line = \fgets($fp)) {
256
			$line = \trim($line);
257
			// ignore empty and malformed lines
258
			if (\strpos($line, '=') !== false) {
259
				list($key, $value) = \explode('=', $line, 2);
260
				// we are interested only on the File# and Title# lines
261
				if (Util::startsWith($key, 'File')) {
262
					$idx = \substr($key, \strlen('File'));
263
					$files[$idx] = $value;
264
				}
265
				elseif (Util::startsWith($key, 'Title')) {
266
					$idx = \substr($key, \strlen('Title'));
267
					$titles[$idx] = $value;
268
				}
269
			}
270
		}
271
		\fclose($fp);
0 ignored issues
show
Bug introduced by
It seems like $fp can also be of type false; however, parameter $handle of fclose() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

271
		\fclose(/** @scrutinizer ignore-type */ $fp);
Loading history...
272
273
		$entries = [];
274
		foreach ($files as $idx => $file) {
275
			$entries[] = [
276
				'path' => $file,
277
				'caption' => Util::arrayGetOrDefault($titles, $idx)
278
			];
279
		}
280
281
		return $entries;
282
	}
283
284
	private static function captionForTrack(Track $track) {
285
		$title = $track->getTitle();
286
		$artist = $track->getArtistName();
287
288
		return empty($artist) ? $title : "$artist - $title";
289
	}
290
291
	private static function extractExtM3uField($line, $field) {
292
		if (Util::startsWith($line, "#$field:")) {
293
			return \trim(\substr($line, \strlen("#$field:")));
294
		} else {
295
			return null;
296
		}
297
	}
298
}
299