From 603b7a2ab853e656cac1554d7d97be2a187a2674 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 7 Jun 2026 12:01:33 -0700 Subject: [PATCH] =?UTF-8?q?Spotify=20tokens=20move=20into=20the=20database?= =?UTF-8?q?=20=E2=80=94=20daily=20Docker=20deauth=20fixed=20(wolf39us)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/spotify_client.py | 19 ++++--- core/spotify_token_cache.py | 84 ++++++++++++++++++++++++++++++ tests/test_spotify_token_cache.py | 85 +++++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 core/spotify_token_cache.py create mode 100644 tests/test_spotify_token_cache.py diff --git a/core/spotify_client.py b/core/spotify_client.py index d40d8780..aa1b4889 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -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") diff --git a/core/spotify_token_cache.py b/core/spotify_token_cache.py new file mode 100644 index 00000000..fd427528 --- /dev/null +++ b/core/spotify_token_cache.py @@ -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) diff --git a/tests/test_spotify_token_cache.py b/tests/test_spotify_token_cache.py new file mode 100644 index 00000000..8ed5326f --- /dev/null +++ b/tests/test_spotify_token_cache.py @@ -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