1/*
2 <one line to give the library's name and an idea of what it does.>
3 Copyright (C) 2012 Vishesh Handa <me@vhanda.in>
4
5 This library is free software; you can redistribute it and/or
6 modify it under the terms of the GNU Lesser General Public
7 License as published by the Free Software Foundation; either
8 version 2.1 of the License, or (at your option) any later version.
9
10 This library is distributed in the hope that it will be useful,
11 but WITHOUT ANY WARRANTY; without even the implied warranty of
12 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13 Lesser General Public License for more details.
14
15 You should have received a copy of the GNU Lesser General Public
16 License along with this library; if not, write to the Free Software
17 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
18*/
19
20
21#include "taglibextractor.h"
22#include "kfilemetadata_debug.h"
23
24// Taglib includes
25#include <taglib.h>
26#include <tag.h>
27#include <tfilestream.h>
28#include <tpropertymap.h>
29#include <aifffile.h>
30#include <apefile.h>
31#include <asffile.h>
32#include <flacfile.h>
33#include <mp4file.h>
34#include <mpcfile.h>
35#include <mpegfile.h>
36#include <oggfile.h>
37#include <opusfile.h>
38#include <speexfile.h>
39#include <vorbisfile.h>
40#include <wavfile.h>
41#include <wavpackfile.h>
42#include <asftag.h>
43#include <asfattribute.h>
44#include <id3v2tag.h>
45#include <mp4tag.h>
46#include <popularimeterframe.h>
47
48using namespace KFileMetaData;
49
50TagLibExtractor::TagLibExtractor(QObject* parent)
51 : ExtractorPlugin(parent)
52{
53}
54
55const QStringList supportedMimeTypes = {
56 QStringLiteral("audio/flac"),
57 QStringLiteral("audio/mp4"),
58 QStringLiteral("audio/mpeg"),
59 QStringLiteral("audio/mpeg3"),
60 QStringLiteral("audio/ogg"),
61 QStringLiteral("audio/opus"),
62 QStringLiteral("audio/speex"),
63 QStringLiteral("audio/wav"),
64 QStringLiteral("audio/x-aiff"),
65 QStringLiteral("audio/x-aifc"),
66 QStringLiteral("audio/x-ape"),
67 QStringLiteral("audio/x-mpeg"),
68 QStringLiteral("audio/x-ms-wma"),
69 QStringLiteral("audio/x-musepack"),
70 QStringLiteral("audio/x-opus+ogg"),
71 QStringLiteral("audio/x-speex+ogg"),
72 QStringLiteral("audio/x-vorbis+ogg"),
73 QStringLiteral("audio/x-wav"),
74 QStringLiteral("audio/x-wavpack"),
75};
76
77QStringList TagLibExtractor::mimetypes() const
78{
79 return supportedMimeTypes;
80}
81
82void extractAudioProperties(TagLib::File* file, ExtractionResult* result)
83{
84 TagLib::AudioProperties* audioProp = file->audioProperties();
85 if (audioProp) {
86 if (audioProp->length()) {
87 // What about the xml duration?
88 result->add(Property::Duration, audioProp->length());
89 }
90
91 if (audioProp->bitrate()) {
92 result->add(Property::BitRate, audioProp->bitrate() * 1000);
93 }
94
95 if (audioProp->channels()) {
96 result->add(Property::Channels, audioProp->channels());
97 }
98
99 if (audioProp->sampleRate()) {
100 result->add(Property::SampleRate, audioProp->sampleRate());
101 }
102 }
103}
104
105void TagLibExtractor::readGenericProperties(const TagLib::PropertyMap &savedProperties, ExtractionResult* result)
106{
107 if (savedProperties.isEmpty()) {
108 return;
109 }
110 if (savedProperties.contains("TITLE")) {
111 result->add(Property::Title, TStringToQString(savedProperties["TITLE"].toString()).trimmed());
112 }
113 if (savedProperties.contains("ALBUM")) {
114 result->add(Property::Album, TStringToQString(savedProperties["ALBUM"].toString()).trimmed());
115 }
116 if (savedProperties.contains("COMMENT")) {
117 result->add(Property::Comment, TStringToQString(savedProperties["COMMENT"].toString()).trimmed());
118 }
119 if (savedProperties.contains("TRACKNUMBER")) {
120 result->add(Property::TrackNumber, savedProperties["TRACKNUMBER"].toString().toInt());
121 }
122 if (savedProperties.contains("DATE")) {
123 result->add(Property::ReleaseYear, savedProperties["DATE"].toString().toInt());
124 }
125 if (savedProperties.contains("OPUS")) {
126 result->add(Property::Opus, savedProperties["OPUS"].toString().toInt());
127 }
128 if (savedProperties.contains("DISCNUMBER")) {
129 result->add(Property::DiscNumber, savedProperties["DISCNUMBER"].toString().toInt());
130 }
131 if (savedProperties.contains("RATING")) {
132 /*
133 * There is no standard regarding ratings. Mimic MediaMonkey's behavior
134 * with a range of 0 to 100 (stored in steps of 10) and make it compatible
135 * with baloo rating with a range from 0 to 10
136 */
137 result->add(Property::Rating, savedProperties["RATING"].toString().toInt() / 10);
138 }
139 if (savedProperties.contains("LOCATION")) {
140 result->add(Property::Location, TStringToQString(savedProperties["LOCATION"].toString()).trimmed());
141 }
142 if (savedProperties.contains("LANGUAGE")) {
143 result->add(Property::Language, TStringToQString(savedProperties["LANGUAGE"].toString()).trimmed());
144 }
145 if (savedProperties.contains("LICENSE")) {
146 result->add(Property::License, TStringToQString(savedProperties["LICENSE"].toString()).trimmed());
147 }
148 if (savedProperties.contains("PUBLISHER")) {
149 result->add(Property::Publisher, TStringToQString(savedProperties["PUBLISHER"].toString()).trimmed());
150 }
151 if (savedProperties.contains("COPYRIGHT")) {
152 result->add(Property::Copyright, TStringToQString(savedProperties["COPYRIGHT"].toString()).trimmed());
153 }
154 if (savedProperties.contains("LABEL")) {
155 result->add(Property::Label, TStringToQString(savedProperties["LABEL"].toString()).trimmed());
156 }
157 if (savedProperties.contains("ENSEMBLE")) {
158 result->add(Property::Ensemble, TStringToQString(savedProperties["ENSEMBLE"].toString()).trimmed());
159 }
160 if (savedProperties.contains("COMPILATION")) {
161 result->add(Property::Compilation, TStringToQString(savedProperties["COMPILATION"].toString()).trimmed());
162 }
163 if (savedProperties.contains("LYRICS")) {
164 result->add(Property::Lyrics, TStringToQString(savedProperties["LYRICS"].toString()).trimmed());
165 }
166 if (savedProperties.contains("ARTIST")) {
167 const auto artists = savedProperties["ARTIST"];
168 for (const auto& artist : artists) {
169 result->add(Property::Artist, TStringToQString(artist).trimmed());
170 }
171 }
172 if (savedProperties.contains("GENRE")) {
173 const auto genres = savedProperties["GENRE"];
174 for (const auto& genre : genres) {
175 result->add(Property::Genre, TStringToQString(genre).trimmed());
176 }
177 }
178 if (savedProperties.contains("ALBUMARTIST")) {
179 const auto albumArtists = savedProperties["ALBUMARTIST"];
180 for (const auto& albumArtist : albumArtists) {
181 result->add(Property::AlbumArtist, TStringToQString(albumArtist).trimmed());
182 }
183 }
184 if (savedProperties.contains("COMPOSER")) {
185 const auto composers = savedProperties["COMPOSER"];
186 for (const auto& composer : composers) {
187 result->add(Property::Composer, TStringToQString(composer).trimmed());
188 }
189 }
190 if (savedProperties.contains("LYRICIST")) {
191 const auto lyricists = savedProperties["LYRICIST"];
192 for (const auto& lyricist : lyricists) {
193 result->add(Property::Lyricist, TStringToQString(lyricist).trimmed());
194 }
195 }
196 if (savedProperties.contains("CONDUCTOR")) {
197 const auto conductors = savedProperties["CONDUCTOR"];
198 for (const auto& conductor : conductors) {
199 result->add(Property::Conductor, TStringToQString(conductor).trimmed());
200 }
201 }
202 if (savedProperties.contains("ARRANGER")) {
203 const auto arrangers = savedProperties["ARRANGER"];
204 for (const auto& arranger : arrangers) {
205 result->add(Property::Arranger, TStringToQString(arranger).trimmed());
206 }
207 }
208 if (savedProperties.contains("PERFORMER")) {
209 const auto performers = savedProperties["PERFORMER"];
210 for (const auto& performer : performers) {
211 result->add(Property::Performer, TStringToQString(performer).trimmed());
212 }
213 }
214 if (savedProperties.contains("AUTHOR")) {
215 const auto authors = savedProperties["AUTHOR"];
216 for (const auto& author: authors) {
217 result->add(Property::Author, TStringToQString(author).trimmed());
218 }
219 }
220
221 if (savedProperties.contains("REPLAYGAIN_TRACK_GAIN")) {
222 auto trackGainString = TStringToQString(savedProperties["REPLAYGAIN_TRACK_GAIN"].toString(";")).trimmed();
223 // remove " dB" suffix
224 if (trackGainString.endsWith(QStringLiteral(" dB"), Qt::CaseInsensitive)) {
225 trackGainString.chop(3);
226 }
227 bool success = false;
228 double replayGainTrackGain = trackGainString.toDouble(&success);
229 if (success) {
230 result->add(Property::ReplayGainTrackGain, replayGainTrackGain);
231 }
232 }
233 if (savedProperties.contains("REPLAYGAIN_ALBUM_GAIN")) {
234 auto albumGainString = TStringToQString(savedProperties["REPLAYGAIN_ALBUM_GAIN"].toString(";")).trimmed();
235 // remove " dB" suffix
236 if (albumGainString.endsWith(QStringLiteral(" dB"), Qt::CaseInsensitive)) {
237 albumGainString.chop(3);
238 }
239 bool success = false;
240 double replayGainAlbumGain = albumGainString.toDouble(&success);
241 if (success) {
242 result->add(Property::ReplayGainAlbumGain, replayGainAlbumGain);
243 }
244 }
245 if (savedProperties.contains("REPLAYGAIN_TRACK_PEAK")) {
246 auto trackPeakString = TStringToQString(savedProperties["REPLAYGAIN_TRACK_PEAK"].toString(";")).trimmed();
247 bool success = false;
248 double replayGainTrackPeak = trackPeakString.toDouble(&success);
249 if (success) {
250 result->add(Property::ReplayGainTrackPeak, replayGainTrackPeak);
251 }
252 }
253 if (savedProperties.contains("REPLAYGAIN_ALBUM_PEAK")) {
254 auto albumPeakString = TStringToQString(savedProperties["REPLAYGAIN_ALBUM_PEAK"].toString(";")).trimmed();
255 bool success = false;
256 double replayGainAlbumPeak = albumPeakString.toDouble(&success);
257 if (success) {
258 result->add(Property::ReplayGainAlbumPeak, replayGainAlbumPeak);
259 }
260 }
261}
262
263void TagLibExtractor::extractId3Tags(TagLib::ID3v2::Tag* Id3Tags, ExtractionResult* result)
264{
265 if (Id3Tags->isEmpty()) {
266 return;
267 }
268 TagLib::ID3v2::FrameList lstID3v2;
269
270 /*
271 * Publisher.
272 * Special handling because TagLib::PropertyMap maps "TPUB" to "LABEL"
273 * Insert manually for Publisher.
274 */
275 lstID3v2 = Id3Tags->frameListMap()["TPUB"];
276 if (!lstID3v2.isEmpty()) {
277 result->add(Property::Publisher, TStringToQString(lstID3v2.front()->toString()));
278 }
279
280 // Compilation.
281 lstID3v2 = Id3Tags->frameListMap()["TCMP"];
282 if (!lstID3v2.isEmpty()) {
283 result->add(Property::Compilation, TStringToQString(lstID3v2.front()->toString()));
284 }
285
286 /*
287 * Rating.
288 * There is no standard regarding ratings. Most of the implementations match
289 * a 5 stars rating to a range of 0-255 for MP3.
290 * Map it to baloo rating with a range of 0 - 10.
291 */
292 lstID3v2 = Id3Tags->frameListMap()["POPM"];
293 if (!lstID3v2.isEmpty()) {
294 TagLib::ID3v2::PopularimeterFrame *ratingFrame = static_cast<TagLib::ID3v2::PopularimeterFrame *>(lstID3v2.front());
295 int rating = ratingFrame->rating();
296 if (rating == 0) {
297 rating = 0;
298 } else if (rating == 1) {
299 TagLib::String ratingProvider = ratingFrame->email();
300 if (ratingProvider == "no@email" || ratingProvider == "org.kde.kfilemetadata") {
301 rating = 1;
302 } else {
303 rating = 2;
304 }
305 } else if (rating >= 1 && rating <= 255) {
306 rating = static_cast<int>(0.032 * rating + 2);
307 }
308 result->add(Property::Rating, rating);
309 }
310}
311
312void TagLibExtractor::extractMp4Tags(TagLib::MP4::Tag* mp4Tags, ExtractionResult* result)
313{
314 if (mp4Tags->isEmpty()) {
315 return;
316 }
317 TagLib::MP4::ItemListMap allTags = mp4Tags->itemListMap();
318
319 /*
320 * There is no standard regarding ratings. Mimic MediaMonkey's behavior
321 * with a range of 0 to 100 (stored in steps of 10) and make it compatible
322 * with baloo rating with a range from 0 to 10.
323 */
324 TagLib::MP4::ItemListMap::Iterator itRating = allTags.find("rate");
325 if (itRating != allTags.end()) {
326 result->add(Property::Rating, itRating->second.toStringList().toString().toInt() / 10);
327 }
328}
329
330void TagLibExtractor::extractAsfTags(TagLib::ASF::Tag* asfTags, ExtractionResult* result)
331{
332 if (asfTags->isEmpty()) {
333 return;
334 }
335
336 TagLib::ASF::AttributeList lstASF = asfTags->attribute("WM/SharedUserRating");
337 if (!lstASF.isEmpty()) {
338 int rating = lstASF.front().toString().toInt();
339 /*
340 * Map the rating values of WMP to Baloo rating.
341 * 0->0, 1->2, 25->4, 50->6, 75->8, 99->10
342 */
343 if (rating == 0) {
344 rating = 0;
345 } else if (rating == 1) {
346 rating = 2;
347 } else {
348 rating = static_cast<int>(0.09 * rating + 2);
349 }
350 result->add(Property::Rating, rating);
351 }
352
353 lstASF = asfTags->attribute("Author");
354 if (!lstASF.isEmpty()) {
355 const auto attribute = lstASF.front();
356 result->add(Property::Author, TStringToQString(attribute.toString()).trimmed());
357 }
358
359 // Lyricist is called "WRITER" for wma/asf files
360 lstASF = asfTags->attribute("WM/Writer");
361 if (!lstASF.isEmpty()) {
362 const auto attribute = lstASF.front();
363 result->add(Property::Lyricist, TStringToQString(attribute.toString()).trimmed());
364 }
365
366 /*
367 * TagLib exports "WM/PUBLISHER" as "LABEL" in the PropertyMap,
368 * add it manually to Publisher.
369 */
370 lstASF = asfTags->attribute("WM/Publisher");
371 if (!lstASF.isEmpty()) {
372 const auto attribute = lstASF.front();
373 result->add(Property::Publisher, TStringToQString(attribute.toString()).trimmed());
374 }
375}
376
377void TagLibExtractor::extract(ExtractionResult* result)
378{
379 const QString fileUrl = result->inputUrl();
380 const QString mimeType = getSupportedMimeType(result->inputMimetype());
381
382 // Open the file readonly. Important if we're sandboxed.
383 TagLib::FileStream stream(fileUrl.toUtf8().constData(), true);
384 if (!stream.isOpen()) {
385 qCWarning(KFILEMETADATA_LOG) << "Unable to open file readonly: " << fileUrl;
386 return;
387 }
388
389 if (mimeType == QLatin1String("audio/mpeg") || mimeType == QLatin1String("audio/mpeg3")
390 || mimeType == QLatin1String("audio/x-mpeg")) {
391 TagLib::MPEG::File file(&stream, TagLib::ID3v2::FrameFactory::instance(), true);
392 if (file.isValid()) {
393 extractAudioProperties(&file, result);
394 readGenericProperties(file.properties(), result);
395 if (file.hasID3v2Tag()) {
396 extractId3Tags(file.ID3v2Tag(), result);
397 }
398 }
399 } else if (mimeType == QLatin1String("audio/x-aiff") || mimeType == QLatin1String("audio/x-aifc")) {
400 TagLib::RIFF::AIFF::File file(&stream, true);
401 if (file.isValid()) {
402 extractAudioProperties(&file, result);
403 readGenericProperties(file.properties(), result);
404 if (file.hasID3v2Tag()) {
405 extractId3Tags(file.tag(), result);
406 }
407 }
408 } else if (mimeType == QLatin1String("audio/wav") || mimeType == QLatin1String("audio/x-wav")) {
409 TagLib::RIFF::WAV::File file(&stream, true);
410 if (file.isValid()) {
411 extractAudioProperties(&file, result);
412 readGenericProperties(file.properties(), result);
413 if (file.hasID3v2Tag()) {
414 extractId3Tags(file.tag(), result);
415 }
416 }
417 } else if (mimeType == QLatin1String("audio/x-musepack")) {
418 TagLib::MPC::File file(&stream, true);
419 if (file.isValid()) {
420 extractAudioProperties(&file, result);
421 readGenericProperties(file.properties(), result);
422 }
423 } else if (mimeType == QLatin1String("audio/x-ape")) {
424 TagLib::APE::File file(&stream, true);
425 if (file.isValid()) {
426 extractAudioProperties(&file, result);
427 readGenericProperties(file.properties(), result);
428 }
429 } else if (mimeType == QLatin1String("audio/x-wavpack")) {
430 TagLib::WavPack::File file(&stream, true);
431 if (file.isValid()) {
432 extractAudioProperties(&file, result);
433 readGenericProperties(file.properties(), result);
434 }
435 } else if (mimeType == QLatin1String("audio/mp4")) {
436 TagLib::MP4::File file(&stream, true);
437 if (file.isValid()) {
438 extractAudioProperties(&file, result);
439 readGenericProperties(file.properties(), result);
440 extractMp4Tags(file.tag(), result);
441 }
442 } else if (mimeType == QLatin1String("audio/flac")) {
443 TagLib::FLAC::File file(&stream, TagLib::ID3v2::FrameFactory::instance(), true);
444 if (file.isValid()) {
445 extractAudioProperties(&file, result);
446 readGenericProperties(file.properties(), result);
447 }
448 } else if (mimeType == QLatin1String("audio/ogg") || mimeType == QLatin1String("audio/x-vorbis+ogg")) {
449 TagLib::Ogg::Vorbis::File file(&stream, true);
450 if (file.isValid()) {
451 extractAudioProperties(&file, result);
452 readGenericProperties(file.properties(), result);
453 }
454 } else if (mimeType == QLatin1String("audio/opus") || mimeType == QLatin1String("audio/x-opus+ogg")) {
455 TagLib::Ogg::Opus::File file(&stream, true);
456 if (file.isValid()) {
457 extractAudioProperties(&file, result);
458 readGenericProperties(file.properties(), result);
459 }
460 } else if (mimeType == QLatin1String("audio/speex") || mimeType == QLatin1String("audio/x-speex+ogg")) {
461 TagLib::Ogg::Speex::File file(&stream, true);
462 // Workaround for buggy taglib:
463 // isValid() returns true for invalid files, but XiphComment* tag() returns a nullptr
464 if (file.isValid() && file.tag()) {
465 extractAudioProperties(&file, result);
466 readGenericProperties(file.properties(), result);
467 }
468 } else if (mimeType == QLatin1String("audio/x-ms-wma")) {
469 TagLib::ASF::File file(&stream, true);
470 if (file.isValid()) {
471 extractAudioProperties(&file, result);
472 readGenericProperties(file.properties(), result);
473 extractAsfTags(file.tag(), result);
474 }
475 }
476
477 result->addType(Type::Audio);
478}
479
480// TAG information (incomplete).
481// https://xiph.org/vorbis/doc/v-comment.html
482// https://help.mp3tag.de/main_tags.html
483// http://id3.org/
484// https://www.legroom.net/2009/05/09/ogg-vorbis-and-flac-comment-field-recommendations
485// https://kodi.wiki/view/Music_tagging#Tags_Kodi_reads
486// https://wiki.hydrogenaud.io/index.php?title=Tag_Mapping
487// https://picard.musicbrainz.org/docs/mappings/
488// -- FLAC/OGG --
489// Artist: ARTIST, PERFORMER
490// Album artist: ALBUMARTIST
491// Composer: COMPOSER
492// Lyricist: LYRICIST
493// Conductor: CONDUCTOR
494// Disc number: DISCNUMBER
495// Total discs: TOTALDISCS, DISCTOTAL
496// Track number: TRACKNUMBER
497// Total tracks: TOTALTRACKS, TRACKTOTAL
498// Genre: GENRE
499// -- ID3v2 --
500// Artist: TPE1
501// Album artist: TPE2
502// Composer: TCOM
503// Lyricist: TEXT
504// Conductor: TPE3
505// Disc number[/total dics]: TPOS
506// Track number[/total tracks]: TRCK
507// Genre: TCON
508