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.
 
 
Music/scripts/test_backfill_itunes_dates.py

178 lines
8.1 KiB

#!/usr/bin/env python3
"""Tests for backfill_itunes_dates. Run: python3 -m unittest test_backfill_itunes_dates"""
import io
import os
import plistlib
import sqlite3
import sys
import tempfile
import unittest
from contextlib import redirect_stdout
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import backfill_itunes_dates as bf # noqa: E402
# The three real file paths from the dev DB. The Kevin track exercises spaces (%20),
# a literal apostrophe, and parentheses; the others share a directory.
KEVIN = ("file:///Users/laurentmorvillier/Music/Music/Media.localized/Music/"
"Kevin%20Saunderson%20as%20E-Dancer/GlobalDJMix.com/"
"Radio%201's%20Essential%20Mix%20(2025-02-08).mp3")
T1130 = ("file:///Users/laurentmorvillier/Music/Music/Media.localized/Music/"
"Unknown%20Artist/Unknown%20Album/1130.mp3")
T1486 = ("file:///Users/laurentmorvillier/Music/Music/Media.localized/Music/"
"Unknown%20Artist/Unknown%20Album/1486.mp3")
class NormPathTests(unittest.TestCase):
# Step: an NFD-encoded source and an NFC-encoded source for the same accented
# filename must normalize to the identical path, regardless of file:// host form.
def test_nfc_nfd_and_host_form_converge(self):
nfd = bf.norm_path("file:///Users/x/Mu%CC%81sica/Cafe%CC%81.mp3")
nfc = bf.norm_path("file://localhost/Users/x/M%C3%BAsica/Caf%C3%A9.mp3")
self.assertEqual(nfd, "/Users/x/Música/Café.mp3")
self.assertEqual(nfd, nfc)
# Step: percent-encoded space and hash decode to literal characters.
def test_special_chars_decoded(self):
self.assertEqual(bf.norm_path("file:///a/b%20c%23d.mp3"), "/a/b c#d.mp3")
# Step: a trailing slash is stripped so dir-ish strings compare cleanly.
def test_trailing_slash_stripped(self):
self.assertEqual(bf.norm_path("file:///a/b/"), "/a/b")
# Step: the real Kevin path with apostrophe + parens round-trips to a clean path.
def test_real_kevin_path(self):
self.assertTrue(
bf.norm_path(KEVIN).endswith("/Radio 1's Essential Mix (2025-02-08).mp3"))
class BuildUpdatesTests(unittest.TestCase):
# Step: a matched track gets dateAdded reformatted to GRDB shape, rating mapped
# 0-100 -> 0-5, playCount copied, and lastPlayedAt formatted; an unmatched DB
# track is reported separately and produces no update.
def test_mapping_and_unmatched(self):
music = {bf.norm_path("file:///a/song.mp3"): {
"date_added": datetime(2021, 3, 14, 9, 26, 53),
"play_count": 7, "rating": 80,
"play_date_utc": datetime(2024, 1, 2, 3, 4, 5),
}}
rows = [(1, "file:///a/song.mp3", "2026-05-24 06:46:01.713"),
(2, "file:///a/missing.mp3", "2026-05-24 06:46:01.999")]
updates, unmatched = bf.build_updates(rows, music)
self.assertEqual([u["id"] for u in updates], [1])
self.assertEqual([r[0] for r in unmatched], [2])
u = updates[0]
self.assertEqual(u["dateAdded"], "2021-03-14 09:26:53.000")
self.assertEqual(u["playCount"], 7)
self.assertEqual(u["rating"], 4)
self.assertEqual(u["lastPlayedAt"], "2024-01-02 03:04:05.000")
# Step: missing optional fields default safely — playCount 0, rating 0,
# lastPlayedAt None — while dateAdded still applies.
def test_absent_optionals(self):
music = {bf.norm_path("file:///a/x.mp3"): {
"date_added": datetime(2020, 1, 1, 0, 0, 0),
"play_count": None, "rating": None, "play_date_utc": None,
}}
updates, _ = bf.build_updates([(1, "file:///a/x.mp3", "old")], music)
u = updates[0]
self.assertEqual(u["playCount"], 0)
self.assertEqual(u["rating"], 0)
self.assertIsNone(u["lastPlayedAt"])
# Step: if the export lacks Date Added, keep the existing value (column is NOT NULL).
def test_missing_date_keeps_existing(self):
music = {bf.norm_path("file:///a/x.mp3"): {
"date_added": None, "play_count": 1, "rating": 0, "play_date_utc": None}}
updates, _ = bf.build_updates([(1, "file:///a/x.mp3", "2026-05-24 06:46:01.713")], music)
self.assertEqual(updates[0]["dateAdded"], "2026-05-24 06:46:01.713")
class IntegrationTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.db = os.path.join(self.tmp, "db.sqlite")
self.xml = os.path.join(self.tmp, "Library.xml")
# Step: build a temp DB mirroring the real tracks columns the script touches,
# seeded with the three real paths carrying placeholder scan dates.
con = sqlite3.connect(self.db)
con.execute(
"CREATE TABLE tracks ("
" id INTEGER PRIMARY KEY,"
" fileURL TEXT NOT NULL,"
" dateAdded TEXT NOT NULL,"
" playCount INTEGER NOT NULL DEFAULT 0,"
" rating INTEGER NOT NULL DEFAULT 0,"
" lastPlayedAt TEXT)")
con.executemany(
"INSERT INTO tracks (id, fileURL, dateAdded, playCount, rating) VALUES (?,?,?,0,0)",
[(1, KEVIN, "2026-05-24 06:46:01.713"),
(2, T1130, "2026-05-24 06:46:01.715"),
(3, T1486, "2026-05-24 06:46:01.718")])
con.commit()
con.close()
# Step: write a synthetic library export. Kevin is matched via the localhost
# host form (proving normalization), 1130 matched with no optional stats, 1486
# deliberately omitted (stays unmatched), plus a streaming entry with no
# Location (must be skipped) and an XML-only local file (unmatched-in-DB).
kevin_localhost = KEVIN.replace("file:///", "file://localhost/")
plist = {"Tracks": {
"10": {"Location": kevin_localhost,
"Date Added": datetime(2025, 2, 9, 12, 0, 0),
"Play Count": 5, "Rating": 100,
"Play Date UTC": datetime(2025, 3, 1, 8, 0, 0)},
"11": {"Location": T1130,
"Date Added": datetime(2024, 11, 30, 10, 0, 0)},
"12": {"Date Added": datetime(2023, 1, 1, 0, 0, 0)}, # streaming, no Location
"13": {"Location": "file:///Users/x/only-in-xml.mp3",
"Date Added": datetime(2022, 6, 6, 6, 6, 6)},
}}
with open(self.xml, "wb") as fp:
plistlib.dump(plist, fp)
def tearDown(self):
import shutil
shutil.rmtree(self.tmp, ignore_errors=True)
# Step: a full --apply run rewrites the two matched rows with Music.app's values
# (Kevin: 5 plays / 5 stars / real dates; 1130: date only), leaves the unmatched
# 1486 row untouched, and ignores the streaming + xml-only entries.
def test_apply_end_to_end(self):
with redirect_stdout(io.StringIO()):
bf.run(self.xml, self.db, apply=True)
con = sqlite3.connect(self.db)
rows = {r[0]: r for r in con.execute(
"SELECT id, dateAdded, playCount, rating, lastPlayedAt FROM tracks")}
con.close()
self.assertEqual(rows[1], (1, "2025-02-09 12:00:00.000", 5, 5, "2025-03-01 08:00:00.000"))
self.assertEqual(rows[2], (2, "2024-11-30 10:00:00.000", 0, 0, None))
self.assertEqual(rows[3], (3, "2026-05-24 06:46:01.718", 0, 0, None)) # untouched
# Step: a backup directory containing the db copy is created on apply.
def test_apply_makes_backup(self):
with redirect_stdout(io.StringIO()):
bf.run(self.xml, self.db, apply=True)
backups_root = os.path.join(self.tmp, "backups")
self.assertTrue(os.path.isdir(backups_root))
stamps = os.listdir(backups_root)
self.assertEqual(len(stamps), 1)
self.assertIn("db.sqlite", os.listdir(os.path.join(backups_root, stamps[0])))
# Step: a dry run reports matches but writes nothing to the DB.
def test_dry_run_writes_nothing(self):
with redirect_stdout(io.StringIO()):
bf.run(self.xml, self.db, apply=False)
con = sqlite3.connect(self.db)
unchanged = con.execute("SELECT dateAdded FROM tracks WHERE id=1").fetchone()[0]
con.close()
self.assertEqual(unchanged, "2026-05-24 06:46:01.713")
if __name__ == "__main__":
unittest.main()