2
* Copyright (C) 2012 Canonical Ltd
4
* This program is free software: you can redistribute it and/or modify
5
* it under the terms of the GNU General Public License version 3 as
6
* published by the Free Software Foundation.
8
* This program is distributed in the hope that it will be useful,
9
* but WITHOUT ANY WARRANTY; without even the implied warranty of
10
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
* GNU General Public License for more details.
13
* You should have received a copy of the GNU General Public License
14
* along with this program. If not, see <http://www.gnu.org/licenses/>.
16
* Authored by David Calle <davidc@framli.eu>
17
* Michal Hruby <michal.hruby@canonical.com>
24
namespace Unity.MusicLens
46
class RhythmboxCollection : Object
48
const string UNKNOWN_ALBUM = _("Unknown");
50
SequenceModel all_tracks;
51
ModelTag<int> album_art_tag;
52
FilterModel tracks_by_play_count;
53
HashTable<string, GenericArray<ModelIter>> album_to_tracks_map;
55
TDB.Database album_art_tdb;
56
FileMonitor tdb_monitor;
57
int current_album_art_tag;
59
HashTable<unowned string, Variant> variant_store;
60
HashTable<int, Variant> int_variant_store;
61
Variant row_buffer[13];
65
ICUTermFilter ascii_filter;
69
public class XmlParser: Object
71
const MarkupParser parser =
80
// contains genre maps
81
Genre genre = new Genre ();
83
MarkupParseContext context;
84
bool is_rhythmdb_xml = false;
88
context = new MarkupParseContext (parser, 0, this, null);
91
public bool parse (string content, size_t len) throws MarkupError
93
return context.parse (content, (ssize_t) len);
96
bool processing_track;
98
int current_data = -1;
100
private void start_tag (MarkupParseContext context, string name,
101
[CCode (array_length = false, array_null_terminated = true)] string[] attr_names, [CCode (array_length = false, array_null_terminated = true)] string[] attr_values)
104
if (!processing_track)
108
case "rhythmdb": is_rhythmdb_xml = true; break;
110
string accepted_element_name = null;
111
for (int i = 0; attr_names[i] != null; i++)
113
if (attr_names[i] == "type" && (attr_values[i] == "song"
114
|| attr_values[i] == "iradio"))
115
accepted_element_name = attr_values[i];
117
if (accepted_element_name == null) return;
118
processing_track = true;
119
current_track = new Track ();
120
current_track.type_track = accepted_element_name == "song" ?
121
TrackType.SONG : TrackType.RADIO;
129
case "location": current_data = Columns.URI; break;
130
case "title": current_data = Columns.TITLE; break;
131
case "duration": current_data = Columns.DURATION; break;
132
case "artist": current_data = Columns.ARTIST; break;
133
case "album": current_data = Columns.ALBUM; break;
134
case "genre": current_data = Columns.GENRE; break;
135
case "track-number": current_data = Columns.TRACK_NUMBER; break;
136
case "play-count": current_data = Columns.PLAY_COUNT; break;
137
case "date": current_data = Columns.YEAR; break;
138
case "media-type": current_data = Columns.MIMETYPE; break;
139
case "album-artist": current_data = Columns.ALBUM_ARTIST; break;
140
default: current_data = -1; break;
145
public signal void track_info_ready (Track track);
147
private void end_tag (MarkupParseContext content, string name)
163
if (current_data >= 0) current_data = -1;
166
if (processing_track) processing_track = false;
169
if (processing_track && current_track != null)
171
track_info_ready (current_track);
173
processing_track = false;
178
private void process_text (MarkupParseContext context,
179
string text, size_t text_len)
182
if (!processing_track || current_data < 0) return;
183
switch (current_data)
185
case Columns.URI: current_track.uri = text; break;
186
case Columns.TITLE: current_track.title = text; break;
187
case Columns.ARTIST: current_track.artist = text; break;
188
case Columns.ALBUM: current_track.album = text; break;
189
case Columns.ALBUM_ARTIST:
190
current_track.album_artist = text;
193
current_track.genre = genre.get_id_for_genre (text.down ());
195
case Columns.MIMETYPE:
196
current_track.mime_type = text;
199
current_track.year = int.parse (text) / 365;
201
case Columns.PLAY_COUNT:
202
current_track.play_count = int.parse (text);
204
case Columns.TRACK_NUMBER:
205
current_track.track_number = int.parse (text);
207
case Columns.DURATION:
208
current_track.duration = int.parse (text);
216
static_assert (13 == Columns.N_COLUMNS); // sync with row_buffer size
217
media_art_dir = Path.build_filename (
218
Environment.get_user_cache_dir (), "media-art");
220
variant_store = new HashTable<unowned string, Variant> (str_hash,
222
int_variant_store = new HashTable<int, Variant> (direct_hash,
224
all_tracks = new SequenceModel ();
225
// the columns correspond to the Columns enum
226
all_tracks.set_schema ("i", "s", "s", "s", "s", "s", "s",
227
"s", "s", "i", "i", "i", "i");
228
assert (all_tracks.get_schema ().length == Columns.N_COLUMNS);
229
album_art_tag = new ModelTag<int> (all_tracks);
230
album_to_tracks_map =
231
new HashTable<string, GenericArray<ModelIter>> (str_hash,
234
var filter = Dee.Filter.new_sort ((row1, row2) =>
236
int a = row1[Columns.PLAY_COUNT].get_int32 ();
237
int b = row2[Columns.PLAY_COUNT].get_int32 ();
239
return b - a; // higher play count first
241
tracks_by_play_count = new FilterModel (all_tracks, filter);
243
ascii_filter = new ICUTermFilter.ascii_folder ();
244
analyzer = new TextAnalyzer ();
245
analyzer.add_term_filter ((terms_in, terms_out) =>
247
foreach (unowned string term in terms_in)
249
var folded = ascii_filter.apply (term);
250
terms_out.add_term (term);
251
if (folded != term) terms_out.add_term (folded);
257
private void initialize_index ()
259
var reader = ModelReader.new ((model, iter) =>
261
var s ="%s\n%s\n%s".printf (model.get_string (iter, Columns.TITLE),
262
model.get_string (iter, Columns.ARTIST),
263
model.get_string (iter, Columns.ALBUM));
267
index = new TreeIndex (all_tracks, analyzer, reader);
270
private string? check_album_art_tdb (string artist, string album)
272
if (album_art_tdb == null) return null;
274
uint8 null_helper[1] = { 0 };
275
ByteArray byte_arr = new ByteArray ();
276
byte_arr.append ("album".data);
277
byte_arr.append (null_helper);
278
byte_arr.append (album.data);
279
byte_arr.append (null_helper);
280
byte_arr.append ("artist".data);
281
byte_arr.append (null_helper);
282
byte_arr.append (artist.data);
283
byte_arr.append (null_helper);
285
TDB.Data key = TDB.NULL_DATA;
286
key.data = byte_arr.data;
287
var val = album_art_tdb.fetch (key);
289
if (val.data != null)
291
Variant v = Variant.new_from_data<int> (new VariantType ("a{sv}"), val.data, false);
292
var file_variant = v.lookup_value ("file", VariantType.STRING);
293
if (file_variant != null)
295
return file_variant.get_string ();
302
private string? get_albumart (Track track)
305
var artist = track.album_artist ?? track.artist;
306
var album = track.album;
308
var artist_norm = artist.normalize (-1, NormalizeMode.NFKD);
309
var album_norm = album.normalize (-1, NormalizeMode.NFKD);
311
filename = check_album_art_tdb (artist, album);
312
if (filename != null)
314
filename = Path.build_filename (Environment.get_user_cache_dir (),
315
"rhythmbox", "album-art",
318
if (FileUtils.test (filename, FileTest.EXISTS)) return filename;
321
var artist_md5 = Checksum.compute_for_string (ChecksumType.MD5,
323
var album_md5 = Checksum.compute_for_string (ChecksumType.MD5,
326
filename = Path.build_filename (media_art_dir,
327
"album-%s-%s".printf (artist_md5, album_md5));
328
if (FileUtils.test (filename, FileTest.EXISTS)) return filename;
330
var combined = "%s\t%s".printf (artist, album).normalize (-1, NormalizeMode.NFKD);
331
filename = Path.build_filename (media_art_dir,
332
"album-%s.jpg".printf (Checksum.compute_for_string (
333
ChecksumType.MD5, combined)));
334
if (FileUtils.test (filename, FileTest.EXISTS)) return filename;
336
// Try Nautilus thumbnails
339
File artwork_file = File.new_for_uri (track.uri);
340
var info = artwork_file.query_info (FILE_ATTRIBUTE_THUMBNAIL_PATH, 0, null);
341
var thumbnail_path = info.get_attribute_string (FILE_ATTRIBUTE_THUMBNAIL_PATH);
342
if (thumbnail_path != null) return thumbnail_path;
346
string artwork = Path.build_filename (
347
Environment.get_user_cache_dir (), "rhythmbox", "covers",
348
"%s - %s.jpg".printf (track.artist, track.album));
349
if (FileUtils.test (artwork, FileTest.EXISTS)) return artwork;
354
public SList<unowned string> get_album_tracks (string album_key)
356
SList<unowned string> results = new SList<unowned string> ();
358
var iter_arr = album_to_tracks_map[album_key];
359
if (iter_arr != null)
361
for (int i = iter_arr.length - 1; i >= 0; i--)
363
results.prepend (all_tracks.get_string (iter_arr[i], Columns.URI));
370
private Track get_track (ModelIter iter)
372
Track track = new Track();
374
track.uri = all_tracks.get_string (iter, Columns.URI);
375
track.title = all_tracks.get_string (iter, Columns.TITLE);
376
track.artist = all_tracks.get_string (iter, Columns.ARTIST);
377
track.album = all_tracks.get_string (iter, Columns.ALBUM);
378
track.artwork_path = all_tracks.get_string (iter, Columns.ARTWORK);
379
track.mime_type = all_tracks.get_string (iter, Columns.MIMETYPE);
380
track.genre = all_tracks.get_string (iter, Columns.GENRE);
381
track.album_artist = all_tracks.get_string (iter, Columns.ALBUM_ARTIST);
382
track.track_number = all_tracks.get_int32 (iter, Columns.TRACK_NUMBER);
383
track.year = all_tracks.get_int32 (iter, Columns.YEAR);
384
track.play_count = all_tracks.get_int32 (iter, Columns.PLAY_COUNT);
385
track.duration = all_tracks.get_int32 (iter, Columns.DURATION);
390
public Track? get_album_track (string uri)
392
var iter = all_tracks.get_first_iter ();
393
var end_iter = all_tracks.get_last_iter ();
395
// FIXME: linear search, change to insert_sorted / find_sorted
396
while (iter != end_iter)
398
if (all_tracks.get_string (iter, Columns.URI) == uri) {
399
return get_track (iter);
401
iter = all_tracks.next (iter);
406
public SList<Track> get_album_tracks_detailed (string album_key)
408
var results = new SList<Track> ();
410
var iter_arr = album_to_tracks_map[album_key];
411
if (iter_arr != null)
413
for (int i = iter_arr.length - 1; i >= 0; i--)
415
results.prepend (get_track (iter_arr[i]));
422
private Variant cached_variant_for_string (string? input)
424
unowned string text = input != null ? input : "";
425
Variant? v = variant_store[text];
426
if (v != null) return v;
428
v = new Variant.string (text);
429
// key is owned by value... awesome right?
430
variant_store[v.get_string ()] = v;
434
private Variant cached_variant_for_int (int input)
436
Variant? v = int_variant_store[input];
437
if (v != null) return v;
439
v = new Variant.int32 (input);
440
// let's not cache every random integer
442
int_variant_store[input] = v;
446
private void prepare_row_buffer (Track track)
448
Variant type = cached_variant_for_int (track.type_track);
449
Variant uri = new Variant.string (track.uri);
450
Variant title = new Variant.string (track.title);
451
Variant artist = cached_variant_for_string (track.artist);
452
Variant album_artist = cached_variant_for_string (track.album_artist);
453
Variant album = cached_variant_for_string (track.album);
454
Variant mime_type = cached_variant_for_string (track.mime_type);
455
Variant artwork = cached_variant_for_string (track.artwork_path);
456
Variant genre = cached_variant_for_string (track.genre);
457
Variant track_number = cached_variant_for_int (track.track_number);
458
Variant year = cached_variant_for_int (track.year);
459
Variant play_count = cached_variant_for_int (track.play_count);
460
Variant duration = cached_variant_for_int (track.duration);
462
row_buffer[0] = type;
464
row_buffer[2] = title;
465
row_buffer[3] = artist;
466
row_buffer[4] = album;
467
row_buffer[5] = artwork;
468
row_buffer[6] = mime_type;
469
row_buffer[7] = genre;
470
row_buffer[8] = album_artist;
471
row_buffer[9] = track_number;
472
row_buffer[10] = year;
473
row_buffer[11] = play_count;
474
row_buffer[12] = duration;
477
public void parse_metadata_file (string path)
479
if (album_art_tdb != null) return;
481
if (tdb_monitor == null)
483
var tdb_file = File.new_for_path (path);
486
tdb_monitor = tdb_file.monitor (FileMonitorFlags.NONE);
487
tdb_monitor.changed.connect (() =>
489
if (album_art_tdb == null) parse_metadata_file (path);
490
else current_album_art_tag++;
495
warning ("%s", err.message);
499
var flags = TDB.OpenFlags.INCOMPATIBLE_HASH | TDB.OpenFlags.SEQNUM | TDB.OpenFlags.NOLOCK;
500
album_art_tdb = new TDB.Database (path, 999, flags,
501
Posix.O_RDONLY, 0600);
502
if (album_art_tdb == null)
504
warning ("Unable to open album-art DB!");
509
album_art_tdb.traverse ((db, key, val) =>
511
var byte_arr = new ByteArray.sized ((uint) val.data_size);
512
byte_arr.append (val.data);
513
Variant v = Variant.new_from_data<ByteArray> (new VariantType ("a{sv}"), byte_arr.data, false, byte_arr);
514
message ("value: %s", v.print (true));
521
public void parse_file (string path)
523
// this could be really expensive if the index was already built, so
524
// we'll destroy it first
528
current_album_art_tag = 0;
529
album_to_tracks_map.remove_all ();
531
var parser = new XmlParser ();
532
parser.track_info_ready.connect ((track) =>
535
string albumart = get_albumart (track);
536
if (albumart != null)
537
track.artwork_path = albumart;
539
track.artwork_path = "audio-x-generic";
541
prepare_row_buffer (track);
542
var iter = all_tracks.append_row (row_buffer);
544
if (track.album == "" || track.album == UNKNOWN_ALBUM) return;
545
var album_key = "%s - %s".printf (track.album, track.album_artist != null ? track.album_artist : track.artist);
546
var arr = album_to_tracks_map[album_key];
549
arr = new GenericArray<ModelIter> ();
550
album_to_tracks_map[album_key] = arr;
555
var file = File.new_for_path (path);
559
var stream = file.read (null);
563
while ((bytes_read = stream.read (buffer, null)) > 0)
565
parser.parse ((string) buffer, bytes_read);
570
warning ("Error while parsing rhythmbox DB: %s", err.message);
573
GLib.List<unowned string> all_albums = album_to_tracks_map.get_keys ();
574
foreach (unowned string s in all_albums)
576
album_to_tracks_map[s].sort_with_data ((a, b) =>
578
var trackno1 = all_tracks.get_int32 (a, Columns.TRACK_NUMBER);
579
var trackno2 = all_tracks.get_int32 (b, Columns.TRACK_NUMBER);
581
return trackno1 - trackno2;
586
private enum ResultType
593
private void add_result (Model results_model, Model model,
594
ModelIter iter, ResultType result_type,
597
// check for updated album art
598
var tag = album_art_tag[model, iter];
599
if (tag < current_album_art_tag)
601
unowned string album = model.get_string (iter, Columns.ALBUM);
602
unowned string artist = model.get_string (iter,
603
Columns.ALBUM_ARTIST);
605
artist = model.get_string (iter, Columns.ARTIST);
607
var album_art_string = check_album_art_tdb (artist, album);
608
if (album_art_string != null)
611
filename = Path.build_filename (Environment.get_user_cache_dir (),
612
"rhythmbox", "album-art",
614
album_art_string = FileUtils.test (filename, FileTest.EXISTS) ?
615
filename : "audio-x-generic";
617
if (album_art_string != model.get_string (iter, Columns.ARTWORK))
619
model.set_value (iter, Columns.ARTWORK,
620
cached_variant_for_string (album_art_string));
623
album_art_tag[model, iter] = current_album_art_tag;
626
var title_col = (result_type == ResultType.SONG
627
|| result_type == ResultType.RADIO) ?
628
Columns.TITLE : Columns.ALBUM;
629
unowned string title = model.get_string (iter, title_col);
630
var uri = model.get_string (iter, Columns.URI);
631
var dnd_uri = model.get_string (iter, Columns.URI);
632
if (result_type == ResultType.ALBUM)
634
if (title == "" || title == UNKNOWN_ALBUM) return;
635
unowned string artist = model.get_string (iter,
636
Columns.ALBUM_ARTIST);
638
artist = model.get_string (iter, Columns.ARTIST);
639
var album_key = "%s - %s".printf (title, artist);
640
StringBuilder sb = new StringBuilder ();
641
foreach (unowned string track_uri in get_album_tracks (album_key))
643
sb.append_printf ("%s\r\n", track_uri);
645
dnd_uri = (owned) sb.str;
646
uri = "album://%s".printf (album_key);
649
results_model.append (uri,
650
model.get_string (iter, Columns.ARTWORK),
652
model.get_string (iter, Columns.MIMETYPE),
654
model.get_string (iter, Columns.ARTIST),
658
public void search (LensSearch search,
659
SearchType search_type,
660
GLib.List<FilterParser>? filters = null,
661
int max_results = -1,
662
int category_override = -1)
665
var empty_search = search.search_string.strip () == "";
669
ResultType result_type;
671
Model model = all_tracks;
672
get_decade_filter (filters, out min_year, out max_year);
673
var active_genres = get_genre_filter (filters);
675
// we need this to be able to sort the albums properly
676
var helper_model = search.results_model;
677
if (category_override >= 0)
679
helper_model = new Dee.SequenceModel ();
680
helper_model.set_schema_full (search.results_model.get_schema ());
685
// display a couple of most played songs
686
model = tracks_by_play_count;
687
var iter = model.get_first_iter ();
688
var end_iter = model.get_last_iter ();
689
var albums_list_nosearch = new HashSet<string> ();
691
while (iter != end_iter)
693
int year = model.get_int32 (iter, Columns.YEAR);
694
unowned string genre = model.get_string (iter, Columns.GENRE);
697
if (year < min_year || year > max_year)
699
iter = model.next (iter);
704
if (active_genres != null) {
705
if (!(genre in active_genres)) {
706
iter = model.next (iter);
711
if (model.get_int32 (iter, Columns.TYPE) == TrackType.SONG)
713
unowned string album = model.get_string (iter,
715
// it's not first as in track #1, but first found from album
716
bool first_track_from_album = !(album in albums_list_nosearch);
717
albums_list_nosearch.add (album);
719
if (first_track_from_album)
721
category_id = category_override >= 0 ?
722
category_override : Category.ALBUMS;
724
add_result (search.results_model, model, iter,
725
ResultType.ALBUM, category_id);
728
category_id = Category.SONGS;
729
result_type = ResultType.SONG;
733
category_id = Category.RADIOS;
734
result_type = ResultType.RADIO;
736
if (category_override >= 0)
737
category_id = category_override;
739
add_result (helper_model, model, iter,
740
result_type, category_id);
743
if (max_results >= 0 && num_results >= max_results) break;
745
iter = model.next (iter);
751
var term_list = Object.new (typeof (Dee.TermList)) as Dee.TermList;
752
// search only the folded terms, FIXME: is that a good idea?
753
analyzer.tokenize (ascii_filter.apply (search.search_string),
756
var matches = new Sequence<Dee.ModelIter> ();
757
bool first_pass = true;
758
foreach (unowned string term in term_list)
760
// FIXME: use PREFIX search only for the last term?
761
var result_set = index.lookup (term, TermMatchFlag.PREFIX);
763
CompareDataFunc<Dee.ModelIter> cmp_func = (a, b) =>
765
return a == b ? 0 : ((void*) a > (void*) b ? 1 : -1);
768
// intersect the results (cause we want to AND the terms)
769
var remaining = new Sequence<Dee.ModelIter> ();
770
foreach (var item in result_set)
773
matches.insert_sorted (item, cmp_func);
774
else if (matches.lookup (item, cmp_func) != null)
775
remaining.insert_sorted (item, cmp_func);
777
if (!first_pass) matches = (owned) remaining;
778
// final result set empty already?
779
if (matches.get_begin_iter () == matches.get_end_iter ()) break;
784
// matches now contain iterators into the all_tracks model which
785
// match the search string
786
var seq_iter = matches.get_begin_iter ();
787
var seq_end_iter = matches.get_end_iter ();
789
var albums_list = new HashSet<string> ();
790
while (seq_iter != seq_end_iter)
792
var model_iter = seq_iter.get ();
793
int year = model.get_int32 (model_iter, Columns.YEAR);
794
string genre = model.get_string (model_iter, Columns.GENRE);
797
if (year < min_year || year > max_year)
799
seq_iter = seq_iter.next ();
804
if (active_genres != null) {
805
bool genre_match = (genre in active_genres);
807
seq_iter = seq_iter.next ();
813
if (model.get_int32 (model_iter, Columns.TYPE) == TrackType.SONG)
815
unowned string album = model.get_string (model_iter,
817
// it's not first as in track #1, but first found from album
818
bool first_track_from_album = !(album in albums_list);
819
albums_list.add (album);
821
if (first_track_from_album)
823
category_id = category_override >= 0 ?
824
category_override : Category.ALBUMS;
826
add_result (search.results_model, model, model_iter,
827
ResultType.ALBUM, category_id);
830
category_id = Category.SONGS;
831
result_type = ResultType.SONG;
835
category_id = Category.RADIOS;
836
result_type = ResultType.RADIO;
838
if (category_override >= 0)
839
category_id = category_override;
841
add_result (helper_model, model, model_iter,
842
result_type, category_id);
845
if (max_results >= 0 && num_results >= max_results) break;
847
seq_iter = seq_iter.next ();
850
if (helper_model == search.results_model) return;
852
// we need to do this because the dash doesn't care about position
853
// of a newly added rows in the model - it just appends it
854
var iter = helper_model.get_first_iter ();
855
var last = helper_model.get_last_iter ();
858
var row = helper_model.get_row (iter);
859
search.results_model.append_row (row);
860
iter = helper_model.next (iter);
864
private void get_decade_filter (GLib.List<FilterParser> filters,
865
out int min_year, out int max_year)
867
Filter? filter = null;
868
foreach (var parser in filters)
870
if (parser is DecadeFilterParser) filter = parser.filter;
873
if (filter == null || !filter.filtering)
880
var mrf = filter as MultiRangeFilter;
881
min_year = int.parse (mrf.get_first_active ().id);
882
// it's supposed to be a decade, so 2000-2009
883
max_year = int.parse (mrf.get_last_active ().id) + 9;
886
private Set<string>? get_genre_filter (GLib.List<FilterParser> filters)
888
Filter? filter = null;
889
foreach (var parser in filters)
891
if (parser is GenreFilterParser) filter = parser.filter;
893
if (filter == null || !filter.filtering)
898
var active_genres = new HashSet<string> ();
899
var all_genres = filter as CheckOptionFilterCompact;
900
foreach (FilterOption option in all_genres.options)
902
if (option.id == null || !option.active) continue;
903
active_genres.add (option.id);
906
return active_genres;