1# This program is free software; you can redistribute it and/or modify
2# it under the terms of the GNU General Public License as published by
3# the Free Software Foundation; either version 2 of the License, or
4# (at your option) any later version.
5
6from tests import TestCase, get_data_path
7
8import os
9import io
10from contextlib import contextmanager
11from senf import fsnative, fsn2text, bytes2fsn, mkstemp, mkdtemp
12
13from quodlibet import config
14from quodlibet.formats import AudioFile, types as format_types, AudioFileError
15from quodlibet.formats._audio import NUMERIC_ZERO_DEFAULT
16from quodlibet.formats import decode_value, MusicFile, FILESYSTEM_TAGS
17from quodlibet.util.tags import _TAGS as TAGS
18from quodlibet.util.path import normalize_path, mkdir, get_home_dir, unquote, \
19                                escape_filename, RootPathFile
20from quodlibet.util.environment import is_windows
21
22from .helper import temp_filename
23
24
25bar_1_1 = AudioFile({
26    "~filename": fsnative(u"/fakepath/1"),
27    "title": "A song",
28    "discnumber": "1/2", "tracknumber": "1/3",
29    "artist": "Foo", "album": "Bar"})
30bar_1_2 = AudioFile({
31    "~filename": fsnative(u"/fakepath/2"),
32    "title": "Perhaps another",
33    "titlesort": "Titles don't sort",
34    "discnumber": "1", "tracknumber": "2/3",
35    "artist": "Lali-ho!", "album": "Bar",
36    "date": "2004-12-12", "originaldate": "2005-01-01",
37    "~#filesize": 1024 ** 2, "~#bitrate": 128})
38bar_2_1 = AudioFile({
39    "~filename": fsnative(u"does not/exist"),
40    "title": "more songs",
41    "discnumber": "2/2", "tracknumber": "1",
42    "artist": "Foo\nI have two artists",
43    "artistsort": "Foosort\n\nThird artist",
44    "album": "Bar",
45    "lyricist": "Foo", "composer": "Foo", "performer": "I have two artists"})
46bar_va = AudioFile({
47    "~filename": "/fakepath/3",
48    "title": "latest",
49    "artist": "Foo\nI have two artists",
50    "album": "Bar",
51    "language": "de\neng",
52    "albumartist": "Various Artists",
53    "performer": "Jay-Z"})
54
55num_call = AudioFile({"custom": "0.3"})
56
57
58class TAudioFile(TestCase):
59
60    def setUp(self):
61        config.RATINGS = config.HardCodedRatingsPrefs()
62        fd, filename = mkstemp()
63        os.close(fd)
64        self.quux = AudioFile({
65            "~filename": normalize_path(filename, True),
66            "album": u"Quuxly"
67        })
68
69    def tearDown(self):
70        try:
71            os.unlink(self.quux["~filename"])
72        except EnvironmentError:
73            pass
74
75    def test_format_type(self):
76        for t in format_types:
77            i = AudioFile.__new__(t)
78            assert isinstance(i("~format"), str)
79
80    def test_tag_strs(self):
81        for t in format_types:
82            i = AudioFile.__new__(t)
83            i["~filename"] = fsnative(u"foo")
84            for tag in TAGS.values():
85                name = tag.name
86                # brute force
87                variants = [
88                    name, "~" + name, name + "sort", "~" + name + "sort",
89                    name + ":role", "~" + name + ":role",
90                    "~" + name + "sort:role", name + "sort:role",
91                ]
92                for name in variants:
93                    if name in FILESYSTEM_TAGS:
94                        assert isinstance(i(name, fsnative()), fsnative)
95                    else:
96                        assert isinstance(i(name), str)
97
98    def test_sort(self):
99        l = [self.quux, bar_1_2, bar_2_1, bar_1_1]
100        l.sort()
101        self.assertEqual(l, [bar_1_1, bar_1_2, bar_2_1, self.quux])
102        self.assertEqual(self.quux, self.quux)
103        self.assertEqual(bar_1_1, bar_1_1)
104        self.assertNotEqual(bar_2_1, bar_1_2)
105
106    def test_realkeys(self):
107        self.failIf("artist" in self.quux.realkeys())
108        self.failIf("~filename" in self.quux.realkeys())
109        self.failUnless("album" in self.quux.realkeys())
110
111    def test_iterrealitems(self):
112        af = AudioFile({
113            "~filename": fsnative(u"foo"),
114            "album": u"Quuxly"
115        })
116        assert list(af.iterrealitems()) == [('album', u'Quuxly')]
117
118    def test_language(self):
119        self.assertEqual(bar_va("~language"), "German\nEnglish")
120        self.assertEqual(bar_va.list("~language"), ['German', 'English'])
121        self.assertEqual(bar_1_1("~language", default="foo"), "foo")
122        self.assertEqual(bar_1_1.list("~language"), [])
123
124    def test_trackdisc(self):
125        self.failUnlessEqual(bar_1_1("~#track"), 1)
126        self.failUnlessEqual(bar_1_1("~#disc"), 1)
127        self.failUnlessEqual(bar_1_1("~#tracks"), 3)
128        self.failUnlessEqual(bar_1_1("~#discs"), 2)
129        self.failIf(bar_1_2("~#discs"))
130        self.failIf(bar_2_1("~#tracks"))
131
132    def test_setitem_keys(self):
133        af = AudioFile()
134        af[u"foo"] = u"bar"
135        assert "foo" in af
136        assert isinstance(list(af.keys())[0], str)
137        af.clear()
138        af[u"öäü"] = u"bar"
139        assert u"öäü" in af
140        assert isinstance(list(af.keys())[0], str)
141
142        with self.assertRaises(TypeError):
143            af[42] = u"foo"
144
145        with self.assertRaises(TypeError):
146            af[b"foo"] = u"bar"
147
148    def test_call(self):
149        # real keys should lookup the same
150        for key in bar_1_1.realkeys():
151            self.failUnlessEqual(bar_1_1[key], bar_1_1(key))
152
153        # fake/generated key checks
154        af = AudioFile()
155        self.failIf(af("not a key"))
156        self.failUnlessEqual(af("not a key", "foo"), "foo")
157        self.failUnlessEqual(af("artist"), "")
158
159        assert self.quux("~basename")
160        assert self.quux("~dirname") == os.path.dirname(self.quux("~filename"))
161        assert self.quux("title") == \
162            "%s [Unknown]" % fsn2text(self.quux("~basename"))
163
164        self.failUnlessEqual(bar_1_1("~#disc"), 1)
165        self.failUnlessEqual(bar_1_2("~#disc"), 1)
166        self.failUnlessEqual(bar_2_1("~#disc"), 2)
167        self.failUnlessEqual(bar_1_1("~#track"), 1)
168        self.failUnlessEqual(bar_1_2("~#track"), 2)
169        self.failUnlessEqual(bar_2_1("~#track"), 1)
170
171    def test_year(self):
172        self.failUnlessEqual(bar_1_2("~year"), "2004")
173        self.failUnlessEqual(bar_1_2("~#year"), 2004)
174        self.failUnlessEqual(bar_1_1("~#year", 1999), 1999)
175
176    def test_filesize(self):
177        self.failUnlessEqual(bar_1_2("~filesize"), "1.00 MB")
178        self.failUnlessEqual(bar_1_2("~#filesize"), 1024 ** 2)
179        assert isinstance(bar_1_2("~filesize"), str)
180
181    def test_bitrate(self):
182        self.assertEqual(bar_1_2("~#bitrate"), 128)
183        self.assertEqual(bar_1_2("~bitrate"), "128 kbps")
184
185    def test_originalyear(self):
186        self.failUnlessEqual(bar_1_2("~originalyear"), "2005")
187        self.failUnlessEqual(bar_1_2("~#originalyear"), 2005)
188        self.failUnlessEqual(bar_1_1("~#originalyear", 1999), 1999)
189
190    def test_call_people(self):
191        af = AudioFile()
192        self.failUnlessEqual(af("~people"), "")
193        self.failUnlessEqual(bar_1_1("~people"), "Foo")
194        self.failUnlessEqual(bar_1_2("~people"), "Lali-ho!")
195        self.failUnlessEqual(bar_2_1("~people"), "Foo\nI have two artists")
196        # See Issue 1034
197        self.failUnlessEqual(bar_va("~people"),
198                             "Foo\nI have two artists\nVarious Artists\nJay-Z")
199
200    def test_call_multiple(self):
201        for song in [self.quux, bar_1_1, bar_2_1]:
202            self.failUnlessEqual(song("~~people"), song("~people"))
203            self.failUnlessEqual(song("~title~people"), song("title"))
204            self.failUnlessEqual(
205                song("~title~~people"), song("~title~artist"))
206
207    def test_tied_filename_numeric(self):
208        self.assertEqual(
209            bar_1_2("~~filename~~#originalyear"), u'/fakepath/2 - 2005')
210
211    def test_call_numeric(self):
212        self.failUnlessAlmostEqual(num_call("~#custom"), 0.3)
213        self.failUnlessEqual(num_call("~#blah~foo", 0), 0)
214
215    def test_list(self):
216        for key in bar_1_1.realkeys():
217            self.failUnlessEqual(bar_1_1.list(key), [bar_1_1(key)])
218
219        af = AudioFile({"~filename": fsnative(u"foo")})
220        self.failUnlessEqual(af.list("artist"), [])
221        self.failUnlessEqual(af.list("title"), [af("title")])
222        self.failUnlessEqual(af.list("not a key"), [])
223
224        self.failUnlessEqual(len(bar_2_1.list("artist")), 2)
225        self.failUnlessEqual(bar_2_1.list("artist"),
226                             bar_2_1["artist"].split("\n"))
227
228    def test_list_tied_tags(self):
229        expected = ["%s - %s" % (bar_1_1("artist"), bar_1_1("title"))]
230        self.failUnlessEqual(bar_1_1.list("~artist~title"), expected)
231
232    def test_list_multiple_tied_tags(self):
233        expected = ["%s - %s" % (bar_2_1.comma("artist"), bar_2_1("title"))]
234        self.failUnlessEqual(bar_2_1.list("~artist~title"), expected)
235
236    def test_list_sort(self):
237        self.failUnlessEqual(bar_1_1.list_sort("title"),
238                             [("A song", "A song")])
239        self.failUnlessEqual(bar_1_1.list_sort("artist"),
240                             [("Foo", "Foo")])
241
242        af = AudioFile({"~filename": fsnative(u"foo")})
243        self.failUnlessEqual(af.list_sort("artist"), [])
244        self.failUnlessEqual(af.list_sort("title"),
245                             [(af("title"), af("title"))])
246        self.failUnlessEqual(af.list_sort("not a key"), [])
247
248        self.failUnlessEqual(bar_1_2.list_sort("title"),
249                             [("Perhaps another", "Perhaps another")])
250        self.failUnlessEqual(bar_2_1.list_sort("artist"),
251                             [("Foo", "Foosort"),
252                              ("I have two artists", "I have two artists")])
253        self.failUnlessEqual(bar_2_1.list_sort("~#track"),
254                             [('1', '1')])
255
256    def test_list_sort_empty_sort(self):
257        # we don't want to care about empty sort values, make sure we ignore
258        # them
259        s = AudioFile({"artist": "x\ny\nz", "artistsort": "c\n\nd"})
260        self.assertEqual(
261            s.list_sort("artist"), [("x", "c"), ("y", "y"), ("z", "d")])
262
263    def test_list_sort_noexist(self):
264        self.failUnlessEqual(bar_1_1.list_sort("nopenopenope"), [])
265
266    def test_list_separate_noexist(self):
267        self.failUnlessEqual(bar_1_1.list_separate("nopenopenope"), [])
268
269    def test_list_sort_length_diff(self):
270        s = AudioFile({"artist": "a\nb", "artistsort": "c"})
271        self.assertEqual(s.list_sort("artist"), [("a", "c"), ("b", "b")])
272
273        s = AudioFile({"artist": "a\nb", "artistsort": "c\nd\ne"})
274        self.assertEqual(s.list_sort("artist"), [("a", "c"), ("b", "d")])
275
276        s = AudioFile({"artistsort": "c\nd\ne"})
277        self.assertEqual(s.list_sort("artist"), [])
278
279        s = AudioFile({"artist": "a\nb"})
280        self.assertEqual(s.list_sort("artist"), [("a", "a"), ("b", "b")])
281
282        s = AudioFile({})
283        self.assertEqual(s.list_sort("artist"), [])
284
285    def test_list_separate(self):
286        self.failUnlessEqual(bar_1_1.list_separate("title"),
287                             [("A song", "A song")])
288        self.failUnlessEqual(bar_1_1.list_separate("artist"),
289                             [("Foo", "Foo")])
290
291        self.failUnlessEqual(bar_2_1.list_separate("~artist~album"),
292                             [('Foo', 'Foosort'),
293                              ('I have two artists', 'I have two artists'),
294                              ('Bar', 'Bar')])
295
296        self.failUnlessEqual(bar_2_1.list_separate("~artist~~#track"),
297                             [('Foo', 'Foosort'),
298                              ('I have two artists', 'I have two artists'),
299                              ('1', '1')])
300
301    def test_list_list_separate_types(self):
302        res = bar_2_1.list_separate("~~#track~artist~~filename")
303        self.assertEqual(res, [(u'1', u'1'), (u'Foo', u'Foosort'),
304                               (u'I have two artists', u'I have two artists'),
305                               (u'does not/exist', u'does not/exist')])
306
307    def test_list_numeric(self):
308        self.assertEqual(bar_1_2.list('~#bitrate'), [128])
309
310    def test_comma(self):
311        for key in bar_1_1.realkeys():
312            self.failUnlessEqual(bar_1_1.comma(key), bar_1_1(key))
313        self.failUnless(", " in bar_2_1.comma("artist"))
314
315    def test_comma_filename(self):
316        self.assertTrue(isinstance(bar_1_1.comma("~filename"), str))
317
318    def test_comma_mountpoint(self):
319        assert not bar_1_1("~mountpoint")
320        assert isinstance(bar_1_1.comma("~mountpoint"), str)
321        assert bar_1_1.comma("~mountpoint") == u""
322
323    def test_exist(self):
324        self.failIf(bar_2_1.exists())
325        self.failUnless(self.quux.exists())
326
327    def test_valid(self):
328        self.failIf(bar_2_1.valid())
329
330        quux = self.quux
331        quux["~#mtime"] = 0
332        self.failIf(quux.valid())
333        quux["~#mtime"] = os.path.getmtime(quux["~filename"])
334        self.failUnless(quux.valid())
335        os.utime(quux["~filename"], (quux["~#mtime"], quux["~#mtime"] - 1))
336        self.failIf(quux.valid())
337        quux["~#mtime"] = os.path.getmtime(quux["~filename"])
338        self.failUnless(quux.valid())
339
340        os.utime(quux["~filename"], (quux["~#mtime"], quux["~#mtime"] - 1))
341        quux.sanitize()
342        self.failUnless(quux.valid())
343
344    def test_can_change(self):
345        af = AudioFile()
346        self.failIf(af.can_change("~foobar"))
347        self.failIf(af.can_change("=foobar"))
348        self.failIf(af.can_change("foo=bar"))
349        self.failIf(af.can_change(""))
350        self.failUnless(af.can_change("foo bar"))
351
352    def test_is_writable(self):
353        self.assertTrue(self.quux.is_writable())
354        os.chmod(self.quux["~filename"], 0o444)
355        self.assertFalse(self.quux.is_writable())
356        os.chmod(self.quux["~filename"], 0o644)
357        self.assertTrue(self.quux.is_writable())
358
359    def test_can_multiple_values(self):
360        af = AudioFile()
361        self.assertEqual(af.can_multiple_values(), True)
362        self.assertTrue(af.can_multiple_values("artist"))
363
364    def test_rename(self):
365        old_fn = self.quux["~filename"]
366
367        fd, new_fn = mkstemp()
368        os.close(fd)
369        os.unlink(new_fn)
370
371        assert self.quux.exists()
372        self.quux.rename(new_fn)
373        assert not os.path.exists(old_fn)
374        assert self.quux.exists()
375        self.quux.rename(old_fn)
376        assert not os.path.exists(new_fn)
377        assert self.quux.exists()
378
379    def test_rename_other_dir(self):
380        old_fn = self.quux["~filename"]
381        new_dir = mkdtemp()
382        self.quux.rename(os.path.join(new_dir, "foo"))
383        assert not os.path.exists(old_fn)
384        assert self.quux.exists()
385        self.quux.rename(old_fn)
386        assert self.quux.exists()
387        os.rmdir(new_dir)
388
389    def test_rename_to_existing(self):
390        self.quux.rename(self.quux("~filename"))
391        if os.name != "nt":
392            self.failUnlessRaises(
393                ValueError, self.quux.rename, fsnative(u"/dev/null"))
394
395        with temp_filename() as new_file:
396            with self.assertRaises(ValueError):
397                self.quux.rename(new_file)
398
399    def test_lyric_filename(self):
400        song = AudioFile()
401        song["~filename"] = fsnative(u"filename")
402        self.assertTrue(isinstance(song.lyric_filename, fsnative))
403        song["title"] = u"Title"
404        song["artist"] = u"Artist"
405        self.assertTrue(isinstance(song.lyric_filename, fsnative))
406        song["lyricist"] = u"Lyricist"
407        self.assertTrue(isinstance(song.lyric_filename, fsnative))
408
409    def lyric_filename_search_test_song(self, pathfile):
410        s = AudioFile()
411        s.sanitize(pathfile)
412        s['artist'] = "SpongeBob SquarePants"
413        s['title'] = "Theme Tune"
414        return s
415
416    @contextmanager
417    def lyric_filename_test_setup(self, no_config=False):
418
419        with temp_filename() as filename:
420            s = self.lyric_filename_search_test_song(filename)
421            root = os.path.dirname(filename)
422
423            if not no_config:
424                config.set("memory", "lyric_filenames",
425                           "<artist>.-.<title>,<artist> - <title>.lyrics_mod")
426            config.set("memory", "lyric_rootpaths", root)
427
428            s.root = root
429            yield s
430
431            if not no_config:
432                self.lyric_filename_search_clean_config()
433
434    def lyric_filename_search_clean_config(self):
435        """reset config to ensure other tests aren't affected"""
436        config.remove_option("memory", "lyric_rootpaths")
437        config.remove_option("memory", "lyric_filenames")
438
439    def test_lyric_filename_search_builtin_default(self):
440        """test built-in default"""
441        with self.lyric_filename_test_setup(no_config=True) as ts:
442            fp = os.path.join(ts.root, ts["artist"], ts["title"] + ".lyric")
443            p = os.path.dirname(fp)
444            mkdir(p)
445            with io.open(fp, "w", encoding='utf-8') as f:
446                f.write(u"")
447            search = unquote(ts.lyric_filename)
448            os.remove(fp)
449            os.rmdir(p)
450            self.assertEqual(search, fp)
451
452    def test_lyric_filename_search_builtin_default_local_path(self):
453        """test built-in default local path"""
454        with self.lyric_filename_test_setup(no_config=True) as ts:
455            fp = os.path.join(ts.root, ts["artist"] + " - " +
456                                       ts["title"] + ".lyric")
457            with io.open(fp, "w", encoding='utf-8') as f:
458                f.write(u"")
459            search = ts.lyric_filename
460            os.remove(fp)
461            if is_windows():
462                fp = fp.lower()  # account for 'os.path.normcase' santisatation
463                search = search.lower()  # compensate for the above
464            self.assertEqual(search, fp)
465
466    def test_lyric_filename_search_file_not_found(self):
467        """test default file not found fallback"""
468        with self.lyric_filename_test_setup() as ts:
469            fp = os.path.join(ts.root, ts["artist"] + ".-." + ts["title"])
470            search = unquote(ts.lyric_filename)
471            self.assertEqual(search, fp)
472
473    def test_lyric_filename_search_custom_path(self):
474        """test custom lyrics file location / naming"""
475        with self.lyric_filename_test_setup() as ts:
476            fp = os.path.join(ts.root, ts["artist"] + " - " +
477                                       ts["title"] + ".lyric")
478            with io.open(fp, "w", encoding='utf-8') as f:
479                f.write(u"")
480            search = ts.lyric_filename
481            os.remove(fp)
482            self.assertEqual(search, fp)
483
484    def test_lyric_filename_search_order_priority(self):
485        """test custom lyrics order priority"""
486        with self.lyric_filename_test_setup() as ts:
487            root2 = os.path.join(get_home_dir(), ".lyrics") # built-in default
488            fp2 = os.path.join(root2, ts["artist"] + " - " +
489                                      ts["title"] + ".lyric")
490            p2 = os.path.dirname(fp2)
491            mkdir(p2)
492            with io.open(fp2, "w", encoding='utf-8') as f:
493                f.write(u"")
494            fp = os.path.join(ts.root, ts["artist"] + " - " +
495                                       ts["title"] + ".lyric")
496            with io.open(fp, "w", encoding='utf-8') as f:
497                f.write(u"")
498            mkdir(p2)
499            search = ts.lyric_filename
500            os.remove(fp2)
501            os.rmdir(p2)
502            os.remove(fp)
503            self.assertEqual(search, fp)
504
505    def test_lyric_filename_search_modified_extension_fallback(self):
506        """test modified extension fallback search"""
507        with self.lyric_filename_test_setup() as ts:
508            fp = os.path.join(ts.root,
509                              ts["artist"] + " - " + ts["title"] + ".txt")
510            with io.open(fp, "w", encoding='utf-8') as f:
511                f.write(u"")
512            search = ts.lyric_filename
513            os.remove(fp)
514            self.assertEqual(search, fp)
515
516    def test_lyric_filename_search_special_characters(self):
517        """test '<' and/or '>' in name (not parsed (transparent to test))"""
518        with self.lyric_filename_test_setup(no_config=True) as ts:
519
520            path_variants = ['<oldskool>'] \
521                if is_windows() else [r'\<artist\>', r'\<artist>',
522                                      r'<artist\>']
523
524            for path_variant in path_variants:
525                ts['artist'] = path_variant + " SpongeBob SquarePants"
526                parts = [ts.root,
527                         ts["artist"] + " - " + ts["title"] + ".lyric"]
528                rpf = RootPathFile(ts.root, os.path.sep.join(parts))
529                if not rpf.valid:
530                    rpf = RootPathFile(rpf.root, rpf.pathfile_escaped)
531                self.assertTrue(rpf.valid,
532                                "even escaped target file is not valid")
533                with io.open(rpf.pathfile, "w", encoding='utf-8') as f:
534                    f.write(u"")
535                search = ts.lyric_filename
536                os.remove(rpf.pathfile)
537                fp = rpf.pathfile
538                if is_windows():
539                    # account for 'os.path.normcase' santisatation
540                    fp = fp.lower()
541                    search = search.lower() # compensate for the above
542                self.assertEqual(search, fp)
543
544    def test_lyric_filename_search_special_characters_across_path(self):
545        """test '<' and/or '>' in name across path separator (not parsed
546        (transparent to test))"""
547        with self.lyric_filename_test_setup(no_config=True) as ts:
548            # test '<' and '>' in name across path
549            # (not parsed (transparent to test))
550            ts['artist'] = "a < b"
551            ts['title'] = "b > a"
552            parts = [ts.root, ts["artist"], ts["title"] + ".lyric"]
553            rpf = RootPathFile(ts.root, os.path.sep.join(parts))
554            rootp = ts.root
555            rmdirs = []
556            # ensure valid dir existence
557            for p in rpf.end.split(os.path.sep)[:-1]:
558                rootp = os.path.sep.join([ts.root, p])
559                if not RootPathFile(ts.root, rootp).valid:
560                    rootp = os.path.sep.join([ts.root, escape_filename(p)])
561                self.assertTrue(RootPathFile(ts.root, rootp).valid,
562                                "even escaped target dir part is not valid!")
563                if not os.path.exists(rootp):
564                    mkdir(rootp)
565                    rmdirs.append(rootp)
566
567            if not rpf.valid:
568                rpf = RootPathFile(rpf.root, rpf.pathfile_escaped)
569
570            with io.open(rpf.pathfile, "w", encoding='utf-8') as f:
571                f.write(u"")
572            # search for lyric file
573            search = ts.lyric_filename
574            # clean up test lyric file / path
575            os.remove(rpf.pathfile)
576            for p in rmdirs:
577                os.rmdir(p)
578            # test whether the 'found' file is the test lyric file
579            fp = rpf.pathfile
580            if is_windows():
581                fp = fp.lower()  # account for 'os.path.normcase' santisatation
582                search = search.lower()  # compensate for the above
583            self.assertEqual(search, fp)
584
585    def test_lyrics_from_file(self):
586        with temp_filename() as filename:
587            af = AudioFile(artist='Motörhead', title='this: again')
588            af.sanitize(filename)
589            lyrics = "blah!\nblasé ��\n"
590            lyrics_dir = os.path.dirname(af.lyric_filename)
591            mkdir(lyrics_dir)
592            with io.open(af.lyric_filename, "w", encoding='utf-8') as lf:
593                lf.write(str(lyrics))
594            self.failUnlessEqual(af("~lyrics").splitlines(),
595                                 lyrics.splitlines())
596            os.remove(af.lyric_filename)
597            os.rmdir(lyrics_dir)
598
599    def test_unsynced_lyrics(self):
600        song = AudioFile()
601        song["unsyncedlyrics"] = "lala"
602        assert song("~lyrics") == "lala"
603        assert song("unsyncedlyrics") == "lala"
604        assert song("lyrics") != "lala"
605
606    def test_mountpoint(self):
607        song = AudioFile()
608        song["~filename"] = fsnative(u"filename")
609        song.sanitize()
610        assert isinstance(song["~mountpoint"], fsnative)
611        assert isinstance(song.comma("~mointpoint"), str)
612
613    def test_sanitize(self):
614        q = AudioFile(self.quux)
615        b = AudioFile(bar_1_1)
616        q.sanitize()
617        b.pop('~filename')
618        self.failUnlessRaises(ValueError, b.sanitize)
619        n = AudioFile({"artist": u"foo\0bar", "title": u"baz\0",
620                       "~filename": fsnative(u"whatever")})
621        n.sanitize()
622        self.failUnlessEqual(n["artist"], "foo\nbar")
623        self.failUnlessEqual(n["title"], "baz")
624
625    def test_performers(self):
626        q = AudioFile([("performer:vocals", "A"), ("performer:guitar", "B"),
627                       ("performer", "C")])
628        self.failUnlessEqual(set(q.list("~performers")), {"A", "B", "C"})
629        self.failUnlessEqual(set(q.list("~performers:roles")),
630                             {"A (Vocals)", "B (Guitar)", "C"})
631
632    def test_performers_multi_value(self):
633        q = AudioFile([
634            ("performer:vocals", "X\nA\nY"),
635            ("performer:guitar", "Y\nB\nA"),
636            ("performer", "C\nB\nA"),
637        ])
638
639        self.failUnlessEqual(
640            set(q.list("~performer")), {"A", "B", "C", "X", "Y"})
641
642        self.failUnlessEqual(
643            set(q.list("~performer:roles")), {
644                    "A (Guitar, Vocals)",
645                    "C",
646                    "B (Guitar)",
647                    "X (Vocals)",
648                    "Y (Guitar, Vocals)",
649                })
650
651    def test_people(self):
652        q = AudioFile([("performer:vocals", "A"), ("performer:guitar", "B"),
653                       ("performer", "C"), ("arranger", "A"),
654                       ("albumartist", "B"), ("artist", "C")])
655        self.failUnlessEqual(q.list("~people"), ["C", "B", "A"])
656        self.failUnlessEqual(q.list("~people:roles"),
657            ["C (Performance)", "B (Guitar)", "A (Arrangement, Vocals)"])
658
659    def test_people_mix(self):
660        q = AudioFile([
661            ("performer:arrangement", "A"),
662            ("arranger", "A"),
663            ("performer", "A"),
664            ("performer:foo", "A"),
665        ])
666        self.failUnlessEqual(q.list("~people"), ["A"])
667        self.failUnlessEqual(q.list("~people:roles"),
668            ["A (Arrangement, Arrangement, Foo, Performance)"])
669
670    def test_people_multi_value(self):
671        q = AudioFile([
672            ("arranger", "A\nX"),
673            ("performer", "A\nY"),
674            ("performer:foo", "A\nX"),
675        ])
676
677        self.failUnlessEqual(q.list("~people"), ["A", "Y", "X"])
678        self.failUnlessEqual(q.list("~people:roles"),
679            ["A (Arrangement, Foo, Performance)", "Y (Performance)",
680             "X (Arrangement, Foo)"])
681
682    def test_people_individuals(self):
683        q = AudioFile({"artist": "A\nX", "albumartist": "Various Artists"})
684        self.failUnlessEqual(q.list("~people:real"), ["A", "X"])
685
686        lonely = AudioFile({"artist": "various artists", "title": "blah"})
687        self.failUnlessEqual(lonely.list("~people:real"),
688                             ["various artists"])
689
690        lots = AudioFile({"artist": "Various Artists", "albumartist": "V.A."})
691        self.failUnlessEqual(lots.list("~people:real"),
692                             ["Various Artists"])
693
694    def test_peoplesort(self):
695        q = AudioFile([("performer:vocals", "The A"),
696                       ("performersort:vocals", "A, The"),
697                       ("performer:guitar", "The B"),
698                       ("performersort:guitar", "B, The"),
699                       ("performer", "The C"),
700                       ("performersort", "C, The"),
701                       ("albumartist", "The B"),
702                       ("albumartistsort", "B, The")])
703        self.failUnlessEqual(q.list("~peoplesort"),
704                             ["B, The", "C, The", "A, The"])
705        self.failUnlessEqual(q.list("~peoplesort:roles"),
706            ["B, The (Guitar)", "C, The (Performance)", "A, The (Vocals)"])
707
708    def test_to_dump(self):
709        dump = bar_1_1.to_dump()
710        num = len(set(bar_1_1.keys()) | NUMERIC_ZERO_DEFAULT)
711        self.failUnlessEqual(dump.count(b"\n"), num + 2)
712        for key, value in bar_1_1.items():
713            self.failUnless(key.encode("utf-8") in dump)
714            self.failUnless(value.encode("utf-8") in dump)
715        for key in NUMERIC_ZERO_DEFAULT:
716            self.failUnless(key.encode("utf-8") in dump)
717
718        n = AudioFile()
719        n.from_dump(dump)
720        self.failUnless(
721            set(dump.split(b"\n")) == set(n.to_dump().split(b"\n")))
722
723    def test_to_dump_unicode(self):
724        b = AudioFile(bar_1_1)
725        b[u"öäü"] = u"öäü"
726        dump = b.to_dump()
727        n = AudioFile()
728        n.from_dump(dump)
729        self.assertEqual(n[u"öäü"], u"öäü")
730
731    def test_add(self):
732        song = AudioFile()
733        self.failIf("foo" in song)
734        song.add("foo", "bar")
735        self.failUnlessEqual(song["foo"], "bar")
736        song.add("foo", "another")
737        self.failUnlessEqual(song.list("foo"), ["bar", "another"])
738
739    def test_remove(self):
740        song = AudioFile()
741        song.add("foo", "bar")
742        song.add("foo", "another")
743        song.add("foo", "one more")
744        song.remove("foo", "another")
745        self.failUnlessEqual(song.list("foo"), ["bar", "one more"])
746        song.remove("foo", "bar")
747        self.failUnlessEqual(song.list("foo"), ["one more"])
748        song.remove("foo", "one more")
749        self.failIf("foo" in song)
750
751    def test_remove_unknown(self):
752        song = AudioFile()
753        song.add("foo", "bar")
754        song.remove("foo", "not in list")
755        song.remove("nope")
756        self.failUnlessEqual(song.list("foo"), ["bar"])
757
758    def test_remove_all(self):
759        song = AudioFile()
760        song.add("foo", "bar")
761        song.add("foo", "another")
762        song.add("foo", "one more")
763        song.remove("foo")
764        self.assertFalse("foo" in song)
765
766    def test_remove_empty(self):
767        song = AudioFile()
768        song.add("foo", u"")
769        song.remove("foo", u"")
770        self.assertFalse("foo" in song)
771
772    def test_change(self):
773        song = AudioFile()
774        song.add("foo", "bar")
775        song.add("foo", "another")
776        song.change("foo", "bar", "one more")
777        self.failUnlessEqual(song.list("foo"), ["one more", "another"])
778        song.change("foo", "does not exist", "finally")
779        self.failUnlessEqual(song["foo"], "finally")
780        song.change("foo", "finally", "we're done")
781        self.failUnlessEqual(song["foo"], "we're done")
782
783    def test_bookmarks_none(self):
784        self.failUnlessEqual([], AudioFile().bookmarks)
785
786    def test_bookmarks_simple(self):
787        af = AudioFile({"~bookmark": "1:20 Mark 1"})
788        self.failUnlessEqual([(80, "Mark 1")], af.bookmarks)
789
790    def test_bookmarks_two(self):
791        af = AudioFile({"~bookmark": "1:40 Mark 2\n1:20 Mark 1"})
792        self.failUnlessEqual([(80, "Mark 1"), (100, "Mark 2")], af.bookmarks)
793
794    def test_bookmark_invalid(self):
795        af = AudioFile({"~bookmark": ("Not Valid\n1:40 Mark 2\n"
796                                      "-20 Not Valid 2\n1:20 Mark 1")})
797        self.failUnlessEqual(
798            [(80, "Mark 1"), (100, "Mark 2"), (-1, "Not Valid"),
799             (-1, "-20 Not Valid 2")], af.bookmarks)
800
801    def test_set_bookmarks_none(self):
802        af = AudioFile({"bookmark": "foo"})
803        af.bookmarks = []
804        self.failUnlessEqual([], AudioFile().bookmarks)
805        self.failIf("~bookmark" in af)
806
807    def test_set_bookmarks_simple(self):
808        af = AudioFile()
809        af.bookmarks = [(120, "A mark"), (140, "Mark twain")]
810        self.failUnlessEqual(af["~bookmark"], "2:00 A mark\n2:20 Mark twain")
811
812    def test_set_bookmarks_invalid_value(self):
813        self.failUnlessRaises(
814            ValueError, setattr, AudioFile(), 'bookmarks', "huh?")
815
816    def test_set_bookmarks_invalid_time(self):
817        self.failUnlessRaises(
818            TypeError, setattr, AudioFile(), 'bookmarks', [("notint", "!")])
819
820    def test_set_bookmarks_unrealistic_time(self):
821        self.failUnlessRaises(
822            ValueError, setattr, AudioFile(), 'bookmarks', [(-1, "!")])
823
824    def test_has_rating(self):
825        song = AudioFile()
826        self.assertFalse(song.has_rating)
827        song["~#rating"] = 0.5
828        self.assertTrue(song.has_rating)
829        song.remove_rating()
830        self.assertFalse(song.has_rating)
831
832    def test_remove_rating(self):
833        song = AudioFile()
834        self.assertFalse(song.has_rating)
835        song.remove_rating()
836        self.assertFalse(song.has_rating)
837        song["~#rating"] = 0.5
838        self.assertTrue(song.has_rating)
839        song.remove_rating()
840        self.assertFalse(song.has_rating)
841
842    def test_album_key(self):
843        album_key_tests = [
844            ({}, ((), (), '')),
845            ({'album': 'foo'}, (('foo',), (), '')),
846            ({'labelid': 'foo'}, ((), (), 'foo')),
847            ({'musicbrainz_albumid': 'foo'}, ((), (), 'foo')),
848            ({'album': 'foo', 'labelid': 'bar'}, (('foo',), (), 'bar')),
849            ({'album': 'foo', 'labelid': 'bar', 'musicbrainz_albumid': 'quux'},
850                (('foo',), (), 'bar')),
851            ({'albumartist': 'a'}, ((), ('a',), '')),
852            ]
853        for tags, expected in album_key_tests:
854            afile = AudioFile(**tags)
855            afile.sanitize(fsnative(u'/dir/fn'))
856            self.failUnlessEqual(afile.album_key, expected)
857
858    def test_eq_ne(self):
859        self.failIf(AudioFile({"a": "b"}) == AudioFile({"a": "b"}))
860        self.failUnless(AudioFile({"a": "b"}) != AudioFile({"a": "b"}))
861
862    def test_invalid_fs_encoding(self):
863        # issue 798
864        a = AudioFile()
865        if os.name != "nt":
866            a["~filename"] = "/\xf6\xe4\xfc/\xf6\xe4\xfc.ogg" # latin 1 encoded
867            a.sort_by_func("~filename")(a)
868            a.sort_by_func("~basename")(a)
869        else:
870            # windows
871            a["~filename"] = \
872                b"/\xf6\xe4\xfc/\xf6\xe4\xfc.ogg".decode("latin-1")
873            a.sort_by_func("~filename")(a)
874            a.sort_by_func("~basename")(a)
875            a.sort_by_func("~dirname")(a)
876
877    def test_sort_key_defaults(self):
878        AF = AudioFile
879        assert AF().sort_key == AF({"tracknumber": "0"}).sort_key
880        assert AF().sort_key != AF({"tracknumber": "1/1"}).sort_key
881        assert AF().sort_key < AF({"tracknumber": "2/2"}).sort_key
882
883        assert AF().sort_key == AF({"discnumber": "0"}).sort_key
884        assert AF().sort_key != AF({"discnumber": "1/1"}).sort_key
885        assert AF().sort_key < AF({"discnumber": "2/2"}).sort_key
886
887    def test_sort_cache(self):
888        copy = AudioFile(bar_1_1)
889
890        sort_1 = tuple(copy.sort_key)
891        copy["title"] = copy["title"] + "something"
892        sort_2 = tuple(copy.sort_key)
893        self.failIfEqual(sort_1, sort_2)
894
895        album_sort_1 = tuple(copy.album_key)
896        copy["album"] = copy["album"] + "something"
897        sort_3 = tuple(copy.sort_key)
898        self.failIfEqual(sort_2, sort_3)
899
900        album_sort_2 = tuple(copy.album_key)
901        self.failIfEqual(album_sort_1, album_sort_2)
902
903    def test_cache_attributes(self):
904        x = AudioFile()
905        x.multisong = not x.multisong
906        x["a"] = "b" # clears cache
907        # attribute should be unchanged
908        self.failIfEqual(AudioFile().multisong, x.multisong)
909
910    def test_sort_func(self):
911        tags = [lambda s: s("foo"), "artistsort", "albumsort",
912                "~filename", "~format", "discnumber", "~#track"]
913
914        for tag in tags:
915            f = AudioFile.sort_by_func(tag)
916            f(bar_1_1)
917            f(bar_1_2)
918            f(bar_2_1)
919
920    def test_sort_func_custom_numeric(self):
921        func = AudioFile.sort_by_func("~#year")
922
923        files = [AudioFile({"year": "nope"}), AudioFile({"date": "2038"})]
924        assert sorted(files, key=func) == files
925
926    def test_uri(self):
927        # On windows where we have unicode paths (windows encoding is utf-16)
928        # we need to encode to utf-8 first, then escape.
929        # On linux we take the byte stream and escape it.
930        # see g_filename_to_uri
931
932        if os.name == "nt":
933            f = AudioFile({"~filename": u"/\xf6\xe4.mp3", "title": "win"})
934            self.failUnlessEqual(f("~uri"), "file:///%C3%B6%C3%A4.mp3")
935        else:
936            f = AudioFile({
937                "~filename": bytes2fsn(b"/\x87\x12.mp3", None),
938                "title": "linux",
939            })
940            self.failUnlessEqual(f("~uri"), "file:///%87%12.mp3")
941
942    def test_reload(self):
943        audio = MusicFile(get_data_path('silence-44-s.mp3'))
944        audio["title"] = u"foo"
945        audio.reload()
946        self.assertNotEqual(audio.get("title"), u"foo")
947
948    def test_reload_fail(self):
949        audio = MusicFile(get_data_path('silence-44-s.mp3'))
950        audio["title"] = u"foo"
951        audio.sanitize(fsnative(u"/dev/null"))
952        self.assertRaises(AudioFileError, audio.reload)
953        self.assertEqual(audio["title"], u"foo")
954
955
956class TAudioFormats(TestCase):
957
958    def setUp(self):
959        with temp_filename() as filename:
960            self.filename = filename
961
962    def test_load_non_exist(self):
963        for t in format_types:
964            if not t.is_file:
965                continue
966            self.assertRaises(AudioFileError, t, self.filename)
967
968    def test_write_non_existing(self):
969        for t in format_types:
970            if not t.is_file:
971                continue
972            instance = AudioFile.__new__(t)
973            instance.sanitize(self.filename)
974            try:
975                instance.write()
976            except AudioFileError:
977                pass
978
979    def test_reload_non_existing(self):
980        for t in format_types:
981            if not t.is_file:
982                continue
983            instance = AudioFile.__new__(t)
984            instance.sanitize(self.filename)
985            try:
986                instance.reload()
987            except AudioFileError:
988                pass
989
990
991class Tdecode_value(TestCase):
992
993    def test_main(self):
994        self.assertEqual(decode_value("~#foo", 0.25), u"0.25")
995        self.assertEqual(decode_value("~#foo", 4), u"4")
996        self.assertEqual(decode_value("~#foo", "bar"), u"bar")
997        self.assertTrue(isinstance(decode_value("~#foo", "bar"), str))
998        path = fsnative(u"/foobar")
999        self.assertEqual(decode_value("~filename", path), fsn2text(path))
1000
1001    def test_path(self):
1002        try:
1003            path = bytes2fsn(b"\xff\xff", "utf-8")
1004        except ValueError:
1005            return
1006
1007        assert decode_value("~filename", path) == fsn2text(path)
1008
1009
1010class Treplay_gain(TestCase):
1011
1012    # -6dB is approximately equal to half magnitude
1013    minus_6db = 0.501187234
1014
1015    def setUp(self):
1016        self.rg_data = {"replaygain_album_gain": "-1.00 dB",
1017                        "replaygain_album_peak": "1.1",
1018                        "replaygain_track_gain": "+1.0000001 dB",
1019                        "replaygain_track_peak": "0.9"}
1020        self.song = AudioFile(self.rg_data)
1021        self.no_rg_song = AudioFile()
1022
1023    def test_large(self):
1024        rg_data = {"replaygain_track_gain": "9999999 dB"}
1025        song = AudioFile(rg_data)
1026        assert song.replay_gain(["track"], 0, 0) == 1.0
1027        assert song.replay_gain([], 0, 99999999999) == 1.0
1028
1029    def test_no_rg_song(self):
1030        scale = self.no_rg_song.replay_gain(["track"], 0, -6.0)
1031        self.failUnlessAlmostEqual(scale, self.minus_6db)
1032
1033        scale = self.no_rg_song.replay_gain(["track"], +10, +10)
1034        self.failUnlessEqual(scale, 1.0)
1035
1036        scale = self.no_rg_song.replay_gain(["track"], -16.0, +10)
1037        self.failUnlessAlmostEqual(scale, self.minus_6db)
1038
1039    def test_nogain(self):
1040        self.failUnlessEqual(self.song.replay_gain(["none", "track"]), 1)
1041
1042    def test_fallback_track(self):
1043        del(self.song["replaygain_track_gain"])
1044        self.failUnlessAlmostEqual(
1045            self.song.replay_gain(["track"], 0, -6.0), self.minus_6db)
1046
1047    def test_fallback_album(self):
1048        del(self.song["replaygain_album_gain"])
1049        self.failUnlessAlmostEqual(
1050            self.song.replay_gain(["album"], 0, -6.0), self.minus_6db)
1051
1052    def test_fallback_and_preamp(self):
1053        del(self.song["replaygain_track_gain"])
1054        self.failUnlessEqual(self.song.replay_gain(["track"], 9, -9), 1)
1055
1056    def test_preamp_track(self):
1057        self.failUnlessAlmostEqual(
1058            self.song.replay_gain(["track"], -7.0, 0), self.minus_6db)
1059
1060    def test_preamp_album(self):
1061        self.failUnlessAlmostEqual(
1062            self.song.replay_gain(["album"], -5.0, 0), self.minus_6db)
1063
1064    def test_preamp_clip(self):
1065        # Make sure excess pre-amp won't clip a track (with peak data)
1066        self.failUnlessAlmostEqual(
1067            self.song.replay_gain(["track"], 12.0, 0), 1.0 / 0.9)
1068
1069    def test_trackgain(self):
1070        self.failUnless(self.song.replay_gain(["track"]) > 1)
1071
1072    def test_albumgain(self):
1073        self.failUnless(self.song.replay_gain(["album"]) < 1)
1074
1075    def test_invalid(self):
1076        self.song["replaygain_album_gain"] = "fdsodgbdf"
1077        self.failUnlessEqual(self.song.replay_gain(["album"]), 1)
1078
1079    def test_track_fallback(self):
1080        radio_rg = self.song.replay_gain(["track"])
1081        del(self.song["replaygain_album_gain"])
1082        del(self.song["replaygain_album_peak"])
1083        # verify defaulting to track when album is present
1084        self.failUnlessAlmostEqual(
1085            self.song.replay_gain(["album", "track"]), radio_rg)
1086
1087    def test_numeric_rg_tags(self):
1088        """Tests fully-numeric (ie no "db") RG tags.  See Issue 865"""
1089        self.failUnless(self.song("replaygain_album_gain"), "-1.00 db")
1090        for key, exp in self.rg_data.items():
1091            # Hack the nasties off and produce the "real" expected value
1092            exp = float(exp.split(" ")[0])
1093            # Compare as floats. Seems fairer.
1094            album_rg = self.song("~#%s" % key)
1095            try:
1096                val = float(album_rg)
1097            except ValueError:
1098                self.fail("Invalid %s returned: %s" % (key, album_rg))
1099            self.failUnlessAlmostEqual(
1100                val, exp, places=5,
1101                msg="%s should be %s not %s" % (key, exp, val))
1102