You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/core/spotify_token_cache.py

85 lines
3.5 KiB

"""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)