refactor: change cache schema to slots based
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "lrx-cli"
|
name = "lrx-cli"
|
||||||
version = "0.6.0"
|
version = "0.6.1"
|
||||||
description = "Fetch line-synced lyrics for your music player."
|
description = "Fetch line-synced lyrics for your music player."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|||||||
+218
-97
@@ -1,8 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Author: Uyanide pywang0608@foxmail.com
|
Author: Uyanide pywang0608@foxmail.com
|
||||||
Date: 2026-03-25 10:18:03
|
Date: 2026-03-25 10:18:03
|
||||||
Description: SQLite-based lyric cache with per-source storage, TTL expiration,
|
Description: SQLite-based lyric cache with per-source slot rows, TTL expiration,
|
||||||
and lightweight schema migrations (including confidence versioning).
|
and schema migrations (confidence versioning + slot migration).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -14,8 +14,18 @@ from loguru import logger
|
|||||||
|
|
||||||
from .lrc import LRCData
|
from .lrc import LRCData
|
||||||
from .normalize import normalize_for_match as _normalize_for_match
|
from .normalize import normalize_for_match as _normalize_for_match
|
||||||
from .config import DURATION_TOLERANCE_MS, LEGACY_CONFIDENCE, CONFIDENCE_ALGO_VERSION
|
from .config import (
|
||||||
|
DURATION_TOLERANCE_MS,
|
||||||
|
LEGACY_CONFIDENCE,
|
||||||
|
CONFIDENCE_ALGO_VERSION,
|
||||||
|
SLOT_SYNCED,
|
||||||
|
SLOT_UNSYNCED,
|
||||||
|
)
|
||||||
from .models import TrackMeta, LyricResult, CacheStatus
|
from .models import TrackMeta, LyricResult, CacheStatus
|
||||||
|
from .ranking import is_positive_status, select_best_positive
|
||||||
|
|
||||||
|
|
||||||
|
_ALL_SLOTS = (SLOT_SYNCED, SLOT_UNSYNCED)
|
||||||
|
|
||||||
|
|
||||||
# Fixed WHERE clause for exact track matching. Column names are hardcoded
|
# Fixed WHERE clause for exact track matching. Column names are hardcoded
|
||||||
@@ -76,18 +86,65 @@ class CacheEngine:
|
|||||||
self._init_db()
|
self._init_db()
|
||||||
|
|
||||||
def _init_db(self) -> None:
|
def _init_db(self) -> None:
|
||||||
"""Create or migrate cache schema and credentials table.
|
"""Create cache tables and run one-time slot/cache migrations."""
|
||||||
|
|
||||||
Migration notes:
|
|
||||||
- Add structural columns introduced after initial releases.
|
|
||||||
- When introducing confidence versioning, rebalance legacy unsynced
|
|
||||||
confidence (+10, capped at 100) and stamp migrated rows with the
|
|
||||||
current algorithm version.
|
|
||||||
"""
|
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS credentials (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
expires_at INTEGER
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cache_exists = conn.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='cache'"
|
||||||
|
).fetchone()
|
||||||
|
if not cache_exists:
|
||||||
|
self._create_cache_table(conn)
|
||||||
|
conn.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(cache)").fetchall()}
|
||||||
|
|
||||||
|
if "positive_kind" not in cols:
|
||||||
|
# Normalize legacy shape first so migration SQL can safely read all columns.
|
||||||
|
if "length" not in cols:
|
||||||
|
conn.execute("ALTER TABLE cache ADD COLUMN length INTEGER")
|
||||||
|
if "confidence" not in cols:
|
||||||
|
conn.execute("ALTER TABLE cache ADD COLUMN confidence REAL")
|
||||||
|
if "confidence_version" not in cols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE cache ADD COLUMN confidence_version INTEGER"
|
||||||
|
)
|
||||||
|
self._migrate_legacy_to_slot_cache(conn)
|
||||||
|
cols = {
|
||||||
|
r[1] for r in conn.execute("PRAGMA table_info(cache)").fetchall()
|
||||||
|
}
|
||||||
|
|
||||||
|
if "confidence_version" not in cols:
|
||||||
|
conn.execute("ALTER TABLE cache ADD COLUMN confidence_version INTEGER")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE cache
|
||||||
|
SET confidence = MIN(100.0, COALESCE(confidence, ?) + 10.0)
|
||||||
|
WHERE status = ? AND positive_kind = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
LEGACY_CONFIDENCE,
|
||||||
|
CacheStatus.SUCCESS_UNSYNCED.value,
|
||||||
|
SLOT_UNSYNCED,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE cache SET confidence_version = ? WHERE confidence_version IS NULL",
|
||||||
|
(CONFIDENCE_ALGO_VERSION,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def _create_cache_table(self, conn: sqlite3.Connection) -> None:
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS cache (
|
CREATE TABLE IF NOT EXISTS cache (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT NOT NULL,
|
||||||
|
positive_kind TEXT NOT NULL,
|
||||||
source TEXT NOT NULL,
|
source TEXT NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
lyrics TEXT,
|
lyrics TEXT,
|
||||||
@@ -98,126 +155,167 @@ class CacheEngine:
|
|||||||
album TEXT,
|
album TEXT,
|
||||||
length INTEGER,
|
length INTEGER,
|
||||||
confidence REAL,
|
confidence REAL,
|
||||||
confidence_version INTEGER
|
confidence_version INTEGER,
|
||||||
|
PRIMARY KEY (key, positive_kind)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS credentials (
|
def _migrate_legacy_to_slot_cache(self, conn: sqlite3.Connection) -> None:
|
||||||
name TEXT PRIMARY KEY,
|
"""One-time migration from single-row cache to slot-scoped cache rows."""
|
||||||
data TEXT NOT NULL,
|
conn.execute("ALTER TABLE cache RENAME TO cache_legacy")
|
||||||
expires_at INTEGER
|
self._create_cache_table(conn)
|
||||||
|
|
||||||
|
positive_statuses = (
|
||||||
|
CacheStatus.SUCCESS_SYNCED.value,
|
||||||
|
CacheStatus.SUCCESS_UNSYNCED.value,
|
||||||
)
|
)
|
||||||
""")
|
negative_statuses = (
|
||||||
# Incremental, idempotent migrations for existing databases.
|
CacheStatus.NOT_FOUND.value,
|
||||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(cache)").fetchall()}
|
CacheStatus.NETWORK_ERROR.value,
|
||||||
if "length" not in cols:
|
)
|
||||||
conn.execute("ALTER TABLE cache ADD COLUMN length INTEGER")
|
|
||||||
if "confidence" not in cols:
|
|
||||||
conn.execute("ALTER TABLE cache ADD COLUMN confidence REAL")
|
|
||||||
if "confidence_version" not in cols:
|
|
||||||
conn.execute("ALTER TABLE cache ADD COLUMN confidence_version INTEGER")
|
|
||||||
# First-time confidence-version migration: boost unsynced rows
|
|
||||||
# from older scoring assumptions while preserving upper bound.
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE cache
|
INSERT INTO cache (
|
||||||
SET confidence = MIN(100.0, COALESCE(confidence, ?) + 10.0)
|
key, positive_kind, source, status, lyrics, created_at, expires_at,
|
||||||
WHERE status = ?
|
artist, title, album, length, confidence, confidence_version
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
key,
|
||||||
|
CASE
|
||||||
|
WHEN status = ? THEN ?
|
||||||
|
WHEN status = ? THEN ?
|
||||||
|
ELSE ?
|
||||||
|
END,
|
||||||
|
source, status, lyrics, created_at, expires_at, artist, title, album, length,
|
||||||
|
CASE
|
||||||
|
WHEN status = ? THEN MIN(100.0, COALESCE(confidence, ?) + 10.0)
|
||||||
|
WHEN status = ? THEN COALESCE(confidence, ?)
|
||||||
|
ELSE COALESCE(confidence, 0.0)
|
||||||
|
END,
|
||||||
|
COALESCE(confidence_version, ?)
|
||||||
|
FROM cache_legacy
|
||||||
|
WHERE status IN (?, ?)
|
||||||
""",
|
""",
|
||||||
(LEGACY_CONFIDENCE, CacheStatus.SUCCESS_UNSYNCED.value),
|
(
|
||||||
|
CacheStatus.SUCCESS_SYNCED.value,
|
||||||
|
SLOT_SYNCED,
|
||||||
|
CacheStatus.SUCCESS_UNSYNCED.value,
|
||||||
|
SLOT_UNSYNCED,
|
||||||
|
SLOT_SYNCED,
|
||||||
|
CacheStatus.SUCCESS_UNSYNCED.value,
|
||||||
|
LEGACY_CONFIDENCE,
|
||||||
|
CacheStatus.SUCCESS_SYNCED.value,
|
||||||
|
LEGACY_CONFIDENCE,
|
||||||
|
CONFIDENCE_ALGO_VERSION,
|
||||||
|
positive_statuses[0],
|
||||||
|
positive_statuses[1],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for slot in _ALL_SLOTS:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE cache SET confidence_version = ? WHERE confidence_version IS NULL",
|
"""
|
||||||
(CONFIDENCE_ALGO_VERSION,),
|
INSERT INTO cache (
|
||||||
|
key, positive_kind, source, status, lyrics, created_at, expires_at,
|
||||||
|
artist, title, album, length, confidence, confidence_version
|
||||||
)
|
)
|
||||||
conn.commit()
|
SELECT
|
||||||
|
key, ?, source, status, lyrics, created_at, expires_at, artist, title,
|
||||||
|
album, length,
|
||||||
|
COALESCE(confidence, 0.0),
|
||||||
|
COALESCE(confidence_version, ?)
|
||||||
|
FROM cache_legacy
|
||||||
|
WHERE status IN (?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
slot,
|
||||||
|
CONFIDENCE_ALGO_VERSION,
|
||||||
|
negative_statuses[0],
|
||||||
|
negative_statuses[1],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute("DROP TABLE cache_legacy")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _slot_for_status(status: CacheStatus) -> str:
|
||||||
|
if status == CacheStatus.SUCCESS_SYNCED:
|
||||||
|
return SLOT_SYNCED
|
||||||
|
if status == CacheStatus.SUCCESS_UNSYNCED:
|
||||||
|
return SLOT_UNSYNCED
|
||||||
|
raise ValueError(f"Status {status.value} requires explicit slot")
|
||||||
|
|
||||||
# Read
|
# Read
|
||||||
|
|
||||||
def get(self, track: TrackMeta, source: str) -> Optional[LyricResult]:
|
def get_all(self, track: TrackMeta, source: str) -> list[LyricResult]:
|
||||||
"""Look up a cached result for *track* from *source*.
|
"""Return all non-expired cached slot rows for *track*/*source*."""
|
||||||
|
|
||||||
Returns None on cache miss or expiration.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
key = _generate_key(track, source)
|
key = _generate_key(track, source)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return []
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
row = conn.execute(
|
|
||||||
"SELECT status, lyrics, source, expires_at, length, confidence FROM cache WHERE key = ?",
|
|
||||||
(key,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
logger.debug(f"Cache miss: {source} / {track.display_name()}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
status_str, lyrics, src, expires_at, cached_length, confidence = row
|
|
||||||
|
|
||||||
# Check TTL expiration
|
|
||||||
if expires_at and expires_at < int(time.time()):
|
|
||||||
logger.debug(f"Cache expired: {source} / {track.display_name()}")
|
|
||||||
conn.execute("DELETE FROM cache WHERE key = ?", (key,))
|
|
||||||
conn.commit()
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Backfill length if the cached row is missing it
|
|
||||||
if cached_length is None and track.length is not None:
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE cache SET length = ? WHERE key = ?",
|
"DELETE FROM cache WHERE key = ? AND expires_at IS NOT NULL AND expires_at < ?",
|
||||||
|
(key, now),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT status, lyrics, source, expires_at, length, confidence
|
||||||
|
FROM cache
|
||||||
|
WHERE key = ? AND (expires_at IS NULL OR expires_at > ?)
|
||||||
|
ORDER BY positive_kind
|
||||||
|
""",
|
||||||
|
(key, now),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
logger.debug(f"Cache miss: {source} / {track.display_name()}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Backfill missing length for all slot rows under the same key.
|
||||||
|
if track.length is not None:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE cache SET length = ? WHERE key = ? AND length IS NULL",
|
||||||
(track.length, key),
|
(track.length, key),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
remaining = expires_at - int(time.time()) if expires_at else None
|
results: list[LyricResult] = []
|
||||||
logger.debug(
|
for status_str, lyrics, src, expires_at, _cached_length, confidence in rows:
|
||||||
f"Cache hit: {source} / {track.display_name()} "
|
remaining = expires_at - now if expires_at else None
|
||||||
f"[{status_str}, ttl={remaining}s]"
|
|
||||||
)
|
|
||||||
status = CacheStatus(status_str)
|
status = CacheStatus(status_str)
|
||||||
if confidence is None:
|
if confidence is None:
|
||||||
if status in (CacheStatus.SUCCESS_SYNCED, CacheStatus.SUCCESS_UNSYNCED):
|
if is_positive_status(status):
|
||||||
confidence = LEGACY_CONFIDENCE
|
confidence = LEGACY_CONFIDENCE
|
||||||
else:
|
else:
|
||||||
confidence = 0.0 # negative statuses: no confidence
|
confidence = 0.0
|
||||||
|
results.append(
|
||||||
return LyricResult(
|
LyricResult(
|
||||||
status=status,
|
status=status,
|
||||||
lyrics=LRCData(lyrics) if lyrics else None,
|
lyrics=LRCData(lyrics) if lyrics else None,
|
||||||
source=src,
|
source=src,
|
||||||
ttl=remaining,
|
ttl=remaining,
|
||||||
confidence=confidence,
|
confidence=confidence,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
def get_best(self, track: TrackMeta, sources: list[str]) -> Optional[LyricResult]:
|
def get_best(self, track: TrackMeta, sources: list[str]) -> Optional[LyricResult]:
|
||||||
"""Return the best cached result across *sources* by confidence.
|
"""Return best positive cached result across sources.
|
||||||
|
|
||||||
Skips negative statuses (NOT_FOUND, NETWORK_ERROR) — those are only
|
Negative statuses are ignored by ranking.
|
||||||
consulted per-source to avoid redundant fetches.
|
|
||||||
"""
|
"""
|
||||||
best: Optional[LyricResult] = None
|
positives: list[LyricResult] = []
|
||||||
for src in sources:
|
for src in sources:
|
||||||
cached = self.get(track, src)
|
rows = self.get_all(track, src)
|
||||||
if not cached:
|
positives.extend(r for r in rows if is_positive_status(r.status))
|
||||||
continue
|
|
||||||
if cached.status not in (
|
return select_best_positive(positives, allow_unsynced=True)
|
||||||
CacheStatus.SUCCESS_SYNCED,
|
|
||||||
CacheStatus.SUCCESS_UNSYNCED,
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
if best is None:
|
|
||||||
best = cached
|
|
||||||
elif cached.confidence > best.confidence:
|
|
||||||
best = cached
|
|
||||||
elif (
|
|
||||||
cached.confidence == best.confidence
|
|
||||||
and cached.status == CacheStatus.SUCCESS_SYNCED
|
|
||||||
and best.status != CacheStatus.SUCCESS_SYNCED
|
|
||||||
):
|
|
||||||
best = cached
|
|
||||||
return best
|
|
||||||
|
|
||||||
# Write
|
# Write
|
||||||
|
|
||||||
@@ -227,6 +325,7 @@ class CacheEngine:
|
|||||||
source: str,
|
source: str,
|
||||||
result: LyricResult,
|
result: LyricResult,
|
||||||
ttl_seconds: Optional[int] = None,
|
ttl_seconds: Optional[int] = None,
|
||||||
|
positive_kind: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Store a lyric result in the cache.
|
"""Store a lyric result in the cache.
|
||||||
|
|
||||||
@@ -242,14 +341,28 @@ class CacheEngine:
|
|||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
expires_at = now + ttl_seconds if ttl_seconds else None
|
expires_at = now + ttl_seconds if ttl_seconds else None
|
||||||
|
|
||||||
|
kinds: list[str]
|
||||||
|
if positive_kind is not None:
|
||||||
|
kinds = [positive_kind]
|
||||||
|
elif result.status in (
|
||||||
|
CacheStatus.SUCCESS_SYNCED,
|
||||||
|
CacheStatus.SUCCESS_UNSYNCED,
|
||||||
|
):
|
||||||
|
kinds = [self._slot_for_status(result.status)]
|
||||||
|
else:
|
||||||
|
# Convenience for callers that still pass a single negative result.
|
||||||
|
kinds = [SLOT_SYNCED, SLOT_UNSYNCED]
|
||||||
|
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
for kind in kinds:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT OR REPLACE INTO cache
|
"""INSERT OR REPLACE INTO cache
|
||||||
(key, source, status, lyrics, created_at, expires_at,
|
(key, positive_kind, source, status, lyrics, created_at, expires_at,
|
||||||
artist, title, album, length, confidence, confidence_version)
|
artist, title, album, length, confidence, confidence_version)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
key,
|
key,
|
||||||
|
kind,
|
||||||
source,
|
source,
|
||||||
result.status.value,
|
result.status.value,
|
||||||
str(result.lyrics) if result.lyrics else None,
|
str(result.lyrics) if result.lyrics else None,
|
||||||
@@ -332,6 +445,7 @@ class CacheEngine:
|
|||||||
f"SELECT status, lyrics, source, confidence FROM cache"
|
f"SELECT status, lyrics, source, confidence FROM cache"
|
||||||
f" WHERE {_TRACK_WHERE}"
|
f" WHERE {_TRACK_WHERE}"
|
||||||
" AND status = ?"
|
" AND status = ?"
|
||||||
|
" AND positive_kind = ?"
|
||||||
" AND (expires_at IS NULL OR expires_at > ?)"
|
" AND (expires_at IS NULL OR expires_at > ?)"
|
||||||
" ORDER BY COALESCE(confidence, ?) DESC,"
|
" ORDER BY COALESCE(confidence, ?) DESC,"
|
||||||
" CASE status WHEN ? THEN 0 ELSE 1 END,"
|
" CASE status WHEN ? THEN 0 ELSE 1 END,"
|
||||||
@@ -339,6 +453,7 @@ class CacheEngine:
|
|||||||
_track_where_params(track)
|
_track_where_params(track)
|
||||||
+ [
|
+ [
|
||||||
status.value,
|
status.value,
|
||||||
|
self._slot_for_status(status),
|
||||||
now,
|
now,
|
||||||
LEGACY_CONFIDENCE,
|
LEGACY_CONFIDENCE,
|
||||||
CacheStatus.SUCCESS_SYNCED.value,
|
CacheStatus.SUCCESS_SYNCED.value,
|
||||||
@@ -520,6 +635,11 @@ class CacheEngine:
|
|||||||
"SELECT source, COUNT(*) FROM cache GROUP BY source"
|
"SELECT source, COUNT(*) FROM cache GROUP BY source"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
)
|
)
|
||||||
|
by_slot = dict(
|
||||||
|
conn.execute(
|
||||||
|
"SELECT positive_kind, COUNT(*) FROM cache GROUP BY positive_kind"
|
||||||
|
).fetchall()
|
||||||
|
)
|
||||||
# Source × Status cross-tabulation
|
# Source × Status cross-tabulation
|
||||||
source_status = conn.execute(
|
source_status = conn.execute(
|
||||||
"SELECT source, status, COUNT(*) FROM cache GROUP BY source, status"
|
"SELECT source, status, COUNT(*) FROM cache GROUP BY source, status"
|
||||||
@@ -567,6 +687,7 @@ class CacheEngine:
|
|||||||
"active": total - expired,
|
"active": total - expired,
|
||||||
"by_status": by_status,
|
"by_status": by_status,
|
||||||
"by_source": by_source,
|
"by_source": by_source,
|
||||||
|
"by_slot": by_slot,
|
||||||
"source_status": source_status_table,
|
"source_status": source_status_table,
|
||||||
"confidence_buckets": buckets,
|
"confidence_buckets": buckets,
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -379,6 +379,13 @@ def stats():
|
|||||||
print(f"Active : {s['active']}")
|
print(f"Active : {s['active']}")
|
||||||
print(f"Expired : {s['expired']}")
|
print(f"Expired : {s['expired']}")
|
||||||
|
|
||||||
|
by_slot = s.get("by_slot", {})
|
||||||
|
if by_slot:
|
||||||
|
print(
|
||||||
|
"Slots : "
|
||||||
|
+ ", ".join(f"{k}={v}" for k, v in sorted(by_slot.items()))
|
||||||
|
)
|
||||||
|
|
||||||
# Source × Status table
|
# Source × Status table
|
||||||
table = s.get("source_status", {})
|
table = s.get("source_status", {})
|
||||||
if table:
|
if table:
|
||||||
@@ -509,6 +516,7 @@ def _print_cache_row(row: dict, indent: str = "") -> None:
|
|||||||
"""Pretty-print a single cache row."""
|
"""Pretty-print a single cache row."""
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
source = row.get("source", "?")
|
source = row.get("source", "?")
|
||||||
|
slot = row.get("positive_kind", "?")
|
||||||
status = row.get("status", "?")
|
status = row.get("status", "?")
|
||||||
artist = row.get("artist", "")
|
artist = row.get("artist", "")
|
||||||
title = row.get("title", "")
|
title = row.get("title", "")
|
||||||
@@ -519,7 +527,7 @@ def _print_cache_row(row: dict, indent: str = "") -> None:
|
|||||||
confidence = row.get("confidence")
|
confidence = row.get("confidence")
|
||||||
|
|
||||||
name = f"{artist} - {title}" if artist and title else row.get("key", "?")
|
name = f"{artist} - {title}" if artist and title else row.get("key", "?")
|
||||||
print(f"{indent}[{source}] {name}")
|
print(f"{indent}[{source}/{slot}] {name}")
|
||||||
if album:
|
if album:
|
||||||
print(f"{indent} Album : {album}")
|
print(f"{indent} Album : {album}")
|
||||||
print(f"{indent} Status : {status}")
|
print(f"{indent} Status : {status}")
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ APP_VERSION = version(APP_NAME)
|
|||||||
# Paths
|
# Paths
|
||||||
CACHE_DIR = user_cache_dir(APP_NAME, APP_AUTHOR)
|
CACHE_DIR = user_cache_dir(APP_NAME, APP_AUTHOR)
|
||||||
DB_PATH = os.path.join(CACHE_DIR, "cache.db")
|
DB_PATH = os.path.join(CACHE_DIR, "cache.db")
|
||||||
|
# Slot identifiers used by per-slot cache rows.
|
||||||
|
SLOT_SYNCED = "SYNCED"
|
||||||
|
SLOT_UNSYNCED = "UNSYNCED"
|
||||||
|
|
||||||
# .env loading
|
# .env loading
|
||||||
_config_env = Path(user_config_dir(APP_NAME, APP_AUTHOR)) / ".env"
|
_config_env = Path(user_config_dir(APP_NAME, APP_AUTHOR)) / ".env"
|
||||||
|
|||||||
+69
-92
@@ -20,9 +20,12 @@ from .config import (
|
|||||||
TTL_NOT_FOUND,
|
TTL_NOT_FOUND,
|
||||||
TTL_NETWORK_ERROR,
|
TTL_NETWORK_ERROR,
|
||||||
HIGH_CONFIDENCE,
|
HIGH_CONFIDENCE,
|
||||||
|
SLOT_SYNCED,
|
||||||
|
SLOT_UNSYNCED,
|
||||||
)
|
)
|
||||||
from .models import TrackMeta, LyricResult, CacheStatus
|
from .models import TrackMeta, LyricResult, CacheStatus
|
||||||
from .enrichers import create_enrichers, enrich_track
|
from .enrichers import create_enrichers, enrich_track
|
||||||
|
from .ranking import is_better_result, select_best_positive
|
||||||
|
|
||||||
|
|
||||||
# Maps CacheStatus to the default TTL used when storing results
|
# Maps CacheStatus to the default TTL used when storing results
|
||||||
@@ -34,44 +37,11 @@ _STATUS_TTL: dict[CacheStatus, Optional[int]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _is_better(new: LyricResult, old: LyricResult, allow_unsynced: bool) -> bool:
|
|
||||||
"""Compare two results: higher confidence wins; if equal, synced > unsynced.
|
|
||||||
If allow_unsynced is False, treat unsynced as strictly worse than any synced."""
|
|
||||||
|
|
||||||
# If new is negative, it's definitely not better
|
|
||||||
if new.status not in (CacheStatus.SUCCESS_SYNCED, CacheStatus.SUCCESS_UNSYNCED):
|
|
||||||
return False
|
|
||||||
# If old is negative, the result is better or equal regardless of other factors
|
|
||||||
if old.status not in (CacheStatus.SUCCESS_SYNCED, CacheStatus.SUCCESS_UNSYNCED):
|
|
||||||
return True
|
|
||||||
# If unsynced results are not allowed, treat them as strictly worse than any synced result
|
|
||||||
if not allow_unsynced:
|
|
||||||
if (
|
|
||||||
new.status == CacheStatus.SUCCESS_UNSYNCED
|
|
||||||
and old.status == CacheStatus.SUCCESS_SYNCED
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
if (
|
|
||||||
old.status == CacheStatus.SUCCESS_UNSYNCED
|
|
||||||
and new.status == CacheStatus.SUCCESS_SYNCED
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
# Compare confidence
|
|
||||||
if new.confidence != old.confidence:
|
|
||||||
return new.confidence > old.confidence
|
|
||||||
# Equal confidence — prefer synced as tiebreaker
|
|
||||||
# Will return false if unsynced results are not allowed
|
|
||||||
return (
|
|
||||||
new.status == CacheStatus.SUCCESS_SYNCED
|
|
||||||
and old.status != CacheStatus.SUCCESS_SYNCED
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _pick_for_return(
|
def _pick_for_return(
|
||||||
result: FetchResult,
|
result: FetchResult,
|
||||||
allow_unsynced: bool,
|
allow_unsynced: bool,
|
||||||
) -> Optional[LyricResult]:
|
) -> Optional[LyricResult]:
|
||||||
"""Pick which lyric result should participate in final selection."""
|
"""Pick best positive slot for final selection under current strategy."""
|
||||||
candidates: list[LyricResult] = []
|
candidates: list[LyricResult] = []
|
||||||
if result.synced and result.synced.status == CacheStatus.SUCCESS_SYNCED:
|
if result.synced and result.synced.status == CacheStatus.SUCCESS_SYNCED:
|
||||||
candidates.append(result.synced)
|
candidates.append(result.synced)
|
||||||
@@ -82,43 +52,41 @@ def _pick_for_return(
|
|||||||
):
|
):
|
||||||
candidates.append(result.unsynced)
|
candidates.append(result.unsynced)
|
||||||
|
|
||||||
if not candidates:
|
return select_best_positive(candidates, allow_unsynced=True)
|
||||||
return None
|
|
||||||
|
|
||||||
best = candidates[0]
|
|
||||||
for c in candidates[1:]:
|
|
||||||
if _is_better(c, best, allow_unsynced=True):
|
|
||||||
best = c
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
def _pick_for_cache(result: FetchResult) -> Optional[LyricResult]:
|
def _iter_slot_results(result: FetchResult) -> list[tuple[str, LyricResult]]:
|
||||||
"""Pick a single cacheable result from FetchResult for legacy one-slot cache schema."""
|
"""Return all non-None slot results with their cache slot key."""
|
||||||
slots = [r for r in (result.synced, result.unsynced) if r is not None]
|
out: list[tuple[str, LyricResult]] = []
|
||||||
if not slots:
|
if result.synced is not None:
|
||||||
return None
|
out.append((SLOT_SYNCED, result.synced))
|
||||||
|
if result.unsynced is not None:
|
||||||
|
out.append((SLOT_UNSYNCED, result.unsynced))
|
||||||
|
return out
|
||||||
|
|
||||||
positives = [
|
|
||||||
r
|
|
||||||
for r in slots
|
|
||||||
if r.status in (CacheStatus.SUCCESS_SYNCED, CacheStatus.SUCCESS_UNSYNCED)
|
|
||||||
]
|
|
||||||
if positives:
|
|
||||||
best = positives[0]
|
|
||||||
for p in positives[1:]:
|
|
||||||
if _is_better(p, best, allow_unsynced=True):
|
|
||||||
best = p
|
|
||||||
return best
|
|
||||||
|
|
||||||
# If there is no positive result, prefer caching NETWORK_ERROR over NOT_FOUND
|
def _pick_cached_for_return(
|
||||||
# to avoid long false-negative TTL when error signals disagree between slots.
|
cached_rows: list[LyricResult],
|
||||||
for r in slots:
|
allow_unsynced: bool,
|
||||||
if r.status == CacheStatus.NETWORK_ERROR:
|
) -> Optional[LyricResult]:
|
||||||
return r
|
"""Convert cached slot rows into FetchResult-like view and select return candidate."""
|
||||||
for r in slots:
|
fr = FetchResult()
|
||||||
if r.status == CacheStatus.NOT_FOUND:
|
for row in cached_rows:
|
||||||
return r
|
if row.status == CacheStatus.SUCCESS_SYNCED:
|
||||||
return None
|
fr = FetchResult(synced=row, unsynced=fr.unsynced)
|
||||||
|
elif row.status == CacheStatus.SUCCESS_UNSYNCED:
|
||||||
|
fr = FetchResult(synced=fr.synced, unsynced=row)
|
||||||
|
return _pick_for_return(fr, allow_unsynced)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_negative_for_both_slots(cached_rows: list[LyricResult]) -> bool:
|
||||||
|
"""True when both slot rows are present and both are negative."""
|
||||||
|
if len(cached_rows) < 2:
|
||||||
|
return False
|
||||||
|
return all(
|
||||||
|
r.status in (CacheStatus.NOT_FOUND, CacheStatus.NETWORK_ERROR)
|
||||||
|
for r in cached_rows
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LrcManager:
|
class LrcManager:
|
||||||
@@ -137,31 +105,36 @@ class LrcManager:
|
|||||||
bypass_cache: bool,
|
bypass_cache: bool,
|
||||||
allow_unsynced: bool,
|
allow_unsynced: bool,
|
||||||
) -> list[tuple[str, LyricResult]]:
|
) -> list[tuple[str, LyricResult]]:
|
||||||
"""Run one group: cache-check first, then parallel-fetch uncached. Returns (source, result) pairs."""
|
"""Run one group with slot-aware cache check then parallel fetch uncached sources."""
|
||||||
cached_results: list[tuple[str, LyricResult]] = []
|
cached_results: list[tuple[str, LyricResult]] = []
|
||||||
need_fetch: list[BaseFetcher] = []
|
need_fetch: list[BaseFetcher] = []
|
||||||
|
|
||||||
for fetcher in group:
|
for fetcher in group:
|
||||||
source = fetcher.source_name
|
source = fetcher.source_name
|
||||||
if not bypass_cache and not fetcher.self_cached:
|
if not bypass_cache and not fetcher.self_cached:
|
||||||
cached = self.cache.get(track, source)
|
cached_rows = self.cache.get_all(track, source)
|
||||||
if cached:
|
if cached_rows:
|
||||||
if cached.status in (
|
if _has_negative_for_both_slots(cached_rows):
|
||||||
CacheStatus.NOT_FOUND,
|
|
||||||
CacheStatus.NETWORK_ERROR,
|
|
||||||
):
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[{source}] cache hit: {cached.status.value}, skipping"
|
f"[{source}] cache hit: all slots negative, skipping"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
is_trusted = cached.confidence >= HIGH_CONFIDENCE
|
|
||||||
logger.info(
|
cached_for_return = _pick_cached_for_return(
|
||||||
f"[{source}] cache hit: {cached.status.value}"
|
cached_rows, allow_unsynced
|
||||||
f" (confidence={cached.confidence:.0f})"
|
|
||||||
)
|
)
|
||||||
cached_results.append((source, cached))
|
if cached_for_return is not None:
|
||||||
|
is_trusted = cached_for_return.confidence >= HIGH_CONFIDENCE
|
||||||
|
logger.info(
|
||||||
|
f"[{source}] cache hit: {cached_for_return.status.value}"
|
||||||
|
f" (confidence={cached_for_return.confidence:.0f})"
|
||||||
|
)
|
||||||
|
cached_results.append((source, cached_for_return))
|
||||||
# Return immediately on trusted synced cache hit
|
# Return immediately on trusted synced cache hit
|
||||||
if cached.status == CacheStatus.SUCCESS_SYNCED and is_trusted:
|
if (
|
||||||
|
cached_for_return.status == CacheStatus.SUCCESS_SYNCED
|
||||||
|
and is_trusted
|
||||||
|
):
|
||||||
return cached_results
|
return cached_results
|
||||||
continue
|
continue
|
||||||
elif not fetcher.self_cached:
|
elif not fetcher.self_cached:
|
||||||
@@ -193,18 +166,20 @@ class LrcManager:
|
|||||||
logger.debug(f"[{source}] returned None")
|
logger.debug(f"[{source}] returned None")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
cache_result = _pick_for_cache(result)
|
|
||||||
return_result = _pick_for_return(result, allow_unsynced)
|
return_result = _pick_for_return(result, allow_unsynced)
|
||||||
|
|
||||||
if (
|
if not fetcher.self_cached and not bypass_cache:
|
||||||
cache_result is not None
|
for slot_kind, slot_result in _iter_slot_results(result):
|
||||||
and not fetcher.self_cached
|
ttl = slot_result.ttl or _STATUS_TTL.get(
|
||||||
and not bypass_cache
|
slot_result.status, TTL_NOT_FOUND
|
||||||
):
|
)
|
||||||
ttl = cache_result.ttl or _STATUS_TTL.get(
|
self.cache.set(
|
||||||
cache_result.status, TTL_NOT_FOUND
|
track,
|
||||||
|
source,
|
||||||
|
slot_result,
|
||||||
|
ttl_seconds=ttl,
|
||||||
|
positive_kind=slot_kind,
|
||||||
)
|
)
|
||||||
self.cache.set(track, source, cache_result, ttl_seconds=ttl)
|
|
||||||
|
|
||||||
if return_result is not None:
|
if return_result is not None:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -269,8 +244,10 @@ class LrcManager:
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
if best_result is None or _is_better(
|
if best_result is None or is_better_result(
|
||||||
result, best_result, allow_unsynced
|
result,
|
||||||
|
best_result,
|
||||||
|
allow_unsynced=allow_unsynced,
|
||||||
):
|
):
|
||||||
best_result = result
|
best_result = result
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Shared ranking rules for LyricResult selection.
|
||||||
|
|
||||||
|
This module centralizes how positive lyric results are compared so cache/core
|
||||||
|
and other callers use the same precedence and edge-case handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .models import CacheStatus, LyricResult
|
||||||
|
|
||||||
|
|
||||||
|
def is_positive_status(status: CacheStatus) -> bool:
|
||||||
|
return status in (CacheStatus.SUCCESS_SYNCED, CacheStatus.SUCCESS_UNSYNCED)
|
||||||
|
|
||||||
|
|
||||||
|
def is_better_result(
|
||||||
|
new: LyricResult,
|
||||||
|
old: LyricResult,
|
||||||
|
*,
|
||||||
|
allow_unsynced: bool,
|
||||||
|
) -> bool:
|
||||||
|
"""Return True when *new* should rank above *old*.
|
||||||
|
|
||||||
|
Ordering rules (highest first):
|
||||||
|
1) Positive statuses always beat negative statuses.
|
||||||
|
2) When allow_unsynced=False, SUCCESS_SYNCED always beats SUCCESS_UNSYNCED.
|
||||||
|
3) Higher confidence beats lower confidence.
|
||||||
|
4) On equal confidence, SUCCESS_SYNCED beats SUCCESS_UNSYNCED.
|
||||||
|
"""
|
||||||
|
new_positive = is_positive_status(new.status)
|
||||||
|
old_positive = is_positive_status(old.status)
|
||||||
|
|
||||||
|
if not new_positive:
|
||||||
|
return False
|
||||||
|
if not old_positive:
|
||||||
|
return True
|
||||||
|
|
||||||
|
new_synced = new.status == CacheStatus.SUCCESS_SYNCED
|
||||||
|
old_synced = old.status == CacheStatus.SUCCESS_SYNCED
|
||||||
|
|
||||||
|
if not allow_unsynced and new_synced != old_synced:
|
||||||
|
return new_synced
|
||||||
|
|
||||||
|
if new.confidence != old.confidence:
|
||||||
|
return new.confidence > old.confidence
|
||||||
|
|
||||||
|
return new_synced and not old_synced
|
||||||
|
|
||||||
|
|
||||||
|
def select_best_positive(
|
||||||
|
candidates: list[LyricResult],
|
||||||
|
*,
|
||||||
|
allow_unsynced: bool,
|
||||||
|
) -> Optional[LyricResult]:
|
||||||
|
"""Pick best positive LyricResult from candidates.
|
||||||
|
|
||||||
|
Negative statuses are ignored.
|
||||||
|
"""
|
||||||
|
positives = [c for c in candidates if is_positive_status(c.status)]
|
||||||
|
if not positives:
|
||||||
|
return None
|
||||||
|
|
||||||
|
best = positives[0]
|
||||||
|
for c in positives[1:]:
|
||||||
|
if is_better_result(c, best, allow_unsynced=allow_unsynced):
|
||||||
|
best = c
|
||||||
|
return best
|
||||||
+155
-29
@@ -7,6 +7,8 @@ import pytest
|
|||||||
|
|
||||||
from lrx_cli.cache import (
|
from lrx_cli.cache import (
|
||||||
CacheEngine,
|
CacheEngine,
|
||||||
|
SLOT_SYNCED,
|
||||||
|
SLOT_UNSYNCED,
|
||||||
_generate_key,
|
_generate_key,
|
||||||
)
|
)
|
||||||
from lrx_cli.config import DURATION_TOLERANCE_MS
|
from lrx_cli.config import DURATION_TOLERANCE_MS
|
||||||
@@ -67,10 +69,10 @@ def test_generate_key_raises_when_metadata_missing() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_migrate_adds_confidence_version_and_boosts_unsynced(tmp_path: Path) -> None:
|
def test_migrate_adds_confidence_version_and_boosts_unsynced(tmp_path: Path) -> None:
|
||||||
"""Legacy cache without confidence_version is migrated in-place.
|
"""Legacy single-row cache is migrated to slot rows.
|
||||||
|
|
||||||
Expected behavior:
|
Expected behavior:
|
||||||
- add confidence_version column
|
- add positive_kind and confidence_version
|
||||||
- boost SUCCESS_UNSYNCED confidence by +10 with cap at 100
|
- boost SUCCESS_UNSYNCED confidence by +10 with cap at 100
|
||||||
- keep SUCCESS_SYNCED confidence unchanged
|
- keep SUCCESS_SYNCED confidence unchanged
|
||||||
"""
|
"""
|
||||||
@@ -111,16 +113,107 @@ def test_migrate_adds_confidence_version_and_boosts_unsynced(tmp_path: Path) ->
|
|||||||
with sqlite3.connect(db_path) as conn:
|
with sqlite3.connect(db_path) as conn:
|
||||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(cache)").fetchall()}
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(cache)").fetchall()}
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT key, status, confidence, confidence_version FROM cache ORDER BY key"
|
"SELECT key, positive_kind, status, confidence, confidence_version FROM cache ORDER BY key, positive_kind"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
|
assert "positive_kind" in cols
|
||||||
assert "confidence_version" in cols
|
assert "confidence_version" in cols
|
||||||
by_key = {
|
by_key = {
|
||||||
k: (status, confidence, version) for k, status, confidence, version in rows
|
(k, slot): (status, confidence, version)
|
||||||
|
for k, slot, status, confidence, version in rows
|
||||||
}
|
}
|
||||||
assert by_key["u1"] == ("SUCCESS_UNSYNCED", 95.0, 1)
|
assert by_key[("u1", SLOT_UNSYNCED)] == ("SUCCESS_UNSYNCED", 95.0, 1)
|
||||||
assert by_key["u2"] == ("SUCCESS_UNSYNCED", 100.0, 1)
|
assert by_key[("u2", SLOT_UNSYNCED)] == ("SUCCESS_UNSYNCED", 100.0, 1)
|
||||||
assert by_key["s1"] == ("SUCCESS_SYNCED", 70.0, 1)
|
assert by_key[("s1", SLOT_SYNCED)] == ("SUCCESS_SYNCED", 70.0, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrate_negative_row_splits_into_two_slot_rows(tmp_path: Path) -> None:
|
||||||
|
db_path = tmp_path / "legacy-negative.db"
|
||||||
|
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE cache (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
lyrics TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER,
|
||||||
|
artist TEXT,
|
||||||
|
title TEXT,
|
||||||
|
album TEXT,
|
||||||
|
length INTEGER,
|
||||||
|
confidence REAL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cache
|
||||||
|
(key, source, status, lyrics, created_at, expires_at, artist, title, album, length, confidence)
|
||||||
|
VALUES
|
||||||
|
('n1', 's1', 'NOT_FOUND', NULL, 1, NULL, 'A', 'T', 'AL', 180000, 0.0)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
CacheEngine(str(db_path))
|
||||||
|
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT key, positive_kind, status FROM cache ORDER BY positive_kind"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
assert rows == [
|
||||||
|
("n1", SLOT_SYNCED, "NOT_FOUND"),
|
||||||
|
("n1", SLOT_UNSYNCED, "NOT_FOUND"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrate_normalizes_old_slot_spelling(tmp_path: Path) -> None:
|
||||||
|
db_path = tmp_path / "slot-spelling.db"
|
||||||
|
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE cache (
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
positive_kind TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
lyrics TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER,
|
||||||
|
artist TEXT,
|
||||||
|
title TEXT,
|
||||||
|
album TEXT,
|
||||||
|
length INTEGER,
|
||||||
|
confidence REAL,
|
||||||
|
confidence_version INTEGER,
|
||||||
|
PRIMARY KEY (key, positive_kind)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cache
|
||||||
|
(key, positive_kind, source, status, lyrics, created_at, expires_at, artist, title, album, length, confidence, confidence_version)
|
||||||
|
VALUES
|
||||||
|
('k1', 'SYNCHED', 's1', 'SUCCESS_SYNCED', 'l1', 1, NULL, 'A', 'T', 'AL', 180000, 80.0, 1),
|
||||||
|
('k1', 'UNSYNCHED', 's1', 'SUCCESS_UNSYNCED', 'l2', 1, NULL, 'A', 'T', 'AL', 180000, 70.0, 1)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
CacheEngine(str(db_path))
|
||||||
|
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT positive_kind FROM cache ORDER BY positive_kind"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
assert rows == [(SLOT_SYNCED,), (SLOT_UNSYNCED,)]
|
||||||
|
|
||||||
|
|
||||||
def test_set_and_get_roundtrip_with_ttl(
|
def test_set_and_get_roundtrip_with_ttl(
|
||||||
@@ -136,9 +229,10 @@ def test_set_and_get_roundtrip_with_ttl(
|
|||||||
ttl_seconds=120,
|
ttl_seconds=120,
|
||||||
)
|
)
|
||||||
|
|
||||||
cached = cache_db.get(track, "lrclib")
|
cached_rows = cache_db.get_all(track, "lrclib")
|
||||||
|
|
||||||
assert cached is not None
|
assert len(cached_rows) == 1
|
||||||
|
cached = cached_rows[0]
|
||||||
assert cached.status is CacheStatus.SUCCESS_SYNCED
|
assert cached.status is CacheStatus.SUCCESS_SYNCED
|
||||||
assert str(cached.lyrics) == "[00:01.00]line"
|
assert str(cached.lyrics) == "[00:01.00]line"
|
||||||
assert cached.source == "lrclib"
|
assert cached.source == "lrclib"
|
||||||
@@ -158,12 +252,29 @@ def test_get_expired_entry_returns_none_and_removes_row(
|
|||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr("lrx_cli.cache.time.time", lambda: 2_000_020)
|
monkeypatch.setattr("lrx_cli.cache.time.time", lambda: 2_000_020)
|
||||||
cached = cache_db.get(track, "netease")
|
cached_rows = cache_db.get_all(track, "netease")
|
||||||
|
|
||||||
assert cached is None
|
assert cached_rows == []
|
||||||
assert cache_db.query_all() == []
|
assert cache_db.query_all() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_negative_without_slot_writes_both_slots(cache_db: CacheEngine) -> None:
|
||||||
|
track = _track()
|
||||||
|
cache_db.set(
|
||||||
|
track, "src", _result(CacheStatus.NOT_FOUND, None, "src"), ttl_seconds=60
|
||||||
|
)
|
||||||
|
|
||||||
|
with sqlite3.connect(cache_db.db_path) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT positive_kind, status FROM cache ORDER BY positive_kind"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
assert rows == [
|
||||||
|
(SLOT_SYNCED, CacheStatus.NOT_FOUND.value),
|
||||||
|
(SLOT_UNSYNCED, CacheStatus.NOT_FOUND.value),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_get_backfills_missing_length_when_track_provides_it(
|
def test_get_backfills_missing_length_when_track_provides_it(
|
||||||
cache_db: CacheEngine,
|
cache_db: CacheEngine,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -187,9 +298,9 @@ def test_get_backfills_missing_length_when_track_provides_it(
|
|||||||
album=None,
|
album=None,
|
||||||
length=200000,
|
length=200000,
|
||||||
)
|
)
|
||||||
cached = cache_db.get(track_with_length, "spotify")
|
cached_rows = cache_db.get_all(track_with_length, "spotify")
|
||||||
|
|
||||||
assert cached is not None
|
assert cached_rows
|
||||||
|
|
||||||
with sqlite3.connect(cache_db.db_path) as conn:
|
with sqlite3.connect(cache_db.db_path) as conn:
|
||||||
row = conn.execute("SELECT length FROM cache LIMIT 1").fetchone()
|
row = conn.execute("SELECT length FROM cache LIMIT 1").fetchone()
|
||||||
@@ -268,22 +379,6 @@ def test_prune_removes_only_expired_rows(
|
|||||||
assert rows[0]["source"] == "s-active"
|
assert rows[0]["source"] == "s-active"
|
||||||
|
|
||||||
|
|
||||||
def test_find_best_positive_uses_exact_match_and_prefers_synced(
|
|
||||||
cache_db: CacheEngine,
|
|
||||||
) -> None:
|
|
||||||
track = _track(artist="Artist", title="Song", album="Album")
|
|
||||||
cache_db.set(track, "s1", _result(CacheStatus.SUCCESS_UNSYNCED, "u", "s1"))
|
|
||||||
cache_db.set(track, "s2", _result(CacheStatus.SUCCESS_SYNCED, "s", "s2"))
|
|
||||||
|
|
||||||
best = cache_db.find_best_positive(track, CacheStatus.SUCCESS_SYNCED)
|
|
||||||
|
|
||||||
assert best is not None
|
|
||||||
assert best.status is CacheStatus.SUCCESS_SYNCED
|
|
||||||
assert str(best.lyrics) == "s"
|
|
||||||
# find_best_positive always reports cache-search source
|
|
||||||
assert best.source == "cache-search"
|
|
||||||
|
|
||||||
|
|
||||||
def test_find_best_positive_returns_status_specific_results(
|
def test_find_best_positive_returns_status_specific_results(
|
||||||
cache_db: CacheEngine,
|
cache_db: CacheEngine,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -297,6 +392,7 @@ def test_find_best_positive_returns_status_specific_results(
|
|||||||
assert best_synced is not None
|
assert best_synced is not None
|
||||||
assert best_synced.status is CacheStatus.SUCCESS_SYNCED
|
assert best_synced.status is CacheStatus.SUCCESS_SYNCED
|
||||||
assert str(best_synced.lyrics) == "s"
|
assert str(best_synced.lyrics) == "s"
|
||||||
|
assert best_synced.source == "cache-search"
|
||||||
|
|
||||||
best_unsynced = cache_db.find_best_positive(track, CacheStatus.SUCCESS_UNSYNCED)
|
best_unsynced = cache_db.find_best_positive(track, CacheStatus.SUCCESS_UNSYNCED)
|
||||||
assert best_unsynced is not None
|
assert best_unsynced is not None
|
||||||
@@ -395,6 +491,34 @@ def test_update_confidence_targets_specific_source(cache_db: CacheEngine) -> Non
|
|||||||
assert rows["s2"]["confidence"] == 100.0 # unchanged default
|
assert rows["s2"]["confidence"] == 100.0 # unchanged default
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_confidence_updates_both_slots_for_same_source(
|
||||||
|
cache_db: CacheEngine,
|
||||||
|
) -> None:
|
||||||
|
track = _track(artist="A", title="T", album="AL")
|
||||||
|
cache_db.set(
|
||||||
|
track,
|
||||||
|
"src",
|
||||||
|
_result(CacheStatus.SUCCESS_SYNCED, "sync", "src"),
|
||||||
|
positive_kind=SLOT_SYNCED,
|
||||||
|
)
|
||||||
|
cache_db.set(
|
||||||
|
track,
|
||||||
|
"src",
|
||||||
|
_result(CacheStatus.SUCCESS_UNSYNCED, "unsync", "src"),
|
||||||
|
positive_kind=SLOT_UNSYNCED,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = cache_db.update_confidence(track, 66.0, "src")
|
||||||
|
assert updated == 2
|
||||||
|
|
||||||
|
with sqlite3.connect(cache_db.db_path) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT positive_kind, confidence FROM cache WHERE source = 'src' ORDER BY positive_kind"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
assert rows == [(SLOT_SYNCED, 66.0), (SLOT_UNSYNCED, 66.0)]
|
||||||
|
|
||||||
|
|
||||||
def test_update_confidence_returns_zero_for_missing_source(
|
def test_update_confidence_returns_zero_for_missing_source(
|
||||||
cache_db: CacheEngine,
|
cache_db: CacheEngine,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -476,3 +600,5 @@ def test_query_track_and_stats_return_expected_aggregates(
|
|||||||
assert stats["expired"] == 0
|
assert stats["expired"] == 0
|
||||||
assert stats["by_status"][CacheStatus.SUCCESS_SYNCED.value] == 1
|
assert stats["by_status"][CacheStatus.SUCCESS_SYNCED.value] == 1
|
||||||
assert stats["by_status"][CacheStatus.SUCCESS_UNSYNCED.value] == 1
|
assert stats["by_status"][CacheStatus.SUCCESS_UNSYNCED.value] == 1
|
||||||
|
assert stats["by_slot"][SLOT_SYNCED] == 1
|
||||||
|
assert stats["by_slot"][SLOT_UNSYNCED] == 1
|
||||||
|
|||||||
+44
-18
@@ -2,9 +2,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
import pytest
|
|
||||||
|
|
||||||
from lrx_cli.config import HIGH_CONFIDENCE
|
from lrx_cli.config import HIGH_CONFIDENCE
|
||||||
|
from lrx_cli.cache import SLOT_UNSYNCED
|
||||||
from lrx_cli.core import LrcManager
|
from lrx_cli.core import LrcManager
|
||||||
from lrx_cli.fetchers.base import BaseFetcher, FetchResult
|
from lrx_cli.fetchers.base import BaseFetcher, FetchResult
|
||||||
from lrx_cli.lrc import LRCData
|
from lrx_cli.lrc import LRCData
|
||||||
@@ -137,7 +137,7 @@ def test_trusted_synced_cancels_sibling(tmp_path):
|
|||||||
assert result.source == "fast"
|
assert result.source == "fast"
|
||||||
|
|
||||||
|
|
||||||
def test_best_confidence_within_group(tmp_path):
|
def test_allow_unsynced_true_picks_highest_confidence_unsynced(tmp_path):
|
||||||
"""When allow_unsynced=True and no trusted synced result, highest-confidence unsynced is returned."""
|
"""When allow_unsynced=True and no trusted synced result, highest-confidence unsynced is returned."""
|
||||||
low = MockFetcher("low", _fr(unsynced=_unsynced("low", confidence=40.0)))
|
low = MockFetcher("low", _fr(unsynced=_unsynced("low", confidence=40.0)))
|
||||||
high = MockFetcher("high", _fr(unsynced=_unsynced("high", confidence=70.0)))
|
high = MockFetcher("high", _fr(unsynced=_unsynced("high", confidence=70.0)))
|
||||||
@@ -148,6 +148,22 @@ def test_best_confidence_within_group(tmp_path):
|
|||||||
assert result.source == "high"
|
assert result.source == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_equal_confidence_prefers_synced_when_unsynced_allowed(tmp_path):
|
||||||
|
"""Tie on confidence should still prefer synced over unsynced."""
|
||||||
|
dual = MockFetcher(
|
||||||
|
"dual",
|
||||||
|
_fr(
|
||||||
|
synced=_synced("dual", confidence=70.0),
|
||||||
|
unsynced=_unsynced("dual", confidence=70.0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
manager = make_manager(tmp_path)
|
||||||
|
with patch("lrx_cli.core.build_plan", return_value=[[dual]]):
|
||||||
|
result = manager.fetch_for_track(_track(), allow_unsynced=True)
|
||||||
|
assert result is not None
|
||||||
|
assert result.status == CacheStatus.SUCCESS_SYNCED
|
||||||
|
|
||||||
|
|
||||||
def test_unsynced_only_returns_none_when_not_allowed(tmp_path):
|
def test_unsynced_only_returns_none_when_not_allowed(tmp_path):
|
||||||
"""When allow_unsynced=False, unsynced-only pipeline result must be rejected."""
|
"""When allow_unsynced=False, unsynced-only pipeline result must be rejected."""
|
||||||
only_unsynced = MockFetcher(
|
only_unsynced = MockFetcher(
|
||||||
@@ -210,22 +226,10 @@ def test_cache_trusted_synced_no_fetch(tmp_path):
|
|||||||
assert result.status == CacheStatus.SUCCESS_SYNCED
|
assert result.status == CacheStatus.SUCCESS_SYNCED
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(
|
def test_cached_slots_support_strategy_switch_without_refetch(
|
||||||
strict=True,
|
|
||||||
reason=(
|
|
||||||
"Known limitation: cache stores only one positive slot; after an allow_unsynced=True "
|
|
||||||
"request caches unsynced, later allow_unsynced=False request does not re-fetch synced"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
def test_xfail_cached_unsynced_should_not_block_live_synced_when_unsynced_disallowed(
|
|
||||||
tmp_path,
|
tmp_path,
|
||||||
):
|
):
|
||||||
"""Known gap reproduced with strategy switch across two requests.
|
"""When both slots are cached, strategy switch should reuse cache without re-fetch."""
|
||||||
|
|
||||||
1) Fetcher returns both synced and unsynced.
|
|
||||||
2) allow_unsynced=True picks/caches higher-confidence unsynced.
|
|
||||||
3) allow_unsynced=False should re-fetch synced, but currently short-circuits on cache.
|
|
||||||
"""
|
|
||||||
fetcher = MockFetcher(
|
fetcher = MockFetcher(
|
||||||
"src",
|
"src",
|
||||||
_fr(
|
_fr(
|
||||||
@@ -244,10 +248,32 @@ def test_xfail_cached_unsynced_should_not_block_live_synced_when_unsynced_disall
|
|||||||
|
|
||||||
fetcher.called = False
|
fetcher.called = False
|
||||||
|
|
||||||
# Second request: stricter strategy should recover synced via re-fetch.
|
# Second request: stricter strategy should use synced cache slot directly.
|
||||||
with patch("lrx_cli.core.build_plan", return_value=[[fetcher]]):
|
with patch("lrx_cli.core.build_plan", return_value=[[fetcher]]):
|
||||||
second = manager.fetch_for_track(track, allow_unsynced=False)
|
second = manager.fetch_for_track(track, allow_unsynced=False)
|
||||||
|
|
||||||
assert fetcher.called
|
assert not fetcher.called
|
||||||
assert second is not None
|
assert second is not None
|
||||||
assert second.status == CacheStatus.SUCCESS_SYNCED
|
assert second.status == CacheStatus.SUCCESS_SYNCED
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsynced_cache_only_still_fetches_when_unsynced_disallowed(tmp_path):
|
||||||
|
"""If only unsynced cache slot exists, allow_unsynced=False must still fetch synced."""
|
||||||
|
fetcher = MockFetcher("src", _fr(synced=_synced("src", confidence=88.0)))
|
||||||
|
manager = make_manager(tmp_path)
|
||||||
|
track = _track()
|
||||||
|
|
||||||
|
manager.cache.set(
|
||||||
|
track,
|
||||||
|
"src",
|
||||||
|
_unsynced("src", confidence=95.0),
|
||||||
|
ttl_seconds=3600,
|
||||||
|
positive_kind=SLOT_UNSYNCED,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("lrx_cli.core.build_plan", return_value=[[fetcher]]):
|
||||||
|
result = manager.fetch_for_track(track, allow_unsynced=False)
|
||||||
|
|
||||||
|
assert fetcher.called
|
||||||
|
assert result is not None
|
||||||
|
assert result.status == CacheStatus.SUCCESS_SYNCED
|
||||||
|
|||||||
Reference in New Issue
Block a user