Compare commits
1
Commits
v0.5.0
...
19b5c750b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19b5c750b7 |
@@ -150,9 +150,15 @@ tools/sync-to-ipod.sh /mnt/tank/media/music-mp3 /media/IPOD/Music
|
|||||||
tools/sync-to-ipod.sh -n /mnt/tank/media/music-mp3 /media/IPOD/Music # dry run
|
tools/sync-to-ipod.sh -n /mnt/tank/media/music-mp3 /media/IPOD/Music # dry run
|
||||||
```
|
```
|
||||||
|
|
||||||
It refuses to start unless the destination is a mounted FAT filesystem that is
|
The destination is where the artist folders should end up — normally a
|
||||||
its own mount point, because `--delete` aimed at the wrong directory empties it
|
subdirectory such as `/media/IPOD/Music`, not the card root. A subdirectory is
|
||||||
and does not announce itself. It also excludes `/.rockbox`, the scrobbler logs
|
the better target: `--delete` is confined to it, and the device path budget is
|
||||||
|
derived from it rather than configured, so the two cannot disagree.
|
||||||
|
|
||||||
|
It refuses to start unless the destination is on a mounted FAT filesystem. That
|
||||||
|
check is also what catches an unmounted device — `/media/IPOD/Music` then
|
||||||
|
resolves to the host's own root filesystem, and this refuses to empty that.
|
||||||
|
`--help` says all of it. It also excludes `/.rockbox`, the scrobbler logs
|
||||||
and the various filesystem metadata directories from deletion — the mirror does
|
and the various filesystem metadata directories from deletion — the mirror does
|
||||||
not contain them, and without the exclusion a sync to the card root would
|
not contain them, and without the exclusion a sync to the card root would
|
||||||
remove the Rockbox install.
|
remove the Rockbox install.
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""The guards on sync-to-ipod.sh, which are the substance of the script.
|
||||||
|
|
||||||
|
rsync --delete is being aimed at a whole filesystem, so every refusal here is
|
||||||
|
protecting against emptying the wrong directory -- a mistake that does not
|
||||||
|
announce itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).resolve().parent.parent / "tools" / "sync-to-ipod.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def run(*arguments):
|
||||||
|
return subprocess.run(
|
||||||
|
["bash", str(SCRIPT), *arguments], capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mirror(tmp_path):
|
||||||
|
source = tmp_path / "mirror"
|
||||||
|
(source / "Album").mkdir(parents=True)
|
||||||
|
(source / "Album" / "track.mp3").write_bytes(b"x")
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_host_root_is_refused(mirror):
|
||||||
|
"""Stripping the trailing slash from "/" leaves an empty string, and an
|
||||||
|
earlier version then reported it as "not a directory" instead."""
|
||||||
|
result = run(str(mirror), "/")
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "refusing to sync onto /" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_mirror_is_refused(tmp_path):
|
||||||
|
"""Mirroring nothing onto the device would delete everything on it."""
|
||||||
|
empty = tmp_path / "empty"
|
||||||
|
empty.mkdir()
|
||||||
|
destination = tmp_path / "dest"
|
||||||
|
destination.mkdir()
|
||||||
|
|
||||||
|
result = run(str(empty), str(destination))
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "refusing to mirror nothing" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_syncing_a_directory_onto_itself_is_refused(mirror):
|
||||||
|
result = run(str(mirror), str(mirror))
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "same directory" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_non_fat_destination_is_refused(mirror, tmp_path):
|
||||||
|
"""Which is also how an unmounted device is caught: /media/IPOD/Music then
|
||||||
|
resolves to the host's own root filesystem."""
|
||||||
|
destination = tmp_path / "dest"
|
||||||
|
destination.mkdir()
|
||||||
|
|
||||||
|
result = run(str(mirror), str(destination))
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "not FAT" in result.stderr
|
||||||
|
assert "Is the device mounted?" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_destination_is_refused(mirror, tmp_path):
|
||||||
|
result = run(str(mirror), str(tmp_path / "nowhere"))
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "not a directory" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_subdirectory_of_the_device_is_a_valid_target(mirror, tmp_path):
|
||||||
|
"""The better target, in fact: --delete is confined to it."""
|
||||||
|
destination = tmp_path / "dest" / "Music"
|
||||||
|
destination.mkdir(parents=True)
|
||||||
|
|
||||||
|
result = run("-f", "-n", str(mirror), str(destination))
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert "dry run, nothing was written" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_device_prefix_is_derived_from_the_destination(mirror, tmp_path):
|
||||||
|
"""Derived rather than configured, so it cannot disagree with where the
|
||||||
|
files are actually going -- and the device's path limit applies to it."""
|
||||||
|
destination = tmp_path / "dest" / "Music"
|
||||||
|
destination.mkdir(parents=True)
|
||||||
|
|
||||||
|
result = run("-f", "-n", str(mirror), str(destination))
|
||||||
|
|
||||||
|
assert "the device will see this as /" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_dry_run_writes_nothing(mirror, tmp_path):
|
||||||
|
destination = tmp_path / "dest"
|
||||||
|
destination.mkdir()
|
||||||
|
|
||||||
|
run("-f", "-n", str(mirror), str(destination))
|
||||||
|
|
||||||
|
assert list(destination.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_rockbox_is_never_deleted(mirror, tmp_path):
|
||||||
|
"""A sync to the card root would otherwise remove the Rockbox install,
|
||||||
|
since the mirror does not contain it."""
|
||||||
|
destination = tmp_path / "dest"
|
||||||
|
destination.mkdir()
|
||||||
|
(destination / ".rockbox").mkdir()
|
||||||
|
(destination / ".rockbox" / "rockbox.ipod").write_bytes(b"firmware")
|
||||||
|
(destination / ".scrobbler.log").write_bytes(b"#AUDIOSCROBBLER/1.1\n")
|
||||||
|
(destination / "Stale.mp3").write_bytes(b"old")
|
||||||
|
|
||||||
|
result = run("-f", "-S", "-U", str(mirror), str(destination))
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert (destination / ".rockbox" / "rockbox.ipod").is_file()
|
||||||
|
assert (destination / ".scrobbler.log").is_file()
|
||||||
|
# But a track whose source has gone is still removed. That is the point.
|
||||||
|
assert not (destination / "Stale.mp3").exists()
|
||||||
|
assert (destination / "Album" / "track.mp3").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_help_goes_to_stdout_and_exits_clean():
|
||||||
|
"""Asking for help is not an error; getting the arguments wrong is."""
|
||||||
|
result = run("--help")
|
||||||
|
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert result.stdout.startswith("usage:")
|
||||||
|
assert result.stderr == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_help_behaves_the_same():
|
||||||
|
result = run("-h")
|
||||||
|
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert result.stdout.startswith("usage:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_misuse_goes_to_stderr_and_does_not():
|
||||||
|
result = run("only-one-argument")
|
||||||
|
|
||||||
|
assert result.returncode == 2
|
||||||
|
assert result.stderr.startswith("usage:")
|
||||||
|
assert result.stdout == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_help_explains_what_the_destination_should_be():
|
||||||
|
"""The question this script actually gets asked."""
|
||||||
|
help_text = run("--help").stdout
|
||||||
|
|
||||||
|
assert "/media/IPOD/Music" in help_text
|
||||||
|
assert "artist folders" in help_text
|
||||||
|
assert ".rockbox" in help_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_help_says_how_to_reach_and_leave_disk_mode():
|
||||||
|
help_text = run("--help").stdout
|
||||||
|
|
||||||
|
assert "Menu+Select" in help_text
|
||||||
|
assert "holding Play" in help_text
|
||||||
+56
-11
@@ -12,7 +12,13 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat >&2 <<'USAGE'
|
# Help goes to stdout and exits clean; misuse goes to stderr and does not.
|
||||||
|
local stream=2 code=2
|
||||||
|
if [ "${1:-}" = "help" ]; then
|
||||||
|
stream=1
|
||||||
|
code=0
|
||||||
|
fi
|
||||||
|
cat >&"$stream" <<'USAGE'
|
||||||
usage: sync-to-ipod.sh [options] <mirror> <destination>
|
usage: sync-to-ipod.sh [options] <mirror> <destination>
|
||||||
|
|
||||||
-n dry run; show what would change and touch nothing
|
-n dry run; show what would change and touch nothing
|
||||||
@@ -24,34 +30,60 @@ Submitting scrobbles needs LASTFM_API_KEY and LASTFM_API_SECRET; it is skipped
|
|||||||
with a note when they are unset. Scrobbling is a write method and needs the
|
with a note when they are unset. Scrobbling is a write method and needs the
|
||||||
secret, unlike the read-only calls elsewhere in these projects.
|
secret, unlike the read-only calls elsewhere in these projects.
|
||||||
|
|
||||||
The destination must be a mounted FAT filesystem. Reach it with the Apple
|
The mirror is the directory holding the artist folders. The destination is
|
||||||
firmware's disk mode: Menu+Select to reboot, then immediately Select+Play.
|
where those folders should end up on the device -- not the card root, unless
|
||||||
|
that is genuinely where you want them:
|
||||||
|
|
||||||
|
sync-to-ipod.sh /mnt/tank/media/music-mp3 /media/IPOD/Music
|
||||||
|
|
||||||
|
A subdirectory is the better target: --delete is confined to it, and the path
|
||||||
|
budget is derived from it, since the device's 260-character limit counts the
|
||||||
|
whole path as the device sees it. /.rockbox and the scrobbler logs are never
|
||||||
|
deleted wherever you point this.
|
||||||
|
|
||||||
|
The destination must be on a mounted FAT filesystem. That check is also what
|
||||||
|
catches an unmounted device: /media/IPOD/Music then resolves to the host's own
|
||||||
|
root filesystem, and this refuses to empty that.
|
||||||
|
|
||||||
|
Reach the device with the Apple firmware's disk mode: Menu+Select to reboot,
|
||||||
|
then immediately Select+Play. Power off afterwards by holding Play.
|
||||||
USAGE
|
USAGE
|
||||||
exit 2
|
exit "$code"
|
||||||
}
|
}
|
||||||
|
|
||||||
dry_run=false
|
dry_run=false
|
||||||
force=false
|
force=false
|
||||||
unmount=true
|
unmount=true
|
||||||
scrobble=true
|
scrobble=true
|
||||||
|
for argument in "$@"; do
|
||||||
|
[ "$argument" = "--help" ] && usage help
|
||||||
|
done
|
||||||
while getopts ":nfSUh" option; do
|
while getopts ":nfSUh" option; do
|
||||||
case "$option" in
|
case "$option" in
|
||||||
n) dry_run=true ;;
|
n) dry_run=true ;;
|
||||||
f) force=true ;;
|
f) force=true ;;
|
||||||
S) scrobble=false ;;
|
S) scrobble=false ;;
|
||||||
U) unmount=false ;;
|
U) unmount=false ;;
|
||||||
|
h) usage help ;;
|
||||||
*) usage ;;
|
*) usage ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
shift $((OPTIND - 1))
|
shift $((OPTIND - 1))
|
||||||
[ $# -eq 2 ] || usage
|
[ $# -eq 2 ] || usage
|
||||||
|
|
||||||
|
# Trailing slashes are stripped for tidiness, but stripping one from "/" leaves
|
||||||
|
# an empty string, and the guard below would then never see the root it is
|
||||||
|
# there to refuse.
|
||||||
mirror=${1%/}
|
mirror=${1%/}
|
||||||
|
mirror=${mirror:-/}
|
||||||
destination=${2%/}
|
destination=${2%/}
|
||||||
|
destination=${destination:-/}
|
||||||
here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
|
||||||
die() {
|
die() {
|
||||||
printf 'sync-to-ipod: %s\n' "$1" >&2
|
# Every argument, not just the first: the second half of a message is
|
||||||
|
# usually the half that says what to do about it.
|
||||||
|
printf 'sync-to-ipod: %s\n' "$*" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,28 +91,41 @@ die() {
|
|||||||
[ -n "$(ls -A "$mirror")" ] || die "mirror $mirror is empty; refusing to mirror nothing"
|
[ -n "$(ls -A "$mirror")" ] || die "mirror $mirror is empty; refusing to mirror nothing"
|
||||||
[ -d "$destination" ] || die "destination $destination is not a directory"
|
[ -d "$destination" ] || die "destination $destination is not a directory"
|
||||||
|
|
||||||
# --delete makes every one of these load-bearing. A destination that is not its
|
# --delete makes every one of these load-bearing. Emptying the wrong directory
|
||||||
# own mount point means the path is wrong, and emptying the wrong directory is
|
# is not a mistake that announces itself.
|
||||||
# not a mistake that announces itself.
|
|
||||||
case "$destination" in
|
case "$destination" in
|
||||||
"" | "/" | "$HOME") die "refusing to sync onto $destination" ;;
|
"" | "/" | "$HOME") die "refusing to sync onto $destination" ;;
|
||||||
esac
|
esac
|
||||||
[ "$(readlink -f "$mirror")" != "$(readlink -f "$destination")" ] ||
|
[ "$(readlink -f "$mirror")" != "$(readlink -f "$destination")" ] ||
|
||||||
die "mirror and destination are the same directory"
|
die "mirror and destination are the same directory"
|
||||||
mountpoint -q -- "$destination" || die "$destination is not a mount point"
|
|
||||||
|
|
||||||
|
# The filesystem the destination sits on, which is the check that matters: a
|
||||||
|
# subdirectory of the card is a perfectly good target, and is the better one,
|
||||||
|
# because --delete is then confined to it. Being FAT is also what proves the
|
||||||
|
# card is mounted at all -- an unmounted /media/IPOD/Music resolves to the
|
||||||
|
# host's own root filesystem, and this refuses to empty that.
|
||||||
filesystem=$(findmnt -no FSTYPE --target "$destination")
|
filesystem=$(findmnt -no FSTYPE --target "$destination")
|
||||||
|
mounted_on=$(findmnt -no TARGET --target "$destination")
|
||||||
case "$filesystem" in
|
case "$filesystem" in
|
||||||
vfat | exfat) ;;
|
vfat | exfat) ;;
|
||||||
*)
|
*)
|
||||||
$force || die "$destination is $filesystem, not FAT; pass -f if that is deliberate"
|
$force ||
|
||||||
|
die "$destination is on a $filesystem filesystem, not FAT." \
|
||||||
|
"Is the device mounted? Pass -f if this is deliberate."
|
||||||
printf 'sync-to-ipod: destination is %s, not FAT\n' "$filesystem" >&2
|
printf 'sync-to-ipod: destination is %s, not FAT\n' "$filesystem" >&2
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# What the device will call this directory, which is what its path limit
|
||||||
|
# applies to. Derived rather than configured, so it cannot disagree with where
|
||||||
|
# the files are actually going.
|
||||||
|
device_prefix=${destination#"$mounted_on"}
|
||||||
|
device_prefix="/${device_prefix#/}"
|
||||||
|
printf 'sync-to-ipod: the device will see this as %s\n' "$device_prefix" >&2
|
||||||
|
|
||||||
if $force; then
|
if $force; then
|
||||||
printf 'sync-to-ipod: skipping the FAT32 check\n' >&2
|
printf 'sync-to-ipod: skipping the FAT32 check\n' >&2
|
||||||
elif ! python3 "$here/check_fat32.py" "$mirror"; then
|
elif ! python3 "$here/check_fat32.py" --device-prefix "$device_prefix" "$mirror"; then
|
||||||
die "the mirror holds paths FAT32 will not take; run music-mirror with --fat32-safe"
|
die "the mirror holds paths FAT32 will not take; run music-mirror with --fat32-safe"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user