Passed
Push — master ( ac98df...4fb72a )
by Pauli
03:42
created

DetailsHelper::getLyricsFileContent()   A

Complexity

Conditions 5
Paths 12

Size

Total Lines 16
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 10
c 1
b 0
f 0
nc 12
nop 1
dl 0
loc 16
rs 9.6111
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 2018 - 2025
11
 */
12
13
namespace OCA\Music\Utility;
14
15
use OCP\Files\File;
16
use OCP\Files\Folder;
17
18
use OCA\Music\AppFramework\Core\Logger;
19
20
class DetailsHelper {
21
	private Extractor $extractor;
22
	private Logger $logger;
23
24
	public function __construct(
25
			Extractor $extractor,
26
			Logger $logger) {
27
		$this->extractor = $extractor;
28
		$this->logger = $logger;
29
	}
30
31
	public function getDetails(int $fileId, Folder $userFolder) : ?array {
32
		$file = $userFolder->getById($fileId)[0] ?? null;
33
		if ($file instanceof File) {
34
			$data = $this->extractor->extract($file);
35
			$audio = $data['audio'] ?? [];
36
			$comments = $data['comments'] ?? [];
37
38
			// remove intermediate arrays
39
			$comments = self::flattenComments($comments);
40
41
			// cleanup strings from invalid characters
42
			\array_walk($audio, [$this, 'sanitizeString']);
43
			\array_walk($comments, [$this, 'sanitizeString']);
44
45
			$result = [
46
				'fileinfo' => $audio,
47
				'tags' => $comments
48
			];
49
50
			// binary data has to be encoded
51
			if (\array_key_exists('picture', $result['tags'])) {
52
				$result['tags']['picture'] = self::encodePictureTag($result['tags']['picture']);
53
			}
54
55
			// 'streams' contains duplicate data
56
			unset($result['fileinfo']['streams']);
57
58
			// one track number is enough
59
			if (\array_key_exists('track', $result['tags'])
60
				&& \array_key_exists('track_number', $result['tags'])) {
61
				unset($result['tags']['track']);
62
			}
63
64
			// special handling for lyrics tags
65
			$lyricsNode = self::transformLyrics($result['tags'], self::getLyricsFileContent($file));
66
			if ($lyricsNode !== null) {
67
				$result['lyrics'] = $lyricsNode;
68
				unset(
69
					$result['tags']['LYRICS'],
70
					$result['tags']['lyrics'],
71
					$result['tags']['unsynchronised_lyric'],
72
					$result['tags']['unsynced lyrics'],
73
					$result['tags']['unsynced_lyrics'],
74
					$result['tags']['unsyncedlyrics']
75
				);
76
			}
77
78
			// add track length
79
			$result['length'] = $data['playtime_seconds'] ?? null;
80
81
			// add file path
82
			$result['path'] = $userFolder->getRelativePath($file->getPath());
83
84
			return $result;
85
		}
86
		return null;
87
	}
88
89
	/**
90
	 * Check if a file has embedded lyrics without parsing them
91
	 */
92
	public function hasLyrics(int $fileId, Folder $userFolder) : bool {
93
		$fileNode = $userFolder->getById($fileId)[0] ?? null;
94
		if ($fileNode instanceof File) {
95
			$data = $this->extractor->extract($fileNode);
96
			$lyrics = ExtractorGetID3::getFirstOfTags($data, ['unsynchronised_lyric', 'unsynced lyrics', 'unsynced_lyrics', 'unsyncedlyrics'])
97
				?? self::getLyricsFileContent($fileNode);
98
			return ($lyrics !== null);
99
		}
100
		return false;
101
	}
102
103
	/**
104
	 * Get lyrics from the file metadata in plain-text format. If there's no unsynchronised lyrics available
105
	 * but there is synchronised lyrics, then the plain-text format is converted from the synchronised lyrics.
106
	 */
107
	public function getLyricsAsPlainText(int $fileId, Folder $userFolder) : ?string {
108
		$lyrics = null;
109
		$fileNode = $userFolder->getById($fileId)[0] ?? null;
110
		if ($fileNode instanceof File) {
111
			$data = $this->extractor->extract($fileNode);
112
			$lyrics = ExtractorGetID3::getFirstOfTags($data, ['unsynchronised_lyric', 'unsynced lyrics', 'unsynced_lyrics', 'unsyncedlyrics']);
113
			self::sanitizeString($lyrics);
114
115
			if ($lyrics === null) {
116
				// no unsynchronized lyrics, try to get and convert the potentially synchronized lyrics
117
				$lyrics = ExtractorGetID3::getFirstOfTags($data, ['LYRICS', 'lyrics']) ?? self::getLyricsFileContent($fileNode);
118
				self::sanitizeString($lyrics);
119
				$parsed = LyricsParser::parseSyncedLyrics($lyrics);
120
				if ($parsed) {
121
					// the lyrics were indeed time-synced, convert the parsed array to a plain string
122
					$lyrics = LyricsParser::syncedToUnsynced($parsed);
123
				}
124
			}
125
		}
126
		return $lyrics;
127
	}
128
129
	/**
130
	 * Get all lyrics from the file metadata, both synced and unsynced. For both lyrics types, a single instance
131
	 * of lyrics contains an array of lyrics lines. In case of synced lyrics, the key of this array is the offset
132
	 * in milliseconds.
133
	 * @return array of items like ['synced' => boolean, 'lines' => array]
134
	 */
135
	public function getLyricsAsStructured(int $fileId, Folder $userFolder) : array {
136
		$result = [];
137
		$fileNode = $userFolder->getById($fileId)[0] ?? null;
138
		if ($fileNode instanceof File) {
139
			$data = $this->extractor->extract($fileNode);
140
			$lyricsTags = ExtractorGetID3::getTags($data, ['LYRICS', 'lyrics', 'unsynchronised_lyric', 'unsynced lyrics', 'unsynced_lyrics', 'unsyncedlyrics']);
141
142
			$lrcFileContent = self::getLyricsFileContent($fileNode);
143
			if ($lrcFileContent !== null) {
144
				// prepend the LRC file content to make it the preferred option for the clients
145
				$lyricsTags = \array_merge(['lyrics_file' => $lrcFileContent], $lyricsTags);
146
			}
147
148
			foreach ($lyricsTags as $tagKey => $tagValue) {
149
				self::sanitizeString($tagValue);
150
151
				// Never try to parse synced lyrics from the "unsync*" tags. The "lyrics" tag, on the other hand,
152
				// may contain either synced or unsynced lyrics and a parse attempt is needed to find out.
153
				$mayBeSynced = !Util::startsWith($tagKey, 'unsync');
154
				$syncedLyrics = $mayBeSynced ? LyricsParser::parseSyncedLyrics($tagValue) : null;
155
156
				if ($syncedLyrics) {
157
					$result[] = [
158
						'synced' => true,
159
						'lines' => $syncedLyrics
160
					];
161
				} else {
162
					$result[] = [
163
						'synced' => false,
164
						'lines' => \explode("\n", $tagValue)
165
					];
166
				}
167
			}
168
		}
169
		return $result;
170
	}
171
172
	/**
173
	 * Read lyrics-related tags, and build a result array containing potentially
174
	 * both time-synced and unsynced lyrics. If no lyrics tags are found, the result will
175
	 * be null. In case the result is non-null, there is always at least the key 'unsynced'
176
	 * in the result which will hold a string representing the lyrics with no timestamps.
177
	 * If found and successfully parsed, there will be also another key 'synced', which will
178
	 * hold the time-synced lyrics. These are presented as an array of arrays of form
179
	 * ['time' => int (ms), 'text' => string].
180
	 */
181
	private static function transformLyrics(array $tags, ?string $lrcFileContent) : ?array {
182
		$lyrics = $tags['LYRICS'] ?? $tags['lyrics'] ?? null; // may be synced or unsynced
183
		$syncedLyrics = LyricsParser::parseSyncedLyrics($lrcFileContent) ?? LyricsParser::parseSyncedLyrics($lyrics);
184
		$unsyncedLyrics = $tags['unsynchronised_lyric']
185
						?? $tags['unsynced lyrics']
186
						?? $tags['unsynced_lyrics']
187
						?? $tags['unsyncedlyrics']
188
						?? LyricsParser::syncedToUnsynced($syncedLyrics)
189
						?? $lrcFileContent
190
						?? $lyrics;
191
192
		if ($unsyncedLyrics !== null) {
193
			$result = ['unsynced' => $unsyncedLyrics];
194
195
			if ($syncedLyrics !== null) {
196
				$result['synced'] = \array_map(fn($timestamp, $text) => [
197
					'time' => \max(0, $timestamp), 'text' => $text
198
				], \array_keys($syncedLyrics), $syncedLyrics);
199
			}
200
		} else {
201
			$result = null;
202
		}
203
204
		return $result;
205
	}
206
207
	private static function getLyricsFileContent(File $audioFile) : ?string {
208
		$audioName = $audioFile->getName();
209
		$bareName = \pathinfo($audioName, PATHINFO_FILENAME);
210
		$parentDir = $audioFile->getParent();
211
		
212
		// if the audio file is named "someSong.mp3", we allow the lyrics file to be named "someSong.lrc" or "someSong.mp3.lrc"
213
		$lrcName1 = "$bareName.lrc";
214
		$lrcName2 = "$audioName.lrc";
215
216
		if ($parentDir->nodeExists($lrcName1)) {
217
			$lrcFile = $parentDir->get($lrcName1);
218
		} else if ($parentDir->nodeExists($lrcName2)) {
219
			$lrcFile = $parentDir->get($lrcName2);
220
		}
221
222
		return (isset($lrcFile) && $lrcFile instanceof File) ? $lrcFile->getContent() : null;
223
	}
224
225
	/**
226
	 * Base64 encode the picture binary data and wrap it so that it can be directly used as
227
	 * src of an HTML img element.
228
	 */
229
	private static function encodePictureTag(array $pic) : ?string {
230
		if ($pic['data']) {
231
			return 'data:' . $pic['image_mime'] . ';base64,' . \base64_encode($pic['data']);
232
		} else {
233
			return null;
234
		}
235
	}
236
237
	/**
238
	 * Remove potentially invalid characters from the string and normalize the line breaks to LF.
239
	 * @param mixed $item
240
	 */
241
	private static function sanitizeString(&$item) : void {
242
		if (\is_string($item)) {
243
			\mb_substitute_character(0xFFFD); // Use the Unicode REPLACEMENT CHARACTER (U+FFFD)
244
			$item = \mb_convert_encoding($item, 'UTF-8', 'UTF-8');
245
			// The tags could contain line breaks in formats LF, CRLF, or CR, but we want the output
246
			// to always use the LF style. Note that the order of the next two lines is important!
247
			$item = \str_replace("\r\n", "\n", $item);
248
			$item = \str_replace("\r", "\n", $item);
249
		}
250
	}
251
252
	/**
253
	 * In the 'comments' field from the extractor, the value for each key is a 1-element
254
	 * array containing the actual tag value. Remove these intermediate arrays.
255
	 */
256
	private static function flattenComments(array $array) : array {
257
		// key 'text' is an exception, its value is an associative array
258
		$textArray = null;
259
260
		foreach ($array as $key => $value) {
261
			if ($key === 'text') {
262
				$textArray = $value;
263
			} else {
264
				$array[$key] = $value[0];
265
			}
266
		}
267
268
		if (!empty($textArray)) {
269
			$array = \array_merge($array, $textArray);
270
			unset($array['text']);
271
		}
272
273
		return $array;
274
	}
275
}
276