Spotify tokens move into the database — daily Docker deauth fixed (wolf39us)

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.
This commit is contained in:
BoulderBadgeDad 2026-06-07 12:01:33 -07:00
parent f1f9d803a5
commit 603b7a2ab8
3 changed files with 182 additions and 6 deletions

View file

@ -688,12 +688,20 @@ class SpotifyClient:
return
try:
# Tokens live in the database-backed config store, not a loose
# file: config/.spotify_cache sat in /app/config, which is only
# persistent when the user's compose maps it — an anonymous
# volume is recreated empty on every container pull, so tokens
# died nightly while every other setting survived ("it keeps
# unauthenticating" daily, wolf39us). The handler imports the
# legacy file once if the store is empty.
from core.spotify_token_cache import DatabaseTokenCache
auth_manager = SpotifyOAuth(
client_id=config['client_id'],
client_secret=config['client_secret'],
redirect_uri=config.get('redirect_uri', "http://127.0.0.1:8888/callback"),
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
cache_path='config/.spotify_cache'
cache_handler=DatabaseTokenCache(config_manager)
)
self.sp = spotipy.Spotify(auth_manager=auth_manager, retries=0, requests_timeout=15)
@ -924,13 +932,12 @@ class SpotifyClient:
except Exception as e:
logger.debug("publish_spotify_status disconnect: %s", e)
cache_path = 'config/.spotify_cache'
try:
if os.path.exists(cache_path):
os.remove(cache_path)
logger.info("Deleted Spotify cache file")
from core.spotify_token_cache import DatabaseTokenCache
DatabaseTokenCache(config_manager).clear()
logger.info("Cleared Spotify token cache (database + legacy file)")
except Exception as e:
logger.warning(f"Failed to delete Spotify cache: {e}")
logger.warning(f"Failed to clear Spotify token cache: {e}")
logger.info("Spotify client disconnected")

View file

@ -0,0 +1,84 @@
"""Database-backed Spotify token cache (wolf39us's daily-deauth fix).
Spotipy's default cache is a loose file — ours lived at
``config/.spotify_cache``. In Docker, ``/app/config`` is a declared VOLUME,
but a compose file that doesn't map it explicitly gets an ANONYMOUS volume,
and anonymous volumes don't survive container recreation. Net effect: a
nightly Watchtower pull kept the user's settings (config now lives in the
database) but silently dropped the OAuth tokens "it keeps unauthenticating"
every day, while a manual re-auth always "fixed" it until the next pull.
This handler stores the token payload in the same database-backed config
store as every other setting (``spotify.token_info``), so tokens survive
exactly as long as the rest of the configuration does. The legacy cache file
is imported once if the store is empty, then left in place for rollback.
"""
from __future__ import annotations
import json
import os
from typing import Any, Dict, Optional
from spotipy.cache_handler import CacheHandler
from utils.logging_config import get_logger
logger = get_logger("spotify_token_cache")
_CONFIG_KEY = "spotify.token_info"
LEGACY_CACHE_PATH = "config/.spotify_cache"
class DatabaseTokenCache(CacheHandler):
"""Spotipy CacheHandler persisting the token in the config database."""
def __init__(self, config_manager, legacy_path: str = LEGACY_CACHE_PATH):
self._config = config_manager
self._legacy_path = legacy_path
def get_cached_token(self) -> Optional[Dict[str, Any]]:
try:
token = self._config.get(_CONFIG_KEY, None)
if isinstance(token, str):
token = json.loads(token)
if isinstance(token, dict) and token.get("access_token"):
return token
except Exception as e:
logger.debug("token cache read failed: %s", e)
# One-time import from the legacy file cache, so an upgrade doesn't
# force a re-auth when the file happens to still be around.
try:
if self._legacy_path and os.path.isfile(self._legacy_path):
with open(self._legacy_path, "r", encoding="utf-8") as fh:
legacy = json.load(fh)
if isinstance(legacy, dict) and legacy.get("access_token"):
logger.info(
"Imported Spotify token from legacy file cache into the "
"database store (tokens now survive container recreation)")
self.save_token_to_cache(legacy)
return legacy
except Exception as e:
logger.debug("legacy token import failed: %s", e)
return None
def save_token_to_cache(self, token_info: Dict[str, Any]) -> None:
try:
self._config.set(_CONFIG_KEY, token_info)
except Exception as e:
# Never let a cache write break an API call — worst case the
# refreshed token isn't persisted and the next run refreshes again.
logger.warning("token cache write failed: %s", e)
def clear(self) -> None:
"""Logout: drop the stored token (and the legacy file if present)."""
try:
self._config.set(_CONFIG_KEY, None)
except Exception as e:
logger.debug("token cache clear failed: %s", e)
try:
if self._legacy_path and os.path.isfile(self._legacy_path):
os.remove(self._legacy_path)
except OSError as e:
logger.debug("legacy cache remove failed: %s", e)

View file

@ -0,0 +1,85 @@
"""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