wolf39us: "It keeps unauthenticating... daily" — re-auth fixes it until the next day. Mechanism: spotipy's token cache was a loose FILE at config/.spotify_cache. /app/config is a declared VOLUME, but a compose file that doesn't map it explicitly gets an ANONYMOUS volume — recreated empty on every container pull. So a nightly Watchtower update kept all his settings (config lives in the database now) while silently dropping the OAuth tokens. His redirect-URI change won't help: callback URLs only matter during the initial handshake, never for refresh. New DatabaseTokenCache (spotipy CacheHandler) stores the token payload in the same database-backed config store as every other setting — tokens now survive exactly as long as the rest of the configuration does. The legacy file is imported once on upgrade (no forced re-auth) and removed on logout; a failed cache write logs and never raises (spotipy calls it mid-request). Tests: roundtrip, JSON-string tolerance, one-time legacy import (store wins after the file vanishes), garbage file ignored, logout clears both stores, write failure never raises. 204 spotify tests pass.
85 lines
2.7 KiB
Python
85 lines
2.7 KiB
Python
"""Database-backed Spotify token cache (wolf39us daily-deauth).
|
|
|
|
The token used to live in config/.spotify_cache — gone on every container
|
|
recreation unless the user's compose maps /app/config explicitly. The
|
|
DatabaseTokenCache stores it in the config store (which demonstrably
|
|
survives recreation — the user's settings did while his tokens died), and
|
|
imports the legacy file once on upgrade.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from core.spotify_token_cache import DatabaseTokenCache
|
|
|
|
|
|
class _Cfg:
|
|
def __init__(self):
|
|
self.store = {}
|
|
|
|
def get(self, key, default=None):
|
|
return self.store.get(key, default)
|
|
|
|
def set(self, key, value):
|
|
self.store[key] = value
|
|
|
|
|
|
TOKEN = {"access_token": "at", "refresh_token": "rt", "expires_at": 123}
|
|
|
|
|
|
def test_save_then_get_roundtrip(tmp_path):
|
|
cache = DatabaseTokenCache(_Cfg(), legacy_path=str(tmp_path / "nope"))
|
|
assert cache.get_cached_token() is None
|
|
cache.save_token_to_cache(TOKEN)
|
|
assert cache.get_cached_token() == TOKEN
|
|
|
|
|
|
def test_json_string_value_tolerated(tmp_path):
|
|
cfg = _Cfg()
|
|
cfg.store["spotify.token_info"] = json.dumps(TOKEN) # stored serialized
|
|
cache = DatabaseTokenCache(cfg, legacy_path=str(tmp_path / "nope"))
|
|
assert cache.get_cached_token() == TOKEN
|
|
|
|
|
|
def test_legacy_file_imported_once(tmp_path):
|
|
legacy = tmp_path / ".spotify_cache"
|
|
legacy.write_text(json.dumps(TOKEN))
|
|
cfg = _Cfg()
|
|
cache = DatabaseTokenCache(cfg, legacy_path=str(legacy))
|
|
|
|
assert cache.get_cached_token() == TOKEN # imported
|
|
assert cfg.store["spotify.token_info"] == TOKEN # persisted to the store
|
|
# Subsequent reads come from the store even if the file vanishes.
|
|
legacy.unlink()
|
|
assert cache.get_cached_token() == TOKEN
|
|
|
|
|
|
def test_garbage_legacy_file_ignored(tmp_path):
|
|
legacy = tmp_path / ".spotify_cache"
|
|
legacy.write_text("not json{{{")
|
|
cache = DatabaseTokenCache(_Cfg(), legacy_path=str(legacy))
|
|
assert cache.get_cached_token() is None
|
|
|
|
|
|
def test_clear_drops_store_and_file(tmp_path):
|
|
legacy = tmp_path / ".spotify_cache"
|
|
legacy.write_text(json.dumps(TOKEN))
|
|
cfg = _Cfg()
|
|
cache = DatabaseTokenCache(cfg, legacy_path=str(legacy))
|
|
cache.save_token_to_cache(TOKEN)
|
|
|
|
cache.clear()
|
|
|
|
assert cfg.store["spotify.token_info"] is None
|
|
assert not legacy.exists()
|
|
assert cache.get_cached_token() is None
|
|
|
|
|
|
def test_write_failure_never_raises(tmp_path):
|
|
class _Broken(_Cfg):
|
|
def set(self, key, value):
|
|
raise RuntimeError("db down")
|
|
|
|
cache = DatabaseTokenCache(_Broken(), legacy_path=str(tmp_path / "nope"))
|
|
cache.save_token_to_cache(TOKEN) # must not raise — spotipy calls this mid-request
|