#!/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()