v0.30.0: Cassette delivery — requested songs arrive in your library
- Sending: a friend's requests ∩ what I still offer is copied into my outbox (via a .part name Syncthing never sees); whatever they no longer request is deleted, so the outbox cleans itself up. - Receiving: a finished file I cassetted is imported exactly like Add to Library — organized into Artist/Album, tags read, date added now, none of their listening history — and a song wanted only for a followed playlist lands in the cache. Then requests.json is rewritten without what arrived and without what they stopped offering. - Safe to repeat: Syncthing temp files ignored, Syncthing asked whether a file is whole, and an exact match already in my library is never imported twice. - Folders are watched (re-armed after each rename), with a slow fallback. - file_importer split into stage_file (off the GUI thread) and track_from_file (date added = now). - pytest --syncthing now runs a real invite → share → request → deliver → cleanup between two Syncthing instances. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
"""Round 68: the request / delivery loop.
|
||||
|
||||
* Sender: their requests ∩ what I offer *now* → my outbox (copied under a
|
||||
.part name Syncthing never sees, then renamed); anything no longer
|
||||
requested is deleted — that's how the outbox cleans itself up.
|
||||
* Receiver: a finished file I want is imported like Add to Library (date
|
||||
added now, their listening history left behind) or, for a followed
|
||||
playlist, copied to the cache; then my requests are rewritten without it
|
||||
and without anything they stopped offering.
|
||||
* Safe to repeat: Syncthing temp files are ignored, an exact match already
|
||||
in my library is never imported twice.
|
||||
|
||||
``test_loop_between_two_libraries`` stands in for Syncthing by copying each
|
||||
side's out/ into the other's in/; the ``syncthing``-marked test does it for
|
||||
real.
|
||||
"""
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lintunes.cassette import delivery, matching, publish, share
|
||||
from lintunes.cassette import requests as req
|
||||
from lintunes.cassette.service import CassetteService
|
||||
from lintunes.cassette.state import (
|
||||
FOLLOW_LIBRARY, Friend, Selection, cache_dir, in_dir, out_dir,
|
||||
)
|
||||
from lintunes.models import Playlist, Track
|
||||
from lintunes.models.library import Library
|
||||
|
||||
|
||||
def _library_with_file(tmp_path, mp3_file, tid=5, name="Song", artist="Artist",
|
||||
album="Album"):
|
||||
library = Library()
|
||||
path = tmp_path / f"{name}.mp3"
|
||||
shutil.copyfile(mp3_file, path)
|
||||
library.tracks[tid] = Track(track_id=tid, name=name, artist=artist,
|
||||
album=album, location=str(path), size=path.stat().st_size,
|
||||
total_time=1000, play_count=99, rating=100)
|
||||
return library
|
||||
|
||||
|
||||
# ---- names ----
|
||||
|
||||
class TestNames:
|
||||
def test_outbox_names(self):
|
||||
name = delivery.outbox_name(12, "/m/A/B/04 Hey: Jude.mp3")
|
||||
assert name == "12 ~ 04 Hey_ Jude.mp3"
|
||||
assert delivery.parse_outbox_name(name) == (12, "04 Hey_ Jude.mp3")
|
||||
assert delivery.parse_outbox_name("notes.txt") is None
|
||||
|
||||
@pytest.mark.parametrize("name", [
|
||||
".syncthing.12 ~ a.mp3.tmp", "~syncthing~12 ~ a.mp3.tmp",
|
||||
".12 ~ a.mp3.part"])
|
||||
def test_temp_files_are_invisible(self, tmp_path, name):
|
||||
(tmp_path / name).write_text("x")
|
||||
assert delivery.list_outbox(tmp_path) == {}
|
||||
|
||||
|
||||
# ---- sending ----
|
||||
|
||||
class TestSend:
|
||||
def test_requested_and_offered_only(self, tmp_path, mp3_file):
|
||||
library = _library_with_file(tmp_path, mp3_file)
|
||||
library.tracks[6] = Track(track_id=6, name="Not offered",
|
||||
location=library.tracks[5].location)
|
||||
root = tmp_path / "root"
|
||||
req.write_requests(in_dir(root, "t"), [5, 6])
|
||||
plan = delivery.plan_send("t", root, library, Selection(track_ids={5}))
|
||||
assert [name for _src, name in plan.copies] == ["5 ~ Song.mp3"]
|
||||
|
||||
def test_stopped_offering_means_not_sent(self, tmp_path, mp3_file):
|
||||
library = _library_with_file(tmp_path, mp3_file)
|
||||
root = tmp_path / "root"
|
||||
req.write_requests(in_dir(root, "t"), [5])
|
||||
plan = delivery.plan_send("t", root, library, Selection())
|
||||
assert plan.copies == []
|
||||
|
||||
def test_copy_then_clean_up(self, tmp_path, mp3_file):
|
||||
library = _library_with_file(tmp_path, mp3_file)
|
||||
root = tmp_path / "root"
|
||||
selection = Selection(all_library=True)
|
||||
req.write_requests(in_dir(root, "t"), [5])
|
||||
plan = delivery.plan_send("t", root, library, selection)
|
||||
result = delivery.run_plans([plan], [], None)
|
||||
outbox = out_dir(root, "t") / delivery.OUTBOX
|
||||
assert result.sent == 1
|
||||
assert [p.name for p in outbox.iterdir()] == ["5 ~ Song.mp3"]
|
||||
# nothing to do the second time
|
||||
again = delivery.plan_send("t", root, library, selection)
|
||||
assert again.copies == [] and again.deletes == []
|
||||
# it arrived: the request is gone, so the file goes
|
||||
req.write_requests(in_dir(root, "t"), [])
|
||||
plan = delivery.plan_send("t", root, library, selection)
|
||||
delivery.run_plans([plan], [], None)
|
||||
assert list(outbox.iterdir()) == []
|
||||
|
||||
def test_missing_file_is_reported_not_crashed(self, tmp_path):
|
||||
library = Library()
|
||||
library.tracks[5] = Track(track_id=5, name="Gone", location="/nope.mp3")
|
||||
root = tmp_path / "root"
|
||||
req.write_requests(in_dir(root, "t"), [5])
|
||||
plan = delivery.plan_send("t", root, library, Selection(all_library=True))
|
||||
assert plan.missing == [5] and plan.copies == []
|
||||
|
||||
|
||||
# ---- receiving ----
|
||||
|
||||
def _their_share(root, token, library, playlists=()):
|
||||
for playlist in playlists:
|
||||
library.playlists[playlist.persistent_id] = playlist
|
||||
share.publish_share(in_dir(root, token), library,
|
||||
Selection(all_library=True, all_playlists=True))
|
||||
|
||||
|
||||
def _deliver(root, token, source: Path, tid: int):
|
||||
outbox = in_dir(root, token) / delivery.OUTBOX
|
||||
outbox.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, outbox / delivery.outbox_name(tid, str(source)))
|
||||
|
||||
|
||||
class TestReceive:
|
||||
def _setup(self, tmp_path, mp3_file, **friend_kw):
|
||||
theirs = _library_with_file(tmp_path, mp3_file)
|
||||
root = tmp_path / "root"
|
||||
_their_share(root, "t", theirs, friend_kw.pop("playlists", ()))
|
||||
friend = Friend(token="t", device_id="D", name="Sam", **friend_kw)
|
||||
return theirs, root, friend
|
||||
|
||||
def test_cassetted_file_is_planned_for_import(self, tmp_path, mp3_file):
|
||||
theirs, root, friend = self._setup(tmp_path, mp3_file, cassetted=[5])
|
||||
_deliver(root, "t", Path(theirs.tracks[5].location), 5)
|
||||
plan = delivery.plan_receive(friend, root, matching.LibraryIndex([]))
|
||||
[arrival] = plan.arrivals
|
||||
assert arrival.to_library and not arrival.to_cache
|
||||
assert plan.cassetted == set() and plan.requests_after == set()
|
||||
|
||||
def test_incomplete_is_left_alone(self, tmp_path, mp3_file):
|
||||
theirs, root, friend = self._setup(tmp_path, mp3_file, cassetted=[5])
|
||||
_deliver(root, "t", Path(theirs.tracks[5].location), 5)
|
||||
plan = delivery.plan_receive(friend, root, matching.LibraryIndex([]),
|
||||
is_complete=lambda p: False)
|
||||
assert plan.arrivals == [] and plan.requests_after == {5}
|
||||
|
||||
def test_already_have_it_is_not_imported_twice(self, tmp_path, mp3_file):
|
||||
theirs, root, friend = self._setup(tmp_path, mp3_file, cassetted=[5])
|
||||
_deliver(root, "t", Path(theirs.tracks[5].location), 5)
|
||||
mine = [Track(track_id=1, name="Song", artist="Artist", album="Album",
|
||||
total_time=1000)]
|
||||
plan = delivery.plan_receive(friend, root, matching.LibraryIndex(mine))
|
||||
assert plan.arrivals == [] and plan.already_have == [5]
|
||||
assert plan.requests_after == set()
|
||||
|
||||
def test_prunes_what_they_stopped_offering(self, tmp_path, mp3_file):
|
||||
theirs, root, friend = self._setup(tmp_path, mp3_file, cassetted=[5, 77])
|
||||
plan = delivery.plan_receive(friend, root, matching.LibraryIndex([]))
|
||||
assert plan.requests_after == {5}
|
||||
|
||||
def test_no_library_file_yet_prunes_nothing(self, tmp_path):
|
||||
root = tmp_path / "root"
|
||||
friend = Friend(token="t", device_id="D", name="Sam", cassetted=[5, 77])
|
||||
plan = delivery.plan_receive(friend, root, matching.LibraryIndex([]))
|
||||
assert plan.requests_after == {5, 77}
|
||||
|
||||
def test_followed_playlist_goes_to_the_cache(self, tmp_path, mp3_file):
|
||||
theirs, root, friend = self._setup(
|
||||
tmp_path, mp3_file, followed=["P"],
|
||||
playlists=[Playlist(name="Mix", persistent_id="P", track_ids=[5])])
|
||||
assert delivery.plan_receive(friend, root,
|
||||
matching.LibraryIndex([])).requests_after == {5}
|
||||
_deliver(root, "t", Path(theirs.tracks[5].location), 5)
|
||||
plan = delivery.plan_receive(friend, root, matching.LibraryIndex([]))
|
||||
[arrival] = plan.arrivals
|
||||
assert arrival.to_cache and not arrival.to_library
|
||||
result = delivery.run_plans([], [plan], tmp_path / "Music")
|
||||
assert (cache_dir(root, "t") / "5.mp3").exists()
|
||||
assert result.staged == []
|
||||
# cached: no longer asked for
|
||||
again = delivery.plan_receive(friend, root, matching.LibraryIndex([]))
|
||||
assert again.requests_after == set()
|
||||
|
||||
def test_followed_into_library_mode(self, tmp_path, mp3_file):
|
||||
theirs, root, friend = self._setup(
|
||||
tmp_path, mp3_file, followed=["P"], followed_mode=FOLLOW_LIBRARY,
|
||||
playlists=[Playlist(name="Mix", persistent_id="P", track_ids=[5])])
|
||||
_deliver(root, "t", Path(theirs.tracks[5].location), 5)
|
||||
[arrival] = delivery.plan_receive(friend, root,
|
||||
matching.LibraryIndex([])).arrivals
|
||||
assert arrival.to_library and arrival.to_cache
|
||||
|
||||
def test_staging_uses_the_original_name(self, tmp_path, mp3_file):
|
||||
theirs, root, friend = self._setup(tmp_path, mp3_file, cassetted=[5])
|
||||
_deliver(root, "t", Path(theirs.tracks[5].location), 5)
|
||||
plan = delivery.plan_receive(friend, root, matching.LibraryIndex([]))
|
||||
result = delivery.run_plans([], [plan], tmp_path / "Music")
|
||||
[staged] = result.staged
|
||||
assert staged.dest.name == "Song.mp3"
|
||||
assert tmp_path / "Music" in staged.dest.parents
|
||||
|
||||
|
||||
# ---- the whole loop, Syncthing played by shutil ----
|
||||
|
||||
def _sync(a_root, b_root, token):
|
||||
"""Copy each side's out/ to the other's in/, like Syncthing would
|
||||
(including deletions)."""
|
||||
for src_root, dst_root in ((a_root, b_root), (b_root, a_root)):
|
||||
src, dst = out_dir(src_root, token), in_dir(dst_root, token)
|
||||
if dst.exists():
|
||||
shutil.rmtree(dst)
|
||||
if src.exists():
|
||||
shutil.copytree(src, dst, ignore=shutil.ignore_patterns("*.part", "*.tmp"))
|
||||
|
||||
|
||||
def test_loop_between_two_libraries(qapp, tmp_path, mp3_file):
|
||||
from lintunes.gui.cassette_delivery import DeliveryCoordinator
|
||||
from lintunes.library_manager import LibraryManager
|
||||
token = "0123456789abcdef"
|
||||
alice_lib = _library_with_file(tmp_path, mp3_file, tid=5, name="Doo Wop",
|
||||
artist="Lauryn Hill", album="Miseducation")
|
||||
alice_mgr = LibraryManager(alice_lib, tmp_path / "alice-data")
|
||||
bob_mgr = LibraryManager(Library(), tmp_path / "bob-data")
|
||||
bob_mgr.organize_root = lambda: tmp_path / "bob-music"
|
||||
|
||||
def service(name, manager, selection=None):
|
||||
s = CassetteService(root=tmp_path / name, config_loader=lambda: {})
|
||||
s._state.friends[token] = Friend(token=token, device_id=name.upper(),
|
||||
name=name, in_folder="x",
|
||||
selection=selection or Selection())
|
||||
s.set_library_provider(lambda: manager.library)
|
||||
s.is_complete = lambda t, p: True
|
||||
return s
|
||||
alice = service("alice", alice_mgr, Selection(all_library=True))
|
||||
bob = service("bob", bob_mgr)
|
||||
alice.republish()
|
||||
_sync(alice.root, bob.root, token)
|
||||
|
||||
# Bob cassettes Alice's song and saves.
|
||||
bob.save_requests(token, {5}, [], {5})
|
||||
_sync(alice.root, bob.root, token)
|
||||
|
||||
def run(coordinator):
|
||||
coordinator.run()
|
||||
deadline = time.time() + 10
|
||||
while coordinator.busy() and time.time() < deadline:
|
||||
qapp.processEvents()
|
||||
time.sleep(0.01)
|
||||
qapp.processEvents()
|
||||
|
||||
a_loop = DeliveryCoordinator(alice, alice_mgr)
|
||||
b_loop = DeliveryCoordinator(bob, bob_mgr)
|
||||
arrived = []
|
||||
b_loop.notice.connect(arrived.append)
|
||||
|
||||
run(a_loop) # Alice sends
|
||||
assert [p.name for p in (out_dir(alice.root, token) / "outbox").iterdir()] \
|
||||
== ["5 ~ Doo Wop.mp3"]
|
||||
_sync(alice.root, bob.root, token)
|
||||
run(b_loop) # Bob brings it in
|
||||
[track] = bob_mgr.library.tracks.values()
|
||||
assert track.name != "" and Path(track.location).exists()
|
||||
assert tmp_path / "bob-music" in Path(track.location).parents
|
||||
assert track.play_count == 0 and track.rating == 0
|
||||
assert arrived == ["1 song arrived from bob."]
|
||||
assert req.read_requests(out_dir(bob.root, token)) == set()
|
||||
assert bob.state().friends[token].cassetted == []
|
||||
|
||||
_sync(alice.root, bob.root, token)
|
||||
run(a_loop) # Alice's outbox cleans up
|
||||
assert list((out_dir(alice.root, token) / "outbox").iterdir()) == []
|
||||
_sync(alice.root, bob.root, token)
|
||||
run(b_loop) # and nothing is imported twice
|
||||
assert len(bob_mgr.library.tracks) == 1
|
||||
|
||||
|
||||
# ---- the real thing ----
|
||||
|
||||
@pytest.mark.syncthing
|
||||
def test_real_delivery(qapp, tmp_path, syncthing_pair, mp3_file):
|
||||
from lintunes.cassette import invite as inv
|
||||
from lintunes.cassette import service as svc_mod
|
||||
from lintunes.cassette import syncthing_api as st
|
||||
from lintunes.gui.cassette_delivery import DeliveryCoordinator
|
||||
from lintunes.library_manager import LibraryManager
|
||||
alice_i, bob_i, _carol = syncthing_pair
|
||||
listen = {i.device_id: [i.listen_address] for i in syncthing_pair}
|
||||
alice_mgr = LibraryManager(
|
||||
_library_with_file(tmp_path, mp3_file, tid=5, name="Doo Wop",
|
||||
artist="Lauryn Hill", album="Miseducation"),
|
||||
tmp_path / "alice-data")
|
||||
bob_mgr = LibraryManager(Library(), tmp_path / "bob-data")
|
||||
bob_mgr.organize_root = lambda: tmp_path / "bob-music"
|
||||
|
||||
def make(instance, manager):
|
||||
config = {st.API_KEY_KEY: instance.api_key, st.ADDRESS_KEY: instance.address}
|
||||
service = CassetteService(root=tmp_path / instance.name,
|
||||
config_loader=lambda: config,
|
||||
addresses_for=lambda d: listen[d])
|
||||
service._state.display_name = instance.name.title()
|
||||
service.set_library_provider(lambda: manager.library)
|
||||
return service, service.client(), DeliveryCoordinator(service, manager)
|
||||
|
||||
a, a_client, a_loop = make(alice_i, alice_mgr)
|
||||
b, b_client, b_loop = make(bob_i, bob_mgr)
|
||||
code = a.create_invite()
|
||||
b.accept_code(code)
|
||||
token = inv.decode(code).token
|
||||
|
||||
def settle(condition, timeout=120):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
for service, client in ((a, a_client), (b, b_client)):
|
||||
service.reconcile(client)
|
||||
for loop in (a_loop, b_loop):
|
||||
if not loop.busy():
|
||||
loop.run()
|
||||
qapp.processEvents()
|
||||
if condition():
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
assert settle(lambda: token in a.state().friends
|
||||
and a.status(token) and a.status(token).code == svc_mod.CONNECTED)
|
||||
edited = a.state()
|
||||
edited.friends[token].selection = Selection(all_library=True)
|
||||
a.apply_settings(edited)
|
||||
assert settle(lambda: 5 in load_their_ids(b, token))
|
||||
b.save_requests(token, {5}, [], {5})
|
||||
assert settle(lambda: len(bob_mgr.library.tracks) == 1), "never arrived"
|
||||
assert settle(lambda: not any((out_dir(a.root, token) / "outbox").glob("*"))), \
|
||||
"sender's outbox never cleaned up"
|
||||
|
||||
|
||||
def load_their_ids(service, token):
|
||||
from lintunes.cassette.friend_library import load_friend_library
|
||||
return set(load_friend_library(in_dir(service.root, token)).tracks)
|
||||
Reference in New Issue
Block a user