Passed
Push — master ( 5e9301...eb9a09 )
by Pauli
02:44
created

DetailsHelper::getLyricsAsStructured()   A

Complexity

Conditions 5
Paths 2

Size

Total Lines 29
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

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