116 lines
3.9 KiB
Python
116 lines
3.9 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""Report tracks in a Music library whose files are no longer on disk.
|
||
|
|
|
||
|
|
Reads the XML from Music's File > Library > Export Library. Asking Music itself
|
||
|
|
does not work: a broken track makes AppleScript's `location` raise rather than
|
||
|
|
return a value, so a bulk query dies on the first one with error -1728, and a
|
||
|
|
per-track loop costs an Apple event apiece.
|
||
|
|
|
||
|
|
The library is checked against a single directory walk rather than by testing
|
||
|
|
each file. Over SMB a per-file test is one network round trip per track --
|
||
|
|
fifty thousand of them -- where a walk reads each directory once and gets every
|
||
|
|
name in it back at once.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import os
|
||
|
|
import plistlib
|
||
|
|
import sys
|
||
|
|
import unicodedata
|
||
|
|
import urllib.parse
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
|
||
|
|
def location_of(track):
|
||
|
|
"""Return the filesystem path a track points at, or None if it has none."""
|
||
|
|
location = track.get("Location")
|
||
|
|
if not location or not location.startswith("file://"):
|
||
|
|
return None
|
||
|
|
return Path(urllib.parse.unquote(urllib.parse.urlparse(location).path))
|
||
|
|
|
||
|
|
|
||
|
|
def key_for(path):
|
||
|
|
"""Return a comparison key for a path.
|
||
|
|
|
||
|
|
Normalised to NFC because macOS stores filenames decomposed and most
|
||
|
|
everything else composes them, so "Motley Crue" with its umlauts is two
|
||
|
|
different byte strings depending on which side wrote it. Casefolded because
|
||
|
|
the share is very likely case-insensitive and a file is not missing merely
|
||
|
|
because someone capitalised it differently.
|
||
|
|
"""
|
||
|
|
return unicodedata.normalize("NFC", str(path)).casefold()
|
||
|
|
|
||
|
|
|
||
|
|
def existing_under(roots):
|
||
|
|
"""Return every file below the given roots, keyed for comparison."""
|
||
|
|
found = set()
|
||
|
|
for root in roots:
|
||
|
|
for base, _, names in os.walk(root):
|
||
|
|
for name in names:
|
||
|
|
found.add(key_for(os.path.join(base, name)))
|
||
|
|
return found
|
||
|
|
|
||
|
|
|
||
|
|
def roots_of(paths, given):
|
||
|
|
"""Return the directories worth walking."""
|
||
|
|
if given:
|
||
|
|
return [Path(root) for root in given]
|
||
|
|
try:
|
||
|
|
return [Path(os.path.commonpath([str(path) for path in paths]))]
|
||
|
|
except ValueError:
|
||
|
|
# Tracks spread across separate volumes have no common parent.
|
||
|
|
return sorted({path.parents[-2] for path in paths if len(path.parents) > 1})
|
||
|
|
|
||
|
|
|
||
|
|
def main(argv=None):
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
||
|
|
parser.add_argument("library", help="Library.xml exported from Music")
|
||
|
|
parser.add_argument("--root", action="append", help="directory to scan; repeatable")
|
||
|
|
parser.add_argument("--limit", type=int, default=0, help="show at most this many")
|
||
|
|
args = parser.parse_args(argv)
|
||
|
|
|
||
|
|
with open(args.library, "rb") as handle:
|
||
|
|
library = plistlib.load(handle)
|
||
|
|
|
||
|
|
tracks = [
|
||
|
|
(track, location_of(track)) for track in library.get("Tracks", {}).values()
|
||
|
|
]
|
||
|
|
local = [(track, path) for track, path in tracks if path is not None]
|
||
|
|
if not local:
|
||
|
|
print("no local files in this library", file=sys.stderr)
|
||
|
|
return 0
|
||
|
|
|
||
|
|
roots = roots_of([path for _, path in local], args.root)
|
||
|
|
print(f"scanning {', '.join(str(root) for root in roots)}", file=sys.stderr)
|
||
|
|
present = existing_under(roots)
|
||
|
|
|
||
|
|
gone = [(track, path) for track, path in local if key_for(path) not in present]
|
||
|
|
for track, path in gone[: args.limit or None]:
|
||
|
|
print(
|
||
|
|
"\t".join(
|
||
|
|
(
|
||
|
|
track.get("Artist", ""),
|
||
|
|
track.get("Album", ""),
|
||
|
|
track.get("Name", ""),
|
||
|
|
str(path),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
print(
|
||
|
|
f"\n{len(gone)} of {len(local)} local tracks are missing their file"
|
||
|
|
f" ({len(tracks) - len(local)} have no file at all)",
|
||
|
|
file=sys.stderr,
|
||
|
|
)
|
||
|
|
if gone and len(gone) == len(local):
|
||
|
|
print(
|
||
|
|
"Every single one is missing, which almost certainly means the share"
|
||
|
|
" is not mounted rather than that the library is empty.",
|
||
|
|
file=sys.stderr,
|
||
|
|
)
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|