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.
178 lines
8.1 KiB
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()
|
|
|