Merge lp:~mhr3/unity-lens-music/rb-parse-tdb into lp:unity-lens-music
- rb-parse-tdb
- Merge into trunk
Proposed by
Michal Hruby
Status: | Merged | ||||
---|---|---|---|---|---|
Approved by: | Michal Hruby | ||||
Approved revision: | 80 | ||||
Merged at revision: | 80 | ||||
Proposed branch: | lp:~mhr3/unity-lens-music/rb-parse-tdb | ||||
Merge into: | lp:unity-lens-music | ||||
Diff against target: |
549 lines (+330/-44) 7 files modified
configure.ac (+2/-1) src/Makefile.am (+5/-0) src/daemon.vala (+13/-5) src/rhythmbox-collection.vala (+160/-37) src/rhythmbox-scope.vala (+24/-1) src/tdb.deps (+1/-0) src/tdb.vapi (+125/-0) |
||||
To merge this branch: | bzr merge lp:~mhr3/unity-lens-music/rb-parse-tdb | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Gord Allott (community) | Approve | ||
Review via email: mp+101394@code.launchpad.net |
Commit message
Added rhythmbox's TDB parsing so we'll have more sources of album arts in the lens
Description of the change
Added rhythmbox's TDB parsing so we'll have more sources of album arts in the lens.
To post a comment you must log in.
Revision history for this message
Unity Merger (unity-merger) wrote : | # |
No commit message specified.
Revision history for this message
Unity Merger (unity-merger) wrote : | # |
The Jenkins job https:/
Not merging it.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'configure.ac' |
2 | --- configure.ac 2012-04-12 07:57:42 +0000 |
3 | +++ configure.ac 2012-04-23 12:39:24 +0000 |
4 | @@ -64,7 +64,8 @@ |
5 | sqlite3 >= 3.7.7 |
6 | gee-1.0 |
7 | json-glib-1.0 |
8 | - unity >= 4.99.0) |
9 | + unity >= 4.99.0 |
10 | + tdb >= 1.2.6) |
11 | |
12 | AC_SUBST(LENS_DAEMON_CFLAGS) |
13 | AC_SUBST(LENS_DAEMON_LIBS) |
14 | |
15 | === modified file 'src/Makefile.am' |
16 | --- src/Makefile.am 2012-03-27 21:51:45 +0000 |
17 | +++ src/Makefile.am 2012-04-23 12:39:24 +0000 |
18 | @@ -28,6 +28,9 @@ |
19 | --pkg gio-2.0 \ |
20 | --pkg gio-unix-2.0 \ |
21 | --pkg glib-2.0 \ |
22 | + --vapidir $(srcdir) \ |
23 | + --pkg tdb \ |
24 | + --target-glib=2.26 \ |
25 | $(MAINTAINER_VALAFLAGS) |
26 | |
27 | unity_music_daemon_LDADD = \ |
28 | @@ -98,6 +101,8 @@ |
29 | unity_musicstore_daemon.vala.stamp \ |
30 | $(unity_music_daemon_VALASOURCES) \ |
31 | $(unity_musicstore_daemon_VALASOURCES) \ |
32 | + tdb.vapi \ |
33 | + tdb.deps \ |
34 | $(NULL) |
35 | |
36 | unity_music_daemon.vala.stamp: $(unity_music_daemon_VALASOURCES) |
37 | |
38 | === modified file 'src/daemon.vala' |
39 | --- src/daemon.vala 2012-03-20 14:59:27 +0000 |
40 | +++ src/daemon.vala 2012-04-23 12:39:24 +0000 |
41 | @@ -32,9 +32,6 @@ |
42 | |
43 | construct |
44 | { |
45 | - banshee = new BansheeScopeProxy (); |
46 | - rb = new RhythmboxScope (); |
47 | - |
48 | lens = new Unity.Lens("/com/canonical/unity/lens/music", "music"); |
49 | lens.search_in_global = true; |
50 | lens.search_hint = _("Search Music Collection"); |
51 | @@ -44,8 +41,19 @@ |
52 | populate_categories (); |
53 | populate_filters(); |
54 | |
55 | - lens.add_local_scope (banshee.scope); |
56 | - lens.add_local_scope (rb.scope); |
57 | + var app_check = new DesktopAppInfo ("banshee.desktop"); |
58 | + if (app_check != null) |
59 | + { |
60 | + banshee = new BansheeScopeProxy (); |
61 | + lens.add_local_scope (banshee.scope); |
62 | + } |
63 | + |
64 | + app_check = new DesktopAppInfo ("rhythmbox.desktop"); |
65 | + if (app_check != null) |
66 | + { |
67 | + rb = new RhythmboxScope (); |
68 | + lens.add_local_scope (rb.scope); |
69 | + } |
70 | |
71 | try { |
72 | lens.export (); |
73 | |
74 | === modified file 'src/rhythmbox-collection.vala' |
75 | --- src/rhythmbox-collection.vala 2012-03-27 21:51:45 +0000 |
76 | +++ src/rhythmbox-collection.vala 2012-04-23 12:39:24 +0000 |
77 | @@ -45,14 +45,19 @@ |
78 | { |
79 | |
80 | SequenceModel all_tracks; |
81 | + ModelTag<int> album_art_tag; |
82 | FilterModel tracks_by_play_count; |
83 | |
84 | + TDB.Database album_art_tdb; |
85 | + FileMonitor tdb_monitor; |
86 | + int current_album_art_tag; |
87 | + |
88 | HashTable<unowned string, Variant> variant_store; |
89 | HashTable<int, Variant> int_variant_store; |
90 | Variant row_buffer[11]; |
91 | |
92 | Analyzer analyzer; |
93 | - Index index; |
94 | + Index? index; |
95 | ICUTermFilter ascii_filter; |
96 | |
97 | string media_art_dir; |
98 | @@ -203,9 +208,10 @@ |
99 | direct_equal); |
100 | all_tracks = new SequenceModel (); |
101 | // the columns correspond to the Columns enum |
102 | - all_tracks.set_schema ("s", "s", "s", "s", "s", "s", "s", "s", "i", "i", "i"); |
103 | + all_tracks.set_schema ("s", "s", "s", "s", "s", "s", |
104 | + "s", "s", "i", "i", "i"); |
105 | assert (all_tracks.get_schema ().length == Columns.N_COLUMNS); |
106 | - |
107 | + album_art_tag = new ModelTag<int> (all_tracks); |
108 | |
109 | var filter = Dee.Filter.new_sort ((row1, row2) => |
110 | { |
111 | @@ -227,6 +233,11 @@ |
112 | if (folded != term) terms_out.add_term (folded); |
113 | } |
114 | }); |
115 | + initialize_index (); |
116 | + } |
117 | + |
118 | + private void initialize_index () |
119 | + { |
120 | var reader = ModelReader.new ((model, iter) => |
121 | { |
122 | var s ="%s\n%s\n%s".printf (model.get_string (iter, Columns.TITLE), |
123 | @@ -237,21 +248,63 @@ |
124 | |
125 | index = new TreeIndex (all_tracks, analyzer, reader); |
126 | } |
127 | + |
128 | + private string? check_album_art_tdb (string artist, string album) |
129 | + { |
130 | + if (album_art_tdb == null) return null; |
131 | + |
132 | + uint8 null_helper[1] = { 0 }; |
133 | + ByteArray byte_arr = new ByteArray (); |
134 | + byte_arr.append ("album".data); |
135 | + byte_arr.append (null_helper); |
136 | + byte_arr.append (album.data); |
137 | + byte_arr.append (null_helper); |
138 | + byte_arr.append ("artist".data); |
139 | + byte_arr.append (null_helper); |
140 | + byte_arr.append (artist.data); |
141 | + byte_arr.append (null_helper); |
142 | + |
143 | + TDB.Data key = TDB.NULL_DATA; |
144 | + key.data = byte_arr.data; |
145 | + var val = album_art_tdb.fetch (key); |
146 | + |
147 | + if (val.data != null) |
148 | + { |
149 | + Variant v = Variant.new_from_data<int> (new VariantType ("a{sv}"), val.data, false); |
150 | + var file_variant = v.lookup_value ("file", VariantType.STRING); |
151 | + if (file_variant != null) |
152 | + { |
153 | + return file_variant.get_string (); |
154 | + } |
155 | + } |
156 | + |
157 | + return null; |
158 | + } |
159 | |
160 | private string? get_albumart (Track track) |
161 | { |
162 | + string filename; |
163 | var artist = track.album_artist ?? track.artist; |
164 | var album = track.album; |
165 | |
166 | var artist_norm = artist.normalize (-1, NormalizeMode.NFKD); |
167 | var album_norm = album.normalize (-1, NormalizeMode.NFKD); |
168 | |
169 | + filename = check_album_art_tdb (artist, album); |
170 | + if (filename != null) |
171 | + { |
172 | + filename = Path.build_filename (Environment.get_user_cache_dir (), |
173 | + "rhythmbox", "album-art", |
174 | + filename); |
175 | + |
176 | + if (FileUtils.test (filename, FileTest.EXISTS)) return filename; |
177 | + } |
178 | + |
179 | var artist_md5 = Checksum.compute_for_string (ChecksumType.MD5, |
180 | artist_norm); |
181 | var album_md5 = Checksum.compute_for_string (ChecksumType.MD5, |
182 | album_norm); |
183 | |
184 | - string filename; |
185 | filename = Path.build_filename (media_art_dir, |
186 | "album-%s-%s".printf (artist_md5, album_md5)); |
187 | if (FileUtils.test (filename, FileTest.EXISTS)) return filename; |
188 | @@ -331,8 +384,59 @@ |
189 | row_buffer[10] = play_count; |
190 | } |
191 | |
192 | + public void parse_metadata_file (string path) |
193 | + { |
194 | + if (album_art_tdb != null) return; |
195 | + |
196 | + if (tdb_monitor == null) |
197 | + { |
198 | + var tdb_file = File.new_for_path (path); |
199 | + try |
200 | + { |
201 | + tdb_monitor = tdb_file.monitor (FileMonitorFlags.NONE); |
202 | + tdb_monitor.changed.connect (() => |
203 | + { |
204 | + if (album_art_tdb == null) parse_metadata_file (path); |
205 | + else current_album_art_tag++; |
206 | + }); |
207 | + } |
208 | + catch (Error err) |
209 | + { |
210 | + warning ("%s", err.message); |
211 | + } |
212 | + } |
213 | + |
214 | + var flags = TDB.OpenFlags.INCOMPATIBLE_HASH | TDB.OpenFlags.SEQNUM | TDB.OpenFlags.NOLOCK; |
215 | + album_art_tdb = new TDB.Database (path, 999, flags, |
216 | + Posix.O_RDONLY, 0600); |
217 | + if (album_art_tdb == null) |
218 | + { |
219 | + warning ("Unable to open album-art DB!"); |
220 | + return; |
221 | + } |
222 | + |
223 | + /* |
224 | + album_art_tdb.traverse ((db, key, val) => |
225 | + { |
226 | + var byte_arr = new ByteArray.sized ((uint) val.data_size); |
227 | + byte_arr.append (val.data); |
228 | + Variant v = Variant.new_from_data<ByteArray> (new VariantType ("a{sv}"), byte_arr.data, false, byte_arr); |
229 | + message ("value: %s", v.print (true)); |
230 | + |
231 | + return 0; |
232 | + }); |
233 | + */ |
234 | + } |
235 | + |
236 | public void parse_file (string path) |
237 | { |
238 | + // this could be really expensive if the index was already built, so |
239 | + // we'll destroy it first |
240 | + index = null; |
241 | + all_tracks.clear (); |
242 | + initialize_index (); |
243 | + current_album_art_tag = 0; |
244 | + |
245 | var parser = new XmlParser (); |
246 | parser.track_info_ready.connect ((track) => |
247 | { |
248 | @@ -366,6 +470,48 @@ |
249 | } |
250 | } |
251 | |
252 | + private void add_result (Model results_model, Model model, |
253 | + ModelIter iter, Columns title_col, |
254 | + uint category_id) |
255 | + { |
256 | + // check for updated album art |
257 | + var tag = album_art_tag[model, iter]; |
258 | + if (tag < current_album_art_tag) |
259 | + { |
260 | + unowned string album = model.get_string (iter, Columns.ALBUM); |
261 | + unowned string artist = model.get_string (iter, |
262 | + Columns.ALBUM_ARTIST); |
263 | + if (artist == "") |
264 | + artist = model.get_string (iter, Columns.ARTIST); |
265 | + |
266 | + var album_art_string = check_album_art_tdb (artist, album); |
267 | + if (album_art_string != null) |
268 | + { |
269 | + string filename; |
270 | + filename = Path.build_filename (Environment.get_user_cache_dir (), |
271 | + "rhythmbox", "album-art", |
272 | + album_art_string); |
273 | + album_art_string = FileUtils.test (filename, FileTest.EXISTS) ? |
274 | + filename : "audio-x-generic"; |
275 | + |
276 | + if (album_art_string != model.get_string (iter, Columns.ARTWORK)) |
277 | + { |
278 | + model.set_value (iter, Columns.ARTWORK, |
279 | + cached_variant_for_string (album_art_string)); |
280 | + } |
281 | + } |
282 | + album_art_tag[model, iter] = current_album_art_tag; |
283 | + } |
284 | + |
285 | + results_model.append (model.get_string (iter, Columns.URI), |
286 | + model.get_string (iter, Columns.ARTWORK), |
287 | + category_id, |
288 | + model.get_string (iter, Columns.MIMETYPE), |
289 | + model.get_string (iter, title_col), |
290 | + model.get_string (iter, Columns.ARTIST), |
291 | + model.get_string (iter, Columns.URI)); |
292 | + } |
293 | + |
294 | public void search (LensSearch search, |
295 | SearchType search_type, |
296 | GLib.List<FilterParser>? filters = null, |
297 | @@ -420,28 +566,16 @@ |
298 | { |
299 | category_id = category_override >= 0 ? |
300 | category_override : Category.ALBUMS; |
301 | - |
302 | - search.results_model.append ( |
303 | - model.get_string (iter, Columns.URI), |
304 | - model.get_string (iter, Columns.ARTWORK), |
305 | - category_id, |
306 | - model.get_string (iter, Columns.MIMETYPE), |
307 | - model.get_string (iter, Columns.ALBUM), |
308 | - model.get_string (iter, Columns.ARTIST), |
309 | - model.get_string (iter, Columns.URI)); |
310 | + |
311 | + add_result (search.results_model, model, iter, |
312 | + Columns.ALBUM, category_id); |
313 | } |
314 | |
315 | category_id = category_override >= 0 ? |
316 | category_override : Category.SONGS; |
317 | |
318 | - search.results_model.append ( |
319 | - model.get_string (iter, Columns.URI), |
320 | - model.get_string (iter, Columns.ARTWORK), |
321 | - category_id, |
322 | - model.get_string (iter, Columns.MIMETYPE), |
323 | - model.get_string (iter, Columns.TITLE), |
324 | - model.get_string (iter, Columns.ARTIST), |
325 | - model.get_string (iter, Columns.URI)); |
326 | + add_result (search.results_model, model, iter, |
327 | + Columns.TITLE, category_id); |
328 | |
329 | num_results++; |
330 | if (max_results >= 0 && num_results >= max_results) break; |
331 | @@ -515,6 +649,7 @@ |
332 | |
333 | unowned string album = model.get_string (model_iter, |
334 | Columns.ALBUM); |
335 | + |
336 | // it's not first as in track #1, but first found from album |
337 | bool first_track_from_album = !(album in albums_list); |
338 | albums_list.add (album); |
339 | @@ -524,27 +659,15 @@ |
340 | category_id = category_override >= 0 ? |
341 | category_override : Category.ALBUMS; |
342 | |
343 | - search.results_model.append ( |
344 | - model.get_string (model_iter, Columns.URI), |
345 | - model.get_string (model_iter, Columns.ARTWORK), |
346 | - category_id, |
347 | - model.get_string (model_iter, Columns.MIMETYPE), |
348 | - model.get_string (model_iter, Columns.ALBUM), |
349 | - model.get_string (model_iter, Columns.ARTIST), |
350 | - model.get_string (model_iter, Columns.URI)); |
351 | + add_result (search.results_model, model, model_iter, |
352 | + Columns.ALBUM, category_id); |
353 | } |
354 | |
355 | category_id = category_override >= 0 ? |
356 | category_override : Category.SONGS; |
357 | |
358 | - search.results_model.append ( |
359 | - model.get_string (model_iter, Columns.URI), |
360 | - model.get_string (model_iter, Columns.ARTWORK), |
361 | - category_id, |
362 | - model.get_string (model_iter, Columns.MIMETYPE), |
363 | - model.get_string (model_iter, Columns.TITLE), |
364 | - model.get_string (model_iter, Columns.ARTIST), |
365 | - model.get_string (model_iter, Columns.URI)); |
366 | + add_result (search.results_model, model, model_iter, |
367 | + Columns.TITLE, category_id); |
368 | |
369 | num_results++; |
370 | if (max_results >= 0 && num_results >= max_results) break; |
371 | |
372 | === modified file 'src/rhythmbox-scope.vala' |
373 | --- src/rhythmbox-scope.vala 2012-03-20 14:59:27 +0000 |
374 | +++ src/rhythmbox-scope.vala 2012-04-23 12:39:24 +0000 |
375 | @@ -25,6 +25,7 @@ |
376 | { |
377 | private RhythmboxCollection collection; |
378 | private bool db_ready; |
379 | + private FileMonitor rb_xml_monitor; |
380 | |
381 | public RhythmboxScope () |
382 | { |
383 | @@ -105,7 +106,29 @@ |
384 | if (!db_ready) |
385 | { |
386 | // parse the DB lazily |
387 | - collection.parse_file ("%s/.local/share/rhythmbox/rhythmdb.xml".printf (Environment.get_home_dir ())); |
388 | + var tdb_path = Path.build_filename (Environment.get_user_cache_dir (), |
389 | + "rhythmbox", "album-art", |
390 | + "store.tdb"); |
391 | + collection.parse_metadata_file (tdb_path); |
392 | + |
393 | + var xml_path = Path.build_filename (Environment.get_user_data_dir (), |
394 | + "rhythmbox", "rhythmdb.xml"); |
395 | + collection.parse_file (xml_path); |
396 | + if (rb_xml_monitor == null) |
397 | + { |
398 | + // re-parse the file if it changes |
399 | + File xml_file = File.new_for_path (xml_path); |
400 | + try |
401 | + { |
402 | + rb_xml_monitor = xml_file.monitor (FileMonitorFlags.NONE); |
403 | + rb_xml_monitor.changed.connect (() => { db_ready = false; }); |
404 | + } |
405 | + catch (Error err) |
406 | + { |
407 | + warning ("%s", err.message); |
408 | + } |
409 | + } |
410 | + |
411 | db_ready = true; |
412 | } |
413 | |
414 | |
415 | === added file 'src/tdb.deps' |
416 | --- src/tdb.deps 1970-01-01 00:00:00 +0000 |
417 | +++ src/tdb.deps 2012-04-23 12:39:24 +0000 |
418 | @@ -0,0 +1,1 @@ |
419 | +posix |
420 | |
421 | === added file 'src/tdb.vapi' |
422 | --- src/tdb.vapi 1970-01-01 00:00:00 +0000 |
423 | +++ src/tdb.vapi 2012-04-23 12:39:24 +0000 |
424 | @@ -0,0 +1,125 @@ |
425 | +/* tdb.vapi |
426 | + * |
427 | + * Copyright (C) 2012 Canonical Ltd. |
428 | + * |
429 | + * This library is free software; you can redistribute it and/or |
430 | + * modify it under the terms of the GNU Lesser General Public |
431 | + * License as published by the Free Software Foundation; either |
432 | + * version 2.1 of the License, or (at your option) any later version. |
433 | + |
434 | + * This library is distributed in the hope that it will be useful, |
435 | + * but WITHOUT ANY WARRANTY; without even the implied warranty of |
436 | + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
437 | + * Lesser General Public License for more details. |
438 | + |
439 | + * You should have received a copy of the GNU Lesser General Public |
440 | + * License along with this library; if not, write to the Free Software |
441 | + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA |
442 | + * |
443 | + * Author: |
444 | + * Michal Hruby <michal.hruby@canonical.com> |
445 | + */ |
446 | + |
447 | +[CCode (lower_case_cprefix = "tdb_", cheader_filename = "tdb.h")] |
448 | +namespace TDB { |
449 | + /* Database Connection Handle */ |
450 | + [Compact] |
451 | + [CCode (free_function = "tdb_close", cname = "TDB_CONTEXT", cprefix = "tdb_")] |
452 | + public class Database { |
453 | + [CCode (cname = "tdb_open")] |
454 | + public Database (string name, int hash_size, TDB.OpenFlags tdb_flags, int open_flags, Posix.mode_t mode); |
455 | + [CCode (cname = "tdb_open_ex")] |
456 | + public Database.open_ex (string name, int hash_size, int tdb_flags, int open_flags, Posix.mode_t mode, TDB.LogFunc log_fn); |
457 | + |
458 | + public int reopen (); |
459 | + public static int reopen_all (); |
460 | + |
461 | + public TDB.Error error (); |
462 | + public unowned string errorstr (); |
463 | + |
464 | + public TDB.Data fetch (TDB.Data key); |
465 | + public int @delete (TDB.Data key); |
466 | + public int store (TDB.Data key, TDB.Data dbuf, TDB.StoreType type_flag); |
467 | + public TDB.Data firstkey (); |
468 | + public TDB.Data nextkey (TDB.Data key); |
469 | + public int traverse (TDB.TraverseFunc traverse_func); |
470 | + public int exists (TDB.Data key); |
471 | + public int lockkeys (TDB.Data[] keys); |
472 | + public void unlockkeys (); |
473 | + public int lockall (); |
474 | + public void unlockall (); |
475 | + |
476 | + public int chainlock (TDB.Data key); |
477 | + public void chainunlock (TDB.Data key); |
478 | + } |
479 | + |
480 | + [CCode (cname = "TDB_DATA")] |
481 | + [SimpleType] |
482 | + public struct Data { |
483 | + [CCode (array_length_cname = "dsize", array_length_type = "size_t", cname = "dptr")] |
484 | + public unowned uint8[] data; |
485 | + [CCode (cname = "dsize")] |
486 | + public size_t data_size; |
487 | + } |
488 | + |
489 | + [CCode (cname = "tdb_null")] |
490 | + public const TDB.Data NULL_DATA; |
491 | + |
492 | + [CCode (has_target = false)] |
493 | + public delegate void LogFunc (TDB.Database db, TDB.DebugLevel debug_level, string format, ...); |
494 | + |
495 | + public delegate int TraverseFunc (TDB.Database db, TDB.Data key, TDB.Data @value); |
496 | + |
497 | + [CCode (cname = "SQLITE_ANY")] |
498 | + public const int ANY; |
499 | + |
500 | + [CCode (cname = "enum tdb_debug_level", cprefix = "TDB_DEBUG_")] |
501 | + public enum DebugLevel { |
502 | + FATAL, |
503 | + ERROR, |
504 | + WARNING, |
505 | + TRACE |
506 | + } |
507 | + |
508 | + [CCode (cname = "int", cprefix = "TDB_")] |
509 | + public enum StoreType { |
510 | + REPLACE, |
511 | + INSERT, |
512 | + MODIFY |
513 | + } |
514 | + |
515 | + [CCode (cname = "int", cprefix = "TDB_")] |
516 | + public enum OpenFlags { |
517 | + DEFAULT, |
518 | + CLEAR_IF_FIRST, |
519 | + INTERNAL, |
520 | + NOLOCK, |
521 | + NOMMAP, |
522 | + CONVERT, |
523 | + BIGENDIAN, |
524 | + NOSYNC, |
525 | + SEQNUM, |
526 | + VOLATILE, |
527 | + ALLOW_NESTING, |
528 | + DISALLOW_NESTING, |
529 | + INCOMPATIBLE_HASH |
530 | + } |
531 | + |
532 | + [CCode (cname = "enum TDB_ERROR", cprefix = "TDB_ERR_")] |
533 | + public enum Error { |
534 | + [CCode (cname = "TDB_SUCCESS")] |
535 | + SUCCESS, |
536 | + CORRUPT, |
537 | + IO, |
538 | + LOCK, |
539 | + OOM, |
540 | + EXISTS, |
541 | + NOLOCK, |
542 | + LOCK_TIMEOUT, |
543 | + NOEXISTS, |
544 | + EINVAL, |
545 | + RDONLY, |
546 | + NESTING |
547 | + } |
548 | +} |
549 | + |
seems good to me, +1