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:
parent
f1f9d803a5
commit
603b7a2ab8
3 changed files with 182 additions and 6 deletions
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
84
core/spotify_token_cache.py
Normal file
84
core/spotify_token_cache.py
Normal 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)
|
||||
85
tests/test_spotify_token_cache.py
Normal file
85
tests/test_spotify_token_cache.py
Normal 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
|
||||
Loading…
Reference in a new issue