Completed
Pull Request — master (#777)
by Pauli
13:39 queued 11:25
created

PlaylistFileService   A

Complexity

Total Complexity 36

Size/Duplication

Total Lines 269
Duplicated Lines 0 %

Test Coverage

Coverage 0%

Importance

Changes 7
Bugs 0 Features 0
Metric Value
eloc 132
dl 0
loc 269
ccs 0
cts 129
cp 0
rs 9.52
c 7
b 0
f 0
wmc 36

9 Methods

Rating   Name   Duplication   Size   Complexity  
A importFromFile() 0 26 4
B parseM3uFile() 0 36 6
A extractExtM3uField() 0 5 2
A doParseFile() 0 31 5
B exportToFile() 0 40 6
A captionForTrack() 0 5 2
A parseFile() 0 6 2
A __construct() 0 7 1
B parsePlsFile() 0 50 8
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 $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);
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);
156
		} else {
157
			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...
158
		}
159
	}
160
161
	private function doParseFile(File $file, $baseFolder) {
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 = Util::resolveRelativePath($cwd, $entry['path']);
179
			try {
180
				$trackFiles[] = [
181
					'file' => $baseFolder->get($path),
182
					'caption' => $entry['caption']
183
				];
184
			} catch (\OCP\Files\NotFoundException $ex) {
185
				$invalidPaths[] = $path;
186
			}
187
		}
188
189
		return [
190
			'files' => $trackFiles,
191
			'invalid_paths' => $invalidPaths
192
		];
193
	}
194
195
	private function parseM3uFile(File $file) {
196
		$entries = [];
197
198
		// By default, files with extension .m3u8 are interpreted as UTF-8 and files with extension
199
		// .m3u as ISO-8859-1. These can be overridden with the tag '#EXTENC' in the file contents.
200
		$encoding = Util::endsWith($file->getPath(), '.m3u8', /*ignoreCase=*/true) ? 'UTF-8' : 'ISO-8859-1';
201
202
		$caption = null;
203
204
		$fp = $file->fopen('r');
205
		while ($line = \fgets($fp)) {
206
			$line = \mb_convert_encoding($line, \mb_internal_encoding(), $encoding);
207
			$line = \trim($line);
208
			if (Util::startsWith($line, '#')) {
209
				// comment or extended fromat attribute line
210
				if ($value = self::extractExtM3uField($line, 'EXTENC')) {
211
					// update the used encoding with the explicitly defined one
212
					$encoding = $value;
213
				}
214
				elseif ($value = self::extractExtM3uField($line, 'EXTINF')) {
215
					// The format should be "length,caption". Set caption to null if the field is badly formatted.
216
					$parts = \explode(',', $value, 2);
217
					$caption = \trim(Util::arrayGetOrDefault($parts, 1));
218
				}
219
			}
220
			else {
221
				$entries[] = [
222
					'path' => $line,
223
					'caption' => $caption
224
				];
225
				$caption = null; // the caption has been used up
226
			}
227
		}
228
		\fclose($fp);
229
230
		return $entries;
231
	}
232
233
	private function parsePlsFile(File $file) {
234
		$files = [];
235
		$titles = [];
236
237
		$content = $file->getContent();
238
239
		// If the file doesn't seem to be UTF-8, then assume it to be ISO-8859-1
240
		if (!\mb_check_encoding($content, 'UTF-8')) {
241
			$content = \mb_convert_encoding($content, 'UTF-8', 'ISO-8859-1');
242
		}
243
244
		$fp = \fopen("php://temp", 'r+');
245
		\assert($fp !== false, 'Unexpected error: opening temporary stream failed');
246
247
		\fputs($fp, $content);
248
		\rewind($fp);
249
250
		// the first line should always be [playlist]
251
		if (\trim(\fgets($fp)) != '[playlist]') {
252
			throw new \UnexpectedValueException('the file is not in valid PLS format');
253
		}
254
255
		// the rest of the non-empty lines should be in format "key=value"
256
		while ($line = \fgets($fp)) {
257
			$line = \trim($line);
258
			// ignore empty and malformed lines
259
			if (\strpos($line, '=') !== false) {
260
				list($key, $value) = \explode('=', $line, 2);
261
				// we are interested only on the File# and Title# lines
262
				if (Util::startsWith($key, 'File')) {
263
					$idx = \substr($key, \strlen('File'));
264
					$files[$idx] = $value;
265
				}
266
				elseif (Util::startsWith($key, 'Title')) {
267
					$idx = \substr($key, \strlen('Title'));
268
					$titles[$idx] = $value;
269
				}
270
			}
271
		}
272
		\fclose($fp);
273
274
		$entries = [];
275
		foreach ($files as $idx => $file) {
276
			$entries[] = [
277
				'path' => $file,
278
				'caption' => Util::arrayGetOrDefault($titles, $idx)
279
			];
280
		}
281
282
		return $entries;
283
	}
284
285
	private static function captionForTrack(Track $track) {
286
		$title = $track->getTitle();
287
		$artist = $track->getArtistName();
288
289
		return empty($artist) ? $title : "$artist - $title";
290
	}
291
292
	private static function extractExtM3uField($line, $field) {
293
		if (Util::startsWith($line, "#$field:")) {
294
			return \trim(\substr($line, \strlen("#$field:")));
295
		} else {
296
			return null;
297
		}
298
	}
299
}
300