"""Round 37: File → Export Playlist… — a folder of files, or a web mix. Two destinations share one planner. The folder variant copies files verbatim under "Artist - Title" names next to an .m3u. The web variant additionally writes a self-contained page (index.html + a dependency-free player.js/css, replacing the abandoned audio.js + jQuery pair the hand-made mixes shipped) and converts anything a browser can't decode. The load-bearing claims under test: * bitrate never triggers a conversion — only *unplayability* does; * a conversion is always to FLAC, so it can never lose a bit; * DRM'd tracks are reported, never silently dropped and never attempted; * the manifest is written last, so a cancelled export leaves no page or m3u promising files that aren't there. Since Round 42 the web variant's only manifest is the page — a folder you upload has no use for a playlist file. """ import hashlib import shutil import subprocess import pytest from lintunes.export import exporter, web_support from lintunes.export.exporter import ExportWorker, plan_export from lintunes.models import Track def _track(tid, name, artist, path, kind="MPEG audio file", total_time=180_000): return Track(track_id=tid, name=name, artist=artist, location=str(path), kind=kind, total_time=total_time) def _audio(tmp_path, filename, data=b"x" * 100): path = tmp_path / "local" / filename path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) return path def _run(plan, cancel_on_emit=None): """Drive a worker synchronously on the test thread so signals fire directly.""" worker = ExportWorker(plan) out = {"finished": None, "cancelled": None, "failed": None, "progress": []} worker.finished.connect(lambda d: out.__setitem__("finished", d)) worker.cancelled.connect(lambda d: out.__setitem__("cancelled", d)) worker.failed.connect(lambda m: out.__setitem__("failed", m)) def on_progress(done, total, label): out["progress"].append((done, total, label)) if cancel_on_emit is not None and len(out["progress"]) >= cancel_on_emit: worker.cancel() worker.progress.connect(on_progress) worker._run() return out def _pcm_hash(path): """SHA-256 of the decoded samples — format-independent audio identity.""" result = subprocess.run( ["ffmpeg", "-loglevel", "error", "-i", str(path), "-f", "s32le", "-c:a", "pcm_s32le", "-"], capture_output=True, ) assert result.returncode == 0, result.stderr.decode() return hashlib.sha256(result.stdout).hexdigest() class TestPlayabilityGate: @pytest.mark.parametrize("name", [ "a.mp3", "a.mpga", "a.m4a", "a.aac", "a.wav", "a.flac", "a.ogg", "a.opus", "a.webm", ]) def test_browser_native_formats_are_copied_verbatim(self, name): assert web_support.conversion_for(f"/music/{name}") == (None, None) def test_bitrate_is_never_a_conversion_trigger(self): # A 320 kbps MP3 is exactly as playable as a 96 kbps one. assert web_support.conversion_for( "/music/loud.mp3", "MPEG audio file") == (None, None) def test_plain_aac_in_m4a_is_copied(self): for kind in ("AAC audio file", "Matched AAC audio file", "Purchased AAC audio file"): assert web_support.conversion_for("/music/a.m4a", kind) == (None, None) def test_apple_lossless_converts_despite_playable_suffix(self): # .m4a is in the allow-list; only `kind` reveals this is ALAC. assert web_support.conversion_for( "/music/a.m4a", "Apple Lossless audio file") == ("flac", None) def test_aiff_converts(self): assert web_support.conversion_for("/music/a.aiff") == ("flac", None) assert web_support.conversion_for("/music/a.aif") == ("flac", None) def test_unknown_format_converts_rather_than_being_dropped(self): assert web_support.conversion_for("/music/a.wma") == ("flac", None) def test_drm_is_refused_never_converted(self): for loc, kind in (("/music/a.m4p", ""), ("/music/a.m4a", "Protected AAC audio file")): target, reason = web_support.conversion_for(loc, kind) assert target is None assert "copy-protected" in reason def test_missing_location_is_refused(self): assert web_support.conversion_for("") == (None, "it has no file") class TestPlan: def test_folder_plan_keeps_playlist_order(self, tmp_path): tracks = [_track(i, f"Song {i}", "Artist", _audio(tmp_path, f"{i}.mp3")) for i in (3, 1, 2)] plan = plan_export("Mix", tracks, tmp_path / "out", exporter.FOLDER) assert [i.display for i in plan.items] == [ "Artist - Song 3", "Artist - Song 1", "Artist - Song 2"] def test_folder_names_are_artist_title(self, tmp_path): plan = plan_export( "Mix", [_track(1, "Lights Out", "Broadcast", _audio(tmp_path, "x.mp3"))], tmp_path / "out", exporter.FOLDER) assert plan.items[0].dest_name == "Broadcast - Lights Out.mp3" def test_dest_layout_differs_by_kind(self, tmp_path): tracks = [_track(1, "S", "A", _audio(tmp_path, "x.mp3"))] folder = plan_export("My Mix", tracks, tmp_path / "out", exporter.FOLDER) web = plan_export("My Mix", tracks, tmp_path / "out", exporter.WEB) assert folder.audio_dir == folder.dest_dir == tmp_path / "out" / "My Mix" assert web.audio_dir == tmp_path / "out" / "My Mix" / "audios" def test_missing_files_are_skipped_with_a_reason(self, tmp_path): tracks = [ _track(1, "Here", "A", _audio(tmp_path, "x.mp3")), _track(2, "Gone", "A", tmp_path / "local" / "nope.mp3"), _track(3, "Nowhere", "A", ""), ] plan = plan_export("Mix", tracks, tmp_path / "out", exporter.FOLDER) assert len(plan.items) == 1 assert [d for d, _why in plan.skipped] == ["A - Gone", "A - Nowhere"] assert all("missing" in why for _d, why in plan.skipped) def test_duplicate_track_ids_are_collapsed(self, tmp_path): src = _audio(tmp_path, "x.mp3") track = _track(1, "S", "A", src) plan = plan_export("Mix", [track, track, track], tmp_path / "out", exporter.FOLDER) assert len(plan.items) == 1 def test_collisions_get_stable_suffixes_regardless_of_order(self, tmp_path): a = _track(7, "Song", "Artist", _audio(tmp_path, "a.mp3")) b = _track(9, "Song", "Artist", _audio(tmp_path, "b.mp3")) forward = plan_export("Mix", [a, b], tmp_path / "o1", exporter.FOLDER) reverse = plan_export("Mix", [b, a], tmp_path / "o2", exporter.FOLDER) assert {i.dest_name for i in forward.items} == { "Artist - Song [7].mp3", "Artist - Song [9].mp3"} # Every member of the colliding group is suffixed, so the name a given # track gets never depends on where it sits in the playlist. assert {i.dest_name for i in reverse.items} == { i.dest_name for i in forward.items} def test_drm_only_skipped_for_web(self, tmp_path): src = _audio(tmp_path, "locked.m4p") tracks = [_track(1, "Locked", "A", src, kind="Protected AAC audio file")] web = plan_export("Mix", tracks, tmp_path / "o1", exporter.WEB) folder = plan_export("Mix", tracks, tmp_path / "o2", exporter.FOLDER) assert web.items == [] and len(web.skipped) == 1 # A plain folder is for a real music player, which may well cope. assert len(folder.items) == 1 and folder.skipped == [] def test_needs_ffmpeg_only_when_something_converts(self, tmp_path): plain = plan_export( "Mix", [_track(1, "S", "A", _audio(tmp_path, "x.mp3"))], tmp_path / "o1", exporter.WEB) lossless = plan_export( "Mix", [_track(1, "S", "A", _audio(tmp_path, "y.m4a"), kind="Apple Lossless audio file")], tmp_path / "o2", exporter.WEB) assert not plain.needs_ffmpeg assert lossless.needs_ffmpeg class TestFolderExport: def test_writes_files_and_m3u(self, tmp_path): tracks = [ _track(1, "Lights Out", "Broadcast", _audio(tmp_path, "a.mp3", b"aa"), total_time=241_000), _track(2, "Naomi", "Neutral Milk Hotel", _audio(tmp_path, "b.mp3", b"bbb"), total_time=180_400), ] plan = plan_export("Road Trip", tracks, tmp_path / "out", exporter.FOLDER) out = _run(plan) assert out["failed"] is None assert out["finished"]["exported"] == 2 dest = tmp_path / "out" / "Road Trip" assert (dest / "Broadcast - Lights Out.mp3").read_bytes() == b"aa" assert (dest / "Neutral Milk Hotel - Naomi.mp3").read_bytes() == b"bbb" m3u = (dest / "Road Trip.m3u").read_text().splitlines() assert m3u == [ "#EXTM3U", "#EXTINF:241,Broadcast - Lights Out", "Broadcast - Lights Out.mp3", "#EXTINF:180,Neutral Milk Hotel - Naomi", "Neutral Milk Hotel - Naomi.mp3", ] def test_no_web_assets_in_a_folder_export(self, tmp_path): plan = plan_export( "Mix", [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))], tmp_path / "out", exporter.FOLDER) _run(plan) dest = tmp_path / "out" / "Mix" assert not (dest / "index.html").exists() assert not (dest / "player.js").exists() def test_source_files_are_never_touched(self, tmp_path): src = _audio(tmp_path, "a.mp3", b"original") plan = plan_export("Mix", [_track(1, "S", "A", src)], tmp_path / "out", exporter.FOLDER) _run(plan) assert src.exists() and src.read_bytes() == b"original" class TestWebExport: def _export(self, tmp_path, tracks, **details): plan = plan_export("My Mix", tracks, tmp_path / "out", exporter.WEB) for key, value in details.items(): setattr(plan, key, value) out = _run(plan) assert out["failed"] is None, out["failed"] return tmp_path / "out" / "My Mix", out def test_ships_a_self_contained_page(self, tmp_path): dest, _ = self._export( tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))]) for name in ("index.html", "player.js", "player.css", "player-graphics.gif", "placeholder.png"): assert (dest / name).is_file(), f"missing {name}" assert (dest / "audios" / "A - S.mp3").is_file() def test_no_jquery_or_audiojs_anywhere(self, tmp_path): dest, _ = self._export( tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))]) html = (dest / "index.html").read_text() assert "jquery" not in html.lower() assert "audio.min.js" not in html assert ".swf" not in html assert not list(dest.glob("*.swf")) # The one remaining script tag is ours. assert html.count(" from the *filename*; the visible label keeps # them and must be escaped or the markup breaks. tracks = [_track(1, "Rock & Roll", "AC", _audio(tmp_path, "a.mp3"))] dest, _ = self._export(tmp_path, tracks) html = (dest / "index.html").read_text() assert "AC<DC> - Rock & Roll" in html assert "" not in html def test_title_and_description_are_substituted(self, tmp_path): dest, _ = self._export( tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))], title="2025", description='hi there') html = (dest / "index.html").read_text() assert "2025" in html assert "

2025

" in html # Deliberately raw: the mixes lean on inline links right here. assert 'hi there' in html assert "$title" not in html and "$tracklist" not in html def test_title_is_escaped(self, tmp_path): dest, _ = self._export( tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))], title="Me & You") assert "

Me & You

" in (dest / "index.html").read_text() def test_placeholder_image_when_none_chosen(self, tmp_path): dest, _ = self._export( tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))]) assert 'src="placeholder.png"' in (dest / "index.html").read_text() assert (dest / "placeholder.png").is_file() def test_chosen_image_is_copied_and_referenced(self, tmp_path): image = tmp_path / "friend.png" image.write_bytes(b"\x89PNG\r\n\x1a\n" + b"fake") dest, _ = self._export( tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))], image=image) assert (dest / "friend.png").read_bytes() == image.read_bytes() assert 'src="friend.png"' in (dest / "index.html").read_text() def test_no_m3u_beside_the_page(self, tmp_path): """A web mix is a folder you upload, not one you open in a player.""" dest, _ = self._export( tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))]) assert not list(dest.glob("*.m3u")) def test_drm_track_is_reported_not_dropped_silently(self, tmp_path): tracks = [ _track(1, "Fine", "A", _audio(tmp_path, "a.mp3")), _track(2, "Locked", "A", _audio(tmp_path, "b.m4p")), ] plan = plan_export("My Mix", tracks, tmp_path / "out", exporter.WEB) out = _run(plan) assert out["finished"]["skipped"] == 1 assert plan.skipped[0][0] == "A - Locked" assert not (tmp_path / "out" / "My Mix" / "audios" / "A - Locked.m4p").exists() class TestConversion: def test_alac_becomes_bit_identical_flac(self, tmp_path): if shutil.which("ffmpeg") is None: pytest.skip("ffmpeg not available") src = tmp_path / "local" / "lossless.m4a" src.parent.mkdir(parents=True, exist_ok=True) assert subprocess.run( ["ffmpeg", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=2", "-c:a", "alac", str(src)], ).returncode == 0 tracks = [_track(1, "Lossless", "A", src, kind="Apple Lossless audio file")] plan = plan_export("Mix", tracks, tmp_path / "out", exporter.WEB) assert plan.items[0].dest_name == "A - Lossless.flac" out = _run(plan) assert out["failed"] is None, out["failed"] assert out["finished"]["converted"] == 1 flac = tmp_path / "out" / "Mix" / "audios" / "A - Lossless.flac" assert flac.is_file() # The whole point of targeting FLAC: not one sample is lost. assert _pcm_hash(flac) == _pcm_hash(src) def test_playable_file_is_copied_byte_for_byte(self, mp3_file, tmp_path): tracks = [_track(1, "S", "A", mp3_file)] plan = plan_export("Mix", tracks, tmp_path / "out", exporter.WEB) assert plan.items[0].convert_to is None _run(plan) copied = tmp_path / "out" / "Mix" / "audios" / "A - S.mp3" assert copied.read_bytes() == mp3_file.read_bytes() def test_ffmpeg_available_reflects_path(self, monkeypatch): monkeypatch.setattr(web_support.shutil, "which", lambda _: None) assert not web_support.ffmpeg_available() monkeypatch.setattr(web_support.shutil, "which", lambda _: "/usr/bin/ffmpeg") assert web_support.ffmpeg_available() def test_missing_ffmpeg_fails_the_export_rather_than_writing_junk(self, tmp_path): src = _audio(tmp_path, "lossless.m4a") tracks = [_track(1, "S", "A", src, kind="Apple Lossless audio file")] plan = plan_export("Mix", tracks, tmp_path / "out", exporter.WEB) import lintunes.export.web_support as ws original = ws.subprocess.Popen def no_ffmpeg(*args, **kwargs): raise FileNotFoundError("ffmpeg") ws.subprocess.Popen = no_ffmpeg try: out = _run(plan) finally: ws.subprocess.Popen = original assert out["failed"] is not None assert "ffmpeg" in out["failed"] # No manifest, so nothing claims the track was exported. assert not (tmp_path / "out" / "Mix" / "index.html").exists() class TestCancel: def _many(self, tmp_path, count=4): # Files bigger than one CHUNK so progress fires mid-copy. return [_track(i, f"Song {i}", "A", _audio(tmp_path, f"{i}.mp3", b"z" * (exporter.CHUNK + 10))) for i in range(count)] def test_cancel_stops_early(self, tmp_path): plan = plan_export("Mix", self._many(tmp_path), tmp_path / "out", exporter.FOLDER) out = _run(plan, cancel_on_emit=1) assert out["cancelled"] is not None assert out["finished"] is None assert out["cancelled"]["exported"] < out["cancelled"]["total"] def test_cancel_writes_no_manifest(self, tmp_path): plan = plan_export("Mix", self._many(tmp_path), tmp_path / "out", exporter.WEB) _run(plan, cancel_on_emit=1) dest = tmp_path / "out" / "Mix" assert not (dest / "index.html").exists() def test_cancel_removes_the_partial_file(self, tmp_path): plan = plan_export("Mix", self._many(tmp_path), tmp_path / "out", exporter.FOLDER) _run(plan, cancel_on_emit=1) dest = tmp_path / "out" / "Mix" for path in dest.iterdir(): # Whatever survived is a whole file, never a truncated one. assert path.stat().st_size == exporter.CHUNK + 10 def test_cancel_before_any_work_writes_nothing(self, tmp_path): plan = plan_export("Mix", self._many(tmp_path), tmp_path / "out", exporter.FOLDER) worker = ExportWorker(plan) seen = {} worker.cancelled.connect(lambda d: seen.update(d)) worker.cancel() worker._run() assert seen["exported"] == 0 assert not (tmp_path / "out" / "Mix" / "Mix.m3u").exists() class TestVanishedSource: def test_file_removed_after_planning_is_reported_not_fatal(self, tmp_path): gone = _audio(tmp_path, "gone.mp3") tracks = [_track(1, "Here", "A", _audio(tmp_path, "here.mp3")), _track(2, "Gone", "A", gone)] plan = plan_export("Mix", tracks, tmp_path / "out", exporter.FOLDER) gone.unlink() # Syncthing moved it under us out = _run(plan) assert out["failed"] is None assert out["finished"]["vanished"] == 1 assert out["finished"]["exported"] == 1 # The m3u lists only what is really there. m3u = (tmp_path / "out" / "Mix" / "Mix.m3u").read_text() assert "A - Here.mp3" in m3u assert "A - Gone.mp3" not in m3u class TestGuiWiring: """The menu plumbing, which sync never got a test for. Export shares the status-bar progress widgets with device sync, so the interlock between the two is the part worth pinning down. """ @pytest.fixture def window(self, qapp, tmp_path): from lintunes.gui.main_window import MainWindow from lintunes.library_manager import LibraryManager from lintunes.models import Library, Playlist from lintunes.models.playlist import PlaylistType from lintunes.preferences import Preferences audio = _audio(tmp_path, "a.mp3") library = Library( tracks={1: _track(1, "Song", "Artist", audio)}, playlists={"abcd1234": Playlist( name="My Mix", persistent_id="abcd1234", playlist_type=PlaylistType.REGULAR, track_ids=[1])}, ) manager = LibraryManager(library, tmp_path / "data") win = MainWindow(manager, Preferences(tmp_path / "data")) yield win win.close() def test_export_action_is_in_the_file_menu(self, window): texts = [a.text() for menu in window.menuBar().actions() if menu.menu() for a in menu.menu().actions()] assert "Export Playlist…" in texts def test_action_needs_a_playlist_in_view(self, window): window._refresh_export_action() assert not window._export_action.isEnabled() window._show_playlist("abcd1234") window._refresh_export_action() assert window._export_action.isEnabled() def test_sidebar_context_menu_signal_reaches_the_window(self, window): seen = [] window._sidebar.tree.export_requested.disconnect() window._sidebar.tree.export_requested.connect(seen.append) window._sidebar.tree.export_requested.emit("abcd1234") assert seen == ["abcd1234"] def test_a_running_export_blocks_both_export_and_sync(self, window, tmp_path): window._show_playlist("abcd1234") plan = plan_export("My Mix", [window._manager.library.tracks[1]], tmp_path / "out", exporter.FOLDER) window._export_worker = ExportWorker(plan, window) window._export_worker._busy = True try: assert window._busy_worker() is window._export_worker window._refresh_export_action() assert not window._export_action.isEnabled() window._refresh_connection_actions() assert not window._sync_action.isEnabled() finally: window._export_worker._busy = False def test_export_of_an_empty_playlist_says_so_and_starts_nothing( self, window, monkeypatch): from lintunes.gui import main_window as mw window._manager.library.playlists["abcd1234"].track_ids = [] window._show_playlist("abcd1234") told = [] monkeypatch.setattr(mw.QMessageBox, "information", lambda *a, **k: told.append(a[2])) window._export_playlist("abcd1234") assert told and "nothing to export" in told[0] assert window._export_worker is None