Passed
Push — master ( f358a5...b5f949 )
by Pauli
03:17
created

DetailsService::hasLyrics()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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