Passed
Push — feature/playlist_improvements ( faf9ee...aca777 )
by Pauli
14:28
created

PlaylistFileService::extractExtM3uField()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 4
c 1
b 0
f 0
nc 2
nop 2
dl 0
loc 5
ccs 0
cts 0
cp 0
crap 6
rs 10
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
	 */
116
	public function importFromFile($id, $filePath) {
117
		$parsed = $this->doParseFile($this->userFolder->get($filePath));
118
		$trackFilesAndCaptions = $parsed['files'];
119
		$invalidPaths = $parsed['invalid_paths'];
120
121
		$trackIds = [];
122
		foreach ($trackFilesAndCaptions as $trackFileAndCaption) {
123
			$trackFile = $trackFileAndCaption['file'];
124
			if ($track = $this->trackBusinessLayer->findByFileId($trackFile->getId(), $this->userId)) {
125
				$trackIds[] = $track->getId();
126
			} else {
127
				$invalidPaths[] = $trackFile->getPath();
128
			}
129
		}
130
131
		$playlist = $this->playlistBusinessLayer->addTracks($trackIds, $id, $this->userId);
132
133
		if (\count($invalidPaths) > 0) {
134
			$this->logger->log('Some files were not found from the user\'s music library: '
135
								. \json_encode($invalidPaths, JSON_PARTIAL_OUTPUT_ON_ERROR), 'warn');
136
		}
137
138
		return [
139
			'playlist' => $playlist,
140
			'imported_count' => \count($trackIds),
141
			'failed_count' => \count($invalidPaths)
142
		];
143
	}
144
145
	/**
146
	 * 
147
	 * @param int $fileId
148
	 * @throws \OCP\Files\NotFoundException
149
	 * @return array
150
	 */
151
	public function parseFile($fileId) {
152
		$nodes = $this->userFolder->getById($fileId);
153
		if (\count($nodes) > 0) {
154
			return $this->doParseFile($nodes[0]);
155
		} else {
156
			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...
157
		}
158
	}
159
160
	private function doParseFile(File $file) {
161
		$trackFiles = [];
162
		$invalidPaths = [];
163
164
		// By default, files with extension .m3u8 are interpreted as UTF-8 and files with extension
165
		// .m3u as ISO-8859-1. These can be overridden with the tag '#EXTENC' in the file contents.
166
		$encoding = Util::endsWith($file->getPath(), '.m3u8', /*ignoreCase=*/true) ? 'UTF-8' : 'ISO-8859-1';
167
168
		$caption = null;
169
170
		$cwd = $this->userFolder->getRelativePath($file->getParent()->getPath());
171
		$fp = $file->fopen('r');
172
		while ($line = \fgets($fp)) {
173
			$line = \mb_convert_encoding($line, \mb_internal_encoding(), $encoding);
174
			$line = \trim($line);
175
			if (Util::startsWith($line, '#')) {
176
				// comment or extended fromat attribute line
177
				if ($value = self::extractExtM3uField($line, 'EXTENC')) {
178
					// update the used encoding with the explicitly defined one
179
					$encoding = $value;
180
				}
181
				elseif ($value = self::extractExtM3uField($line, 'EXTINF')) {
182
					// The format should be "length,caption". Set caption to null if the field is badly formatted.
183
					$parts = \explode(',', $value, 2);
184
					$caption = \trim(Util::arrayGetOrDefault($parts, 1));
185
				}
186
			}
187
			else {
188
				$path = Util::resolveRelativePath($cwd, $line);
189
				try {
190
					$trackFiles[] = [
191
						'file' => $this->userFolder->get($path),
192
						'caption' => $caption
193
					];
194
					$caption = null; // the caption has been used up
195
				} catch (\OCP\Files\NotFoundException $ex) {
196
					$invalidPaths[] = $path;
197
				}
198
			}
199
		}
200
		\fclose($fp);
201
202
		return [
203
			'files' => $trackFiles,
204
			'invalid_paths' => $invalidPaths
205
		];
206
	}
207
208
	private static function captionForTrack(Track $track) {
209
		$title = $track->getTitle();
210
		$artist = $track->getArtistName();
211
212
		return empty($artist) ? $title : "$artist - $title";
213
	}
214
215
	private static function extractExtM3uField($line, $field) {
216
		if (Util::startsWith($line, "#$field:")) {
217
			return \trim(\substr($line, \strlen("#$field:")));
218
		} else {
219
			return null;
220
		}
221
	}
222
}
223