• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

geigerzaehler / beets-alternatives / 28091147561

24 Jun 2026 10:09AM UTC coverage: 96.917% (-0.4%) from 97.361%
28091147561

push

github

web-flow
Run tests against beets 2.12 (#241)

* Run tests against beets 2.12

* fixup! Run tests against beets 2.12

77 of 78 branches covered (98.72%)

12 of 15 new or added lines in 3 files covered. (80.0%)

4 existing lines in 1 file now uncovered.

1069 of 1103 relevant lines covered (96.92%)

0.97 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.55
/test/cli_test.py
1
import io
1✔
2
import platform
1✔
3
from pathlib import Path
1✔
4
from time import sleep
1✔
5

6
import pytest
1✔
7

8
try:
1✔
9
    from beets.exceptions import UserError
1✔
NEW
10
except ImportError:
×
NEW
11
    from beets.ui import UserError  # pyright: ignore[reportPrivateImportUsage]
×
12
from beets.util.artresizer import ArtResizer
1✔
13
from beets.util.functemplate import Template
1✔
14
from confuse import ConfigValueError
1✔
15
from mediafile import MediaFile
1✔
16
from PIL import Image
1✔
17

18
from .helper import (
1✔
19
    TestHelper,
20
    assert_file_tag,
21
    assert_has_embedded_artwork,
22
    assert_has_not_embedded_artwork,
23
    assert_is_not_file,
24
    assert_media_file_fields,
25
    assert_not_file_tag,
26
    assert_same_file_content,
27
    assert_symlink,
28
    control_stdin,
29
    convert_command,
30
    touch_art,
31
)
32

33

34
class TestDoc(TestHelper):
1✔
35
    """Test alternatives in a larger-scale scenario with transcoding and
36
    multiple changes to the library.
37
    """
38

39
    def test_external(self, tmp_path: Path):
1✔
40
        external_dir = tmp_path / "myplayer"
1✔
41
        self.config["convert"]["formats"] = {
1✔
42
            "aac": {
43
                "command": convert_command("ISAAC"),
44
                "extension": "m4a",
45
            },
46
        }
47
        self.config["alternatives"] = {
1✔
48
            "myplayer": {
49
                "directory": str(external_dir),
50
                "paths": {"default": "$artist/$title"},
51
                "formats": "aac mp3",
52
                "query": "onplayer:true",
53
                "removable": True,
54
            }
55
        }
56

57
        self.add_album(artist="Bach", title="was mp3", format="mp3")
1✔
58
        self.add_album(artist="Bach", title="was m4a", format="m4a")
1✔
59
        self.add_album(artist="Bach", title="was ogg", format="ogg")
1✔
60
        self.add_album(artist="Beethoven", title="was ogg", format="ogg")
1✔
61

62
        external_from_mp3 = external_dir / "Bach" / "was mp3.mp3"
1✔
63
        external_from_m4a = external_dir / "Bach" / "was m4a.m4a"
1✔
64
        external_from_ogg = external_dir / "Bach" / "was ogg.m4a"
1✔
65
        external_beet = external_dir / "Beethoven" / "was ogg.m4a"
1✔
66

67
        self.runcli("modify", "--yes", "onplayer=true", "artist:Bach")
1✔
68
        with control_stdin("y"):
1✔
69
            out = self.runcli("alt", "update", "myplayer")
1✔
70
            assert "Do you want to create the collection?" in out
1✔
71

72
        assert_not_file_tag(external_from_mp3, b"ISAAC")
1✔
73
        assert_not_file_tag(external_from_m4a, b"ISAAC")
1✔
74
        assert_file_tag(external_from_ogg, b"ISAAC")
1✔
75
        assert not external_beet.exists()
1✔
76

77
        self.runcli("modify", "--yes", "composer=JSB", "artist:Bach")
1✔
78

79
        list_output = self.runcli(
1✔
80
            "alt", "list-tracks", "myplayer", "--format", "$artist $title"
81
        )
82
        assert list_output == "Bach was mp3\nBach was m4a\nBach was ogg\n"
1✔
83

84
        self.runcli("alt", "update", "myplayer")
1✔
85
        mediafile = MediaFile(external_from_ogg)
1✔
86
        assert mediafile.composer == "JSB"
1✔
87

88
        self.runcli("modify", "--yes", "onplayer!", "artist:Bach")
1✔
89
        self.runcli(
1✔
90
            "modify", "--album", "--yes", "onplayer=true", "albumartist:Beethoven"
91
        )
92
        self.runcli("alt", "update", "myplayer")
1✔
93

94
        list_output = self.runcli(
1✔
95
            "alt", "list-tracks", "myplayer", "--format", "$artist"
96
        )
97
        assert list_output == "Beethoven\n"
1✔
98

99
        assert not external_from_mp3.exists()
1✔
100
        assert not external_from_m4a.exists()
1✔
101
        assert not external_from_ogg.exists()
1✔
102
        assert_file_tag(external_beet, b"ISAAC")
1✔
103

104

105
@pytest.mark.skipif(platform.system() == "Windows", reason="no symlinks on windows")
1✔
106
class TestSymlinkView(TestHelper):
1✔
107
    """Test alternatives with the ``link`` format producing symbolic links."""
108

109
    @pytest.fixture(autouse=True)
1✔
110
    def _symlink_view(self):
1✔
111
        self.lib.path_formats = [("default", Template("$artist/$album/$title"))]
1✔
112
        self.config["paths"] = {"default": "$artist/$album/$title"}
1✔
113
        self.config["alternatives"] = {
1✔
114
            "by-year": {
115
                "paths": {"default": "$year/$album/$title"},
116
                "formats": "link",
117
            }
118
        }
119
        self.alt_config = self.config["alternatives"]["by-year"]
1✔
120

121
    def _test_add_move_remove_album(self, *, event_log: Path, absolute: bool):
1✔
122
        """Test that symlinks are created, moved and deleted."""
123

124
        self.add_album(
1✔
125
            artist="Michael Jackson",
126
            album="Thriller",
127
            year="1990",
128
            original_year="1982",
129
        )
130

131
        # Symlink is created
132
        self.runcli("alt", "update", "by-year")
1✔
133
        alt_path_1 = self.libdir / "by-year/1990/Thriller/track 1.mp3"
1✔
134
        library_path = self.libdir / "Michael Jackson/Thriller/track 1.mp3"
1✔
135
        assert_symlink(alt_path_1, library_path, absolute)
1✔
136

137
        # Alternative is not updated
138
        assert self.runcli("alt", "update", "by-year") == ""
1✔
139

140
        # Symlink location is updated when path config changes
141
        self.alt_config["paths"]["default"] = "$original_year/$album/$title"
1✔
142
        self.runcli("alt", "update", "by-year")
1✔
143
        alt_path_2 = self.libdir / "by-year/1982/Thriller/track 1.mp3"
1✔
144
        assert_symlink(alt_path_2, library_path, absolute)
1✔
145

146
        # Symlink is removed
147
        self.alt_config["query"] = "some_field::foobar"
1✔
148
        self.runcli("alt", "update", "by-year")
1✔
149
        assert_is_not_file(alt_path_2)
1✔
150
        assert event_log.read_text().split("\n") == [
1✔
151
            f"by-year, ADD, {alt_path_1}, track 1",
152
            f"by-year, MOVE, {alt_path_2}, track 1",
153
            f"by-year, REMOVE, {alt_path_2}, track 1",
154
            "",
155
        ]
156

157
    def test_add_move_remove_album_absolute(self, event_log: Path):
1✔
158
        """Test that absolute symlinks are created, moved and deleted."""
159

160
        self.alt_config["link_type"] = "absolute"
1✔
161
        self._test_add_move_remove_album(event_log=event_log, absolute=True)
1✔
162

163
    def test_add_move_remove_album_relative(self, event_log: Path):
1✔
164
        """Test that relative symlinks are created, moved and deleted."""
165

166
        self.alt_config["link_type"] = "relative"
1✔
167
        self._test_add_move_remove_album(event_log=event_log, absolute=False)
1✔
168

169
    def test_update_link_target(self, tmp_path: Path):
1✔
170
        """Link targets are updated when the item has moved in the library"""
171

172
        self.add_album(artist="Michael Jackson", album="Thriller", year="1990")
1✔
173

174
        self.runcli("alt", "update", "by-year")
1✔
175

176
        alt_path = self.libdir / "by-year/1990/Thriller/track 1.mp3"
1✔
177
        assert_symlink(
1✔
178
            link=alt_path,
179
            target=self.libdir / "Michael Jackson/Thriller/track 1.mp3",
180
            absolute=True,
181
        )
182

183
        # Moving a library item breaks the symlink
184
        new_libdir = tmp_path / "newlib"
1✔
185
        new_libdir.mkdir()
1✔
186
        self.runcli("move", "-a", "-d", str(new_libdir), "Thriller")
1✔
187
        assert alt_path.is_symlink()
1✔
188
        assert not alt_path.is_file()
1✔
189

190
        # Updating the alternative fixes the symlink
191
        self.runcli("alt", "update", "by-year")
1✔
192
        assert_symlink(
1✔
193
            link=alt_path,
194
            target=new_libdir / "Michael Jackson/Thriller/track 1.mp3",
195
            absolute=True,
196
        )
197

198
    def test_invalid_link_type(self):
1✔
199
        self.alt_config["link_type"] = "Hylian"
1✔
200

201
        with pytest.raises(ConfigValueError):
1✔
202
            self.runcli("alt", "update", "by-year")
1✔
203

204
    def test_album_art_linked(self, tmp_path: Path):
1✔
205
        self.alt_config["album_art_copy"] = True
1✔
206
        self.config["art_filename"] = "COVER"
1✔
207
        album = self.add_album(
1✔
208
            artist="Michael Jackson",
209
            album="Thriller",
210
            year="1990",
211
            original_year="1982",
212
        )
213
        album.set_art(self.IMAGE_FIXTURE1)
1✔
214
        album.store()
1✔
215
        self.runcli("alt", "update", "by-year")
1✔
216

217
        external_album_path = tmp_path / "beets_lib" / "by-year" / "1990" / "Thriller"
1✔
218
        external_art_path = external_album_path / "COVER.png"
1✔
219

220
        # Symlink is created
221
        assert album.artpath
1✔
222
        assert_symlink(external_art_path, Path(str(album.artpath, "utf8")))
1✔
223

224

225
class TestExternalCopy(TestHelper):
1✔
226
    """Test alternatives with empty ``format `` option, i.e. only copying
227
    without transcoding.
228
    """
229

230
    @pytest.fixture(autouse=True)
1✔
231
    def _external_copy(self, tmp_path: Path, _setup: None):
1✔
232
        self.config["alternatives"] = {
1✔
233
            "myexternal": {
234
                "directory": str(tmp_path),
235
                "query": "myexternal:true",
236
            }
237
        }
238
        self.external_config = self.config["alternatives"]["myexternal"]
1✔
239

240
    def test_add_singleton(self, event_log: Path):
1✔
241
        item = self.add_track(title="\u00e9", myexternal="true")
1✔
242
        self.runcli("alt", "update", "myexternal")
1✔
243
        item.load()
1✔
244
        alt_path = self.get_path(item)
1✔
245
        assert alt_path.is_file()
1✔
246

247
        assert event_log.read_text() == f"myexternal, ADD, {alt_path}, \u00e9\n"
1✔
248

249
    def test_add_album(self):
1✔
250
        album = self.add_album()
1✔
251
        album["myexternal"] = "true"
1✔
252
        album.store()
1✔
253
        self.runcli("alt", "update", "myexternal")
1✔
254
        for item in album.items():
1✔
255
            assert self.get_path(item).is_file()
1✔
256

257
    def test_add_nonexistent(self):
1✔
258
        item = self.add_external_track("myexternal")
1✔
259
        path = self.get_path(item)
1✔
260
        path.unlink()
1✔
261

262
        self.runcli("alt", "update", "myexternal")
1✔
263
        assert path.is_file()
1✔
264

265
    def test_add_replace(self):
1✔
266
        item = self.add_external_track("myexternal")
1✔
267
        del item["alt.myexternal"]
1✔
268
        item.store()
1✔
269

270
        self.runcli("alt", "update", "myexternal")
1✔
271
        item.load()
1✔
272
        assert "alt.myexternal" in item
1✔
273

274
    def test_update_older(self):
1✔
275
        item = self.add_external_track("myexternal")
1✔
276
        sleep(0.1)
1✔
277
        item.comments = "foo"
1✔
278
        item.store()
1✔
279
        item.write()
1✔
280

281
        self.runcli("alt", "update", "myexternal")
1✔
282
        item.load()
1✔
283
        mediafile = MediaFile(self.get_path(item))
1✔
284
        assert mediafile.comments == "foo"
1✔
285

286
    def test_no_update_newer(self):
1✔
287
        item = self.add_external_track("myexternal")
1✔
288
        sleep(0.1)
1✔
289
        item["composer"] = "JSB"
1✔
290
        item.store()
1✔
291
        # We omit write to keep old mtime
292

293
        self.runcli("alt", "update", "myexternal")
1✔
294
        item.load()
1✔
295
        mediafile = MediaFile(self.get_path(item))
1✔
296
        assert mediafile.composer != "JSB"
1✔
297

298
    def test_move_after_path_format_update(self):
1✔
299
        item = self.add_external_track("myexternal")
1✔
300
        old_path = self.get_path(item)
1✔
301
        assert old_path.is_file()
1✔
302

303
        self.external_config["paths"] = {"default": "$album/$title"}
1✔
304
        self.runcli("alt", "update", "myexternal")
1✔
305

306
        item.load()
1✔
307
        new_path = self.get_path(item)
1✔
308
        assert_is_not_file(old_path)
1✔
309
        assert new_path.is_file()
1✔
310

311
    def test_move_and_write_after_tags_changed(self):
1✔
312
        item = self.add_external_track("myexternal")
1✔
313
        old_path = self.get_path(item)
1✔
314
        assert old_path.is_file()
1✔
315

316
        sleep(0.1)
1✔
317
        item["title"] = "a new title"
1✔
318
        item.store()
1✔
319
        item.write()
1✔
320
        self.runcli("alt", "update", "myexternal")
1✔
321

322
        item.load()
1✔
323
        new_path = self.get_path(item)
1✔
324
        assert_is_not_file(old_path)
1✔
325
        assert new_path.is_file()
1✔
326
        mediafile = MediaFile(new_path)
1✔
327
        assert mediafile.title == "a new title"
1✔
328

329
    def test_prune_after_move(self):
1✔
330
        item = self.add_external_track("myexternal")
1✔
331
        item_alt_path = self.get_path(item)
1✔
332
        assert item_alt_path
1✔
333
        assert item_alt_path.parent.is_dir()
1✔
334

335
        item["artist"] = "a new artist"
1✔
336
        item.store()
1✔
337
        self.runcli("alt", "update", "myexternal")
1✔
338

339
        assert not item_alt_path.parent.is_dir()
1✔
340

341
    def test_remove_item(self):
1✔
342
        item = self.add_external_track("myexternal")
1✔
343
        old_path = self.get_path(item)
1✔
344
        assert old_path.is_file()
1✔
345

346
        del item["myexternal"]
1✔
347
        item.store()
1✔
348
        self.runcli("alt", "update", "myexternal")
1✔
349

350
        item.load()
1✔
351
        assert "alt.myexternal" not in item
1✔
352
        assert_is_not_file(old_path)
1✔
353

354
    def test_remove_album(self):
1✔
355
        album = self.add_external_album("myexternal")
1✔
356
        item = album.items().get()
1✔
357
        assert item
1✔
358
        old_path = self.get_path(item)
1✔
359
        assert old_path.is_file()
1✔
360

361
        del album["myexternal"]
1✔
362
        album.store()
1✔
363
        self.runcli("alt", "update", "myexternal")
1✔
364

365
        item.load()
1✔
366
        assert "alt.myexternal" not in item
1✔
367
        assert_is_not_file(old_path)
1✔
368

369
    def test_unkown_collection(self):
1✔
370
        with pytest.raises(UserError) as e:
1✔
371
            self.runcli("alt", "update", "unkown")
1✔
372
        assert str(e.value) == "Alternative collection 'unkown' not found."
1✔
373

374
    def test_update_all(self, tmp_path: Path):
1✔
375
        dir_a = tmp_path / "a"
1✔
376
        dir_a.mkdir()
1✔
377
        dir_b = tmp_path / "b"
1✔
378
        dir_b.mkdir()
1✔
379
        self.config["alternatives"].get().clear()
1✔
380
        self.config["alternatives"] = {
1✔
381
            "a": {
382
                "directory": str(dir_a),
383
                "query": "myexternal:true",
384
            },
385
            "b": {
386
                "directory": str(dir_b),
387
                "query": "myexternal:true",
388
            },
389
        }
390

391
        with pytest.raises(UserError) as e:
1✔
392
            self.runcli("alt", "update")
1✔
393
        assert str(e.value) == "Please specify a collection name or the --all flag"
1✔
394

395
        item = self.add_track(title="a", myexternal="true")
1✔
396
        self.runcli("alt", "update", "--all")
1✔
397
        item.load()
1✔
398
        path_a = self.get_path(item, path_key="alt.a")
1✔
399
        assert path_a
1✔
400
        assert dir_a in path_a.parents
1✔
401
        assert path_a.is_file()
1✔
402

403
        path_b = self.get_path(item, path_key="alt.b")
1✔
404
        assert path_b
1✔
405
        assert dir_b in path_b.parents
1✔
406
        assert path_b.is_file()
1✔
407

408
        # Don’t update files on second run
409
        assert self.runcli("alt", "update", "--all") == ""
1✔
410

411

412
class TestExternalArt(TestHelper):
1✔
413
    @pytest.fixture(autouse=True)
1✔
414
    def _external_art(self, tmp_path: Path, _setup: None):
1✔
415
        self.external_dir = tmp_path
1✔
416
        self.config["convert"]["embed"] = False
1✔
417
        self.config["art_filename"] = "COVER"
1✔
418
        self.config["alternatives"] = {
1✔
419
            "myexternal": {
420
                "directory": str(self.external_dir),
421
                "query": "myexternal:true",
422
                "formats": "mp3",
423
                "album_art_copy": False,
424
                "album_art_maxwidth": None,
425
                "album_art_format": None,
426
                "album_art_deinterlace": False,
427
                "album_art_quality": 0,
428
            }
429
        }
430
        self.external_config = self.config["alternatives"]["myexternal"]
1✔
431

432
    def test_resize_art(self, tmp_path: Path):
1✔
433
        def assert_art_size(path: bytes):
1✔
434
            size = ArtResizer.shared.get_size(path_in=path)
1✔
435
            assert size is not None
1✔
436
            assert size[0] == 1  # width
1✔
437
            assert size[1] < 3  # height
1✔
438

439
        album = self.add_album(myexternal="true")
1✔
440
        album.store()
1✔
441
        self.runcli("alt", "update", "myexternal")
1✔
442

443
        external_album_path = self.external_dir / "artist 1" / "album 1"
1✔
444
        external_art_path = external_album_path / "COVER.png"
1✔
445
        external_art_path_bytes = bytes(external_art_path)
1✔
446

447
        self.external_config["album_art_copy"] = True
1✔
448
        self.external_config["album_art_maxwidth"] = 1
1✔
449
        album.set_art(self.IMAGE_FIXTURE1)
1✔
450
        assert album.artpath
1✔
451
        artpath = Path(str(album.artpath, "utf8"))
1✔
452
        touch_art(album.artpath, artpath)
1✔
453
        album.store()
1✔
454
        self.runcli("alt", "update", "myexternal")
1✔
455
        assert_art_size(external_art_path_bytes)
1✔
456
        assert ArtResizer.shared.get_format(path_in=external_art_path_bytes) == "PNG"
1✔
457

458
        self.external_config["album_art_format"] = "JPEG"
1✔
459
        self.runcli("alt", "update", "myexternal")
1✔
460

461
        external_art_path = external_album_path / "COVER.jpg"
1✔
462
        external_art_path_bytes = bytes(external_art_path)
1✔
463

464
        assert_art_size(external_art_path_bytes)
1✔
465
        assert ArtResizer.shared.get_format(path_in=external_art_path_bytes) == "JPEG"
1✔
466
        assert external_art_path.name == "COVER.jpg"
1✔
467
        # Check that original album art is still around to verify that
468
        # the reformat was not done in-place
469
        assert artpath.is_file()
1✔
470

471
        # Test that reformat is idempotent
472
        touch_art(album.artpath, external_art_path)
1✔
473
        mtime_1 = external_art_path.stat().st_mtime
1✔
474
        self.runcli("alt", "update", "myexternal")
1✔
475
        mtime_2 = external_art_path.stat().st_mtime
1✔
476
        assert mtime_1 == mtime_2
1✔
477

478
    def test_copy_art(self):
1✔
479
        # Initially add album without artwork. Do not do resizing
480
        self.external_config["album_art_embed"] = False
1✔
481
        self.external_config["album_art_copy"] = True
1✔
482

483
        external_album_path = self.external_dir / "artist 1" / "album 1"
1✔
484
        external_art_path = external_album_path / "COVER.png"
1✔
485

486
        album = self.add_album(myexternal="true")
1✔
487
        album.store()
1✔
488
        self.runcli("alt", "update", "myexternal")
1✔
489

490
        # Test that no artwork is placed
491
        self.runcli("alt", "update", "myexternal")
1✔
492
        assert not external_art_path.is_file()
1✔
493

494
        album.set_art(self.IMAGE_FIXTURE1)
1✔
495
        assert album.artpath
1✔
496
        touch_art(album.artpath, Path(str(album.artpath, "utf8")))
1✔
497
        album.store()
1✔
498
        self.runcli("alt", "update", "myexternal")
1✔
499
        assert_same_file_content(external_art_path, self.IMAGE_FIXTURE1)
1✔
500

501
        # Update art file
502
        album.set_art(self.IMAGE_FIXTURE2)
1✔
503
        touch_art(album.artpath, Path(str(album.artpath, "utf8")))
1✔
504
        self.runcli("alt", "update", "myexternal")
1✔
505
        assert_same_file_content(external_art_path, self.IMAGE_FIXTURE2)
1✔
506

507
        # Test that art is updated after extension was updated
508
        self.external_config["album_art_format"] = "JPEG"
1✔
509
        self.runcli("alt", "update", "myexternal")
1✔
510
        external_art_path = external_album_path / "COVER.jpg"
1✔
511
        assert external_art_path.is_file()
1✔
512

513
        # Test that art is not updated
514
        # Change dest timestamp to be newer than artpath
515
        touch_art(album.artpath, external_art_path)
1✔
516
        mtime_before = external_art_path.stat().st_mtime
1✔
517
        self.runcli("alt", "update", "myexternal")
1✔
518
        assert mtime_before == external_art_path.stat().st_mtime
1✔
519

520
    def test_embed_art(self, tmp_path: Path):
1✔
521
        """Test that artwork is embedded and updated to match the source file.
522

523
        There used to be a bug that meant that albumart was only embedded
524
        once on initial addition to the alternative collection, but not if
525
        the artwork was added or changed later.
526

527
        This test comprehensively checks that embedded artwork is up-to-date
528
        with the artwork file, even if no changes to the database happen.
529

530
        It also tests if the album_art_maxwidth is applied
531
        """
532

533
        # Initially add album without artwork. Do not do resizing
534
        self.external_config["embed"] = True
1✔
535
        self.external_config["album_art_maxwidth"] = None
1✔
536
        album = self.add_album(myexternal="true")
1✔
537
        album.store()
1✔
538
        self.runcli("alt", "update", "myexternal")
1✔
539

540
        item = album.items().get()
1✔
541
        assert item
1✔
542
        assert_has_not_embedded_artwork(self.get_path(item))
1✔
543

544
        # Add a cover image, assert that it is being embedded.
545
        album.set_art(self.IMAGE_FIXTURE1)
1✔
546
        album.store()
1✔
547
        self.runcli("alt", "update", "myexternal")
1✔
548

549
        item = album.items().get()
1✔
550
        assert item
1✔
551
        assert_has_embedded_artwork(self.get_path(item), self.IMAGE_FIXTURE1)
1✔
552

553
        # Change content and update mtime, but do not change the item/album in
554
        # database.
555
        # Assert that artwork is re-embedded.
556
        album.set_art(self.IMAGE_FIXTURE2)
1✔
557
        self.runcli("alt", "update", "myexternal")
1✔
558

559
        item = album.items().get()
1✔
560
        assert item
1✔
561
        assert_has_embedded_artwork(self.get_path(item), self.IMAGE_FIXTURE2)
1✔
562

563
        # now set a maxwidth and verify the final image has the right
564
        # dimensions
565
        assert album.artpath
1✔
566
        touch_art(item.path, Path(str(album.artpath, "utf8")))
1✔
567
        self.external_config["album_art_maxwidth"] = 1
1✔
568
        self.runcli("alt", "update", "myexternal")
1✔
569
        mediafile = MediaFile(self.get_path(item))
1✔
570
        width, height = Image.open(io.BytesIO(mediafile.art)).size  # pyright: ignore
1✔
571
        assert width == 1
1✔
572
        assert height < 3
1✔
573

574

575
class TestExternalConvert(TestHelper):
1✔
576
    """Test alternatives with non-empty ``format`` option, i.e. transcoding
577
    some of the files.
578
    """
579

580
    @pytest.fixture(autouse=True)
1✔
581
    def _external_convert(self, tmp_path: Path, _setup: None):
1✔
582
        external_dir = str(tmp_path)
1✔
583
        self.config["convert"]["formats"] = {"ogg": convert_command("ISOGG")}
1✔
584
        self.config["alternatives"] = {
1✔
585
            "myexternal": {
586
                "directory": external_dir,
587
                "query": "myexternal:true",
588
                "formats": "ogg mp3",
589
            }
590
        }
591
        self.external_config = self.config["alternatives"]["myexternal"]
1✔
592

593
    def test_convert(self):
1✔
594
        item = self.add_track(myexternal="true", format="m4a")
1✔
595
        self.runcli("alt", "update", "myexternal")
1✔
596
        item.load()
1✔
597
        converted_path = self.get_path(item)
1✔
598
        assert_file_tag(converted_path, b"ISOGG")
1✔
599

600
    def test_convert_and_embed(self):
1✔
601
        self.config["convert"]["embed"] = True
1✔
602

603
        album = self.add_album(myexternal="true", format="m4a")
1✔
604
        album.artpath = bytes(self.IMAGE_FIXTURE1)
1✔
605
        album.store()
1✔
606

607
        self.runcli("alt", "update", "myexternal")
1✔
608
        item = album.items().get()
1✔
609
        assert item
1✔
610
        assert_has_embedded_artwork(self.get_path(item))
1✔
611

612
    def test_convert_write_tags(self):
1✔
613
        item = self.add_track(myexternal="true", format="m4a", title="TITLE")
1✔
614

615
        # We "convert" by copying the file. Setting the title simulates
616
        # a badly behaved converter
617
        mediafile_converted = MediaFile(item.path)
1✔
618
        mediafile_converted.title = "WRONG"
1✔
619
        mediafile_converted.save()
1✔
620

621
        self.runcli("alt", "update", "myexternal")
1✔
622
        item.load()
1✔
623

624
        alt_mediafile = MediaFile(self.get_path(item))
1✔
625
        assert alt_mediafile.title == "TITLE"
1✔
626

627
    def test_skip_convert_for_same_format(self):
1✔
628
        item = self.add_track(myexternal="true")
1✔
629
        item["format"] = "OGG"
1✔
630
        item.store()
1✔
631
        self.runcli("alt", "update", "myexternal")
1✔
632
        item.load()
1✔
633
        converted_path = self.get_path(item)
1✔
634
        assert_not_file_tag(converted_path, b"ISOGG")
1✔
635

636
    def test_skip_convert_for_alternative_format(self):
1✔
637
        item = self.add_track(myexternal="true")
1✔
638
        item["format"] = "MP3"
1✔
639
        item.store()
1✔
640
        self.runcli("alt", "update", "myexternal")
1✔
641
        item.load()
1✔
642
        converted_path = self.get_path(item)
1✔
643
        assert_not_file_tag(converted_path, b"ISOGG")
1✔
644

645
    def test_no_move_on_extension_change(self):
1✔
646
        item = self.add_track(myexternal="true", format="m4a")
1✔
647
        self.runcli("alt", "update", "myexternal")
1✔
648

649
        self.config["convert"]["formats"] = {"mp3": convert_command("ISMP3")}
1✔
650
        self.config["alternatives"]["myexternal"]["formats"] = "mp3"
1✔
651

652
        # Assert that this re-encodes instead of copying the ogg file
653
        self.runcli("alt", "update", "myexternal")
1✔
654
        item.load()
1✔
655
        converted_path = self.get_path(item)
1✔
656
        assert_file_tag(converted_path, b"ISMP3")
1✔
657

658

659
@pytest.mark.skipif(platform.system() == "Windows", reason="converter not implemented")
1✔
660
class TestExternalConvertWorker(TestHelper):
1✔
661
    """Test alternatives with non-empty ``format`` option, i.e. transcoding
662
    some of the files. In contrast to the previous test, these test do use
663
    the parallelizing ``beetsplug.alternatives.Worker``.
664
    """
665

666
    def test_convert_multiple(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1✔
667
        monkeypatch.undo()
1✔
668
        external_dir = str(tmp_path)
1✔
669
        self.config["convert"]["formats"] = {
1✔
670
            "ogg": "bash -c \"cp '{source}' '$dest'\"".format(
671
                # The convert plugin will encode this again using arg_encoding
672
                source=self.item_fixture_path("ogg")
673
            )
674
        }
675
        self.config["alternatives"] = {
1✔
676
            "myexternal": {
677
                "directory": external_dir,
678
                "query": "myexternal:true",
679
                "formats": "ogg",
680
            }
681
        }
682

683
        items = [
1✔
684
            self.add_track(
685
                title=f"track {i}",
686
                myexternal="true",
687
                format="m4a",
688
            )
689
            for i in range(24)
690
        ]
691
        self.runcli("alt", "update", "myexternal")
1✔
692
        for item in items:
1✔
693
            item.load()
1✔
694
            converted_path = self.get_path(item)
1✔
695
            assert_media_file_fields(converted_path, type="ogg", title=item.title)
1✔
696

697

698
class TestExternalRemovable(TestHelper):
1✔
699
    """Test whether alternatives properly detects ``removable`` collections
700
    and performs the expected user queries before doing anything.
701
    """
702

703
    @pytest.fixture(autouse=True)
1✔
704
    def _external_removable(self, tmp_path: Path, _setup: None):
1✔
705
        external_dir = str(tmp_path / "\u00e9xt")
1✔
706
        self.config["alternatives"] = {
1✔
707
            "myexternal": {
708
                "directory": external_dir,
709
                "query": "",
710
            }
711
        }
712
        self.external_config = self.config["alternatives"]["myexternal"]
1✔
713

714
    def test_ask_create_yes(self):
1✔
715
        item = self.add_track()
1✔
716
        with control_stdin("y"):
1✔
717
            out = self.runcli("alt", "update", "myexternal")
1✔
718
            assert "Do you want to create the collection?" in out
1✔
719
        item.load()
1✔
720
        assert "alt.myexternal" in item
1✔
721

722
    def test_ask_create_no(self):
1✔
723
        item = self.add_track()
1✔
724
        with control_stdin("n"):
1✔
725
            out = self.runcli("alt", "update", "myexternal")
1✔
726
            assert "Skipping creation of" in out
1✔
727
        item.load()
1✔
728
        assert "alt.myexternal" not in item
1✔
729

730
    def test_create_option(self):
1✔
731
        item = self.add_track()
1✔
732
        self.runcli("alt", "update", "--create", "myexternal")
1✔
733
        item.load()
1✔
734
        assert "alt.myexternal" in item
1✔
735

736
    def test_no_create_option(self):
1✔
737
        item = self.add_track()
1✔
738
        self.runcli("alt", "update", "--no-create", "myexternal")
1✔
739
        item.load()
1✔
740
        assert "alt.myexternal" not in item
1✔
741

742
    def test_not_removable(self):
1✔
743
        item = self.add_track()
1✔
744
        self.external_config["removable"] = False
1✔
745
        with control_stdin("y"):
1✔
746
            out = self.runcli("alt", "update", "myexternal")
1✔
747
            assert "Do you want to create the collection?" not in out
1✔
748
        item.load()
1✔
749
        assert "alt.myexternal" in item
1✔
750

751

752
class TestCompletion(TestHelper):
1✔
753
    """Test invocation of ``beet completion`` with this plugin.
754

755
    Only ensures that command does not fail.
756
    """
757

758
    def test_completion(self):
1✔
759
        self.runcli("completion")
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc