mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
144 lines
5.7 KiB
144 lines
5.7 KiB
"""Playlist materialization seam — a playlist folder is a derived view of links
|
|
into the real library. Locks down: symlink vs copy, the auto-fallback when
|
|
symlinks aren't supported, idempotency, stale-link pruning, collision handling,
|
|
and that nothing is ever written outside the playlists root."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from core.playlists.materialize import (
|
|
DEFAULT_MODE,
|
|
materialize_one,
|
|
normalize_mode,
|
|
playlist_dir_for,
|
|
rebuild_playlist_folder,
|
|
)
|
|
|
|
|
|
def _library(tmp_path: Path) -> list[str]:
|
|
a = tmp_path / "Music" / "Daft Punk" / "Discovery" / "One More Time.mp3"
|
|
b = tmp_path / "Music" / "Queen" / "A Night at the Opera" / "Bohemian Rhapsody.mp3"
|
|
for f in (a, b):
|
|
f.parent.mkdir(parents=True, exist_ok=True)
|
|
f.write_bytes(b"audio")
|
|
return [str(a), str(b)]
|
|
|
|
|
|
def test_normalize_mode():
|
|
assert normalize_mode("symlink") == "symlink"
|
|
assert normalize_mode("COPY") == "copy"
|
|
assert normalize_mode("") == DEFAULT_MODE
|
|
assert normalize_mode(None) == DEFAULT_MODE
|
|
assert normalize_mode("nonsense") == DEFAULT_MODE
|
|
|
|
|
|
def test_symlink_mode_creates_relative_links(tmp_path: Path):
|
|
real = _library(tmp_path)
|
|
s = rebuild_playlist_folder(str(tmp_path / "Playlists"), "Road Trip", real, mode="symlink")
|
|
assert s.linked == 2 and s.copied == 0 and not s.fellback
|
|
link = Path(s.playlist_dir) / "One More Time.mp3"
|
|
assert link.is_symlink()
|
|
assert not os.path.isabs(os.readlink(link)) # relative for portability
|
|
assert link.resolve() == Path(real[0]).resolve()
|
|
assert link.read_bytes() == b"audio"
|
|
|
|
|
|
def test_symlink_mode_idempotent(tmp_path: Path):
|
|
real = _library(tmp_path)
|
|
root = str(tmp_path / "Playlists")
|
|
rebuild_playlist_folder(root, "Mix", real, mode="symlink")
|
|
s2 = rebuild_playlist_folder(root, "Mix", real, mode="symlink")
|
|
assert s2.unchanged == 2 and s2.linked == 0 and s2.removed_stale == 0
|
|
|
|
|
|
def test_copy_mode_duplicates_real_files(tmp_path: Path):
|
|
real = _library(tmp_path)
|
|
s = rebuild_playlist_folder(str(tmp_path / "Playlists"), "USB", real, mode="copy")
|
|
assert s.copied == 2 and s.linked == 0
|
|
f = Path(s.playlist_dir) / "Bohemian Rhapsody.mp3"
|
|
assert f.is_file() and not f.is_symlink() and f.read_bytes() == b"audio"
|
|
|
|
|
|
def test_copy_mode_idempotent(tmp_path: Path):
|
|
real = _library(tmp_path)
|
|
root = str(tmp_path / "Playlists")
|
|
rebuild_playlist_folder(root, "Mix", real, mode="copy")
|
|
s2 = rebuild_playlist_folder(root, "Mix", real, mode="copy")
|
|
assert s2.unchanged == 2 and s2.copied == 0
|
|
|
|
|
|
def test_falls_back_to_copy_when_symlinks_unsupported(tmp_path: Path):
|
|
real = _library(tmp_path)
|
|
|
|
def _no_symlinks(target, link):
|
|
raise OSError("symlinks not supported here")
|
|
|
|
s = rebuild_playlist_folder(str(tmp_path / "Playlists"), "Car", real,
|
|
mode="symlink", symlink_fn=_no_symlinks)
|
|
assert s.fellback is True and s.copied == 2 and s.linked == 0
|
|
f = Path(s.playlist_dir) / "One More Time.mp3"
|
|
assert f.is_file() and not f.is_symlink() and f.read_bytes() == b"audio"
|
|
|
|
|
|
def test_rebuild_prunes_entries_no_longer_in_playlist(tmp_path: Path):
|
|
real = _library(tmp_path)
|
|
root = str(tmp_path / "Playlists")
|
|
rebuild_playlist_folder(root, "Mix", real, mode="symlink") # 2 entries
|
|
s = rebuild_playlist_folder(root, "Mix", real[:1], mode="symlink") # drop one
|
|
assert s.removed_stale == 1
|
|
pdir = Path(s.playlist_dir)
|
|
assert (pdir / "One More Time.mp3").exists()
|
|
assert not (pdir / "Bohemian Rhapsody.mp3").exists()
|
|
|
|
|
|
def test_prune_stale_can_be_disabled(tmp_path: Path):
|
|
real = _library(tmp_path)
|
|
root = str(tmp_path / "Playlists")
|
|
rebuild_playlist_folder(root, "Mix", real, mode="symlink")
|
|
s = rebuild_playlist_folder(root, "Mix", real[:1], mode="symlink", prune_stale=False)
|
|
assert s.removed_stale == 0
|
|
assert (Path(s.playlist_dir) / "Bohemian Rhapsody.mp3").exists()
|
|
|
|
|
|
def test_switching_mode_replaces_links_with_copies(tmp_path: Path):
|
|
real = _library(tmp_path)
|
|
root = str(tmp_path / "Playlists")
|
|
rebuild_playlist_folder(root, "Mix", real, mode="symlink")
|
|
s = rebuild_playlist_folder(root, "Mix", real, mode="copy")
|
|
f = Path(s.playlist_dir) / "One More Time.mp3"
|
|
assert f.is_file() and not f.is_symlink() and s.copied == 2
|
|
|
|
|
|
def test_basename_collision_is_disambiguated_not_overwritten(tmp_path: Path):
|
|
p1 = tmp_path / "Music" / "A" / "AlbumA" / "01 - Intro.mp3"
|
|
p2 = tmp_path / "Music" / "B" / "AlbumB" / "01 - Intro.mp3"
|
|
for f, data in ((p1, b"one"), (p2, b"two")):
|
|
f.parent.mkdir(parents=True, exist_ok=True)
|
|
f.write_bytes(data)
|
|
s = rebuild_playlist_folder(str(tmp_path / "Playlists"), "Dup", [str(p1), str(p2)], mode="copy")
|
|
pdir = Path(s.playlist_dir)
|
|
assert (pdir / "01 - Intro.mp3").read_bytes() == b"one"
|
|
assert (pdir / "01 - Intro (2).mp3").read_bytes() == b"two" # second kept, not lost
|
|
assert s.copied == 2
|
|
|
|
|
|
def test_missing_source_is_counted_not_fatal(tmp_path: Path):
|
|
real = _library(tmp_path)
|
|
s = rebuild_playlist_folder(str(tmp_path / "Playlists"), "Mix",
|
|
real + [str(tmp_path / "gone.mp3")], mode="symlink")
|
|
assert s.linked == 2 and s.missing_source == 1
|
|
|
|
|
|
def test_playlist_name_cannot_escape_root(tmp_path: Path):
|
|
root = tmp_path / "Playlists"
|
|
nasty = playlist_dir_for(str(root), "../../etc/evil")
|
|
assert os.path.abspath(nasty).startswith(os.path.abspath(str(root)) + os.sep)
|
|
|
|
|
|
def test_materialize_one_missing_source(tmp_path: Path):
|
|
dest = tmp_path / "Playlists" / "X" / "x.mp3"
|
|
assert materialize_one(str(tmp_path / "nope.mp3"), str(dest), "symlink") == "missing"
|
|
assert not dest.exists()
|