Skip to content

Commit

Permalink
fix: ensure REDIS is purely optional
Browse files Browse the repository at this point in the history
  • Loading branch information
Marlon (esolitos) Saglia committed Jan 3, 2024
1 parent 196b39b commit 296caeb
Show file tree
Hide file tree
Showing 5 changed files with 79 additions and 65 deletions.
12 changes: 4 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ or you can do all via a browser. The first option still requires a browser to
run the OAuth authentication process 'tho.

In either of the two configurations you'll need [Pipenv](https://pipenv.pypa.io/en/latest/)
to install the required libraries.
to install the required libraries.
provide the OAuth client id and
secret as required by [Spotipy](https://spotipy.readthedocs.io/), those should
be passed as environment variables:
Expand All @@ -20,27 +20,23 @@ export SPOTIPY_CLIENT_ID='your-spotify-client-id'
export SPOTIPY_CLIENT_SECRET='your-spotify-client-secret'
```



### Running without http server (only CLI)

The script `spotify_weekly.py` can be run via

```shell script
export SPOTIPY_REDIRECT_URI='http://localhost/'
export REDIRECT_HOST='localhost:80'
```


## Configuration overview

_All configuration is done via environment variables_
*All configuration is done via environment variables*

| Name | Required | Description |
|---------------------------|-----------|-------------|
| `SPOTIPY_CLIENT_ID` | Yes | Oauth Client ID, by Spotify |
| `SPOTIPY_CLIENT_SECRET` | Yes | Oauth Client Secret, by Spotify |
| `SPOTIPY_REDIRECT_URI` | CLI Only | Only when run via CLI. Read "Running without http server" for more info |
| `OAUTH_CACHE_FILE` | No | *(Only HTTP)* Sets the oauth cache file path. Must be writable. |
| `SERVER_HOST` | No | *(Only HTTP)* Http server IP (Default: `127.0.1.1`) |
| `SERVER_PORT` | No | *(Only HTTP)* Http server Port (Default: `8080` |
| `REDIRECT_HOST` | No | *(Only HTTP)* Redirect hostname for Spotify oauth. (Default: "`$SERVER_HOST:$SERVER_PORT`") |
| `REDIRECT_HOST` | No | *(Only HTTP)* Redirect hostname for Spotify oauth. (Default: "`$SERVER_HOST:$SERVER_PORT`") |
97 changes: 51 additions & 46 deletions swa/session.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,39 @@

"""
A module to manage user sessions.
This module provides classes and functions to manage user sessions, storing the data in Redis.
This module provides classes and functions to manage user sessions. It now supports storing the data in Redis,
and falls back to file-based storage when REDIS_URL is not provided.
"""

from __future__ import annotations
import json
import logging
import random
import string

from os import getenv
import os
from typing import Optional

import bottle
from swa.spotifyoauthredis import access_token
from swa.utils import redis_client, redis_session_data_key

COOKIE_SECRET = str(getenv("SPOTIPY_CLIENT_SECRET", "default"))
COOKIE_SECRET = str(os.getenv("SPOTIPY_CLIENT_SECRET", "default"))

# File-based storage directory
FILE_STORAGE_PATH = './session_data'


def is_redis_enabled() -> bool:
"""
Check if Redis is enabled by looking for REDIS_URL.
Returns True if REDIS_URL is set, False otherwise.
"""
return 'REDIS_URL' in os.environ


def get_file_storage_path(session_id: str) -> str:
return os.path.join(FILE_STORAGE_PATH, f'{session_id}.json')


class SessionData:
Expand Down Expand Up @@ -76,16 +93,15 @@ def to_json(self) -> str:


def session_start() -> str:
"""
Starts a session, setting the data in Redis.
session_id = ''.join(random.choices(
string.ascii_letters + string.digits, k=16))
bottle.response.set_cookie('SID', session_id, secret=COOKIE_SECRET)

:return: The session ID for the new session.
"""
session_id = ''.join(random.choices(string.ascii_letters + string.digits, k=16))
bottle.response.set_cookie('SID', session_id, secret=COOKIE_SECRET, path='/', httponly=True)
redis_key = redis_session_data_key(session_id)
redis_data = SessionData({'id': session_id}).to_json()
redis_client().set(name=redis_key, value=redis_data)
# Initialize empty session data
if not is_redis_enabled():
os.makedirs(FILE_STORAGE_PATH, exist_ok=True)
with open(get_file_storage_path(session_id), 'w') as file:
json.dump({}, file)

return session_id

Expand All @@ -97,62 +113,51 @@ def session_get_id(auto_start: bool = True) -> str | None:
:param auto_start: If True (the default), a new session will be started if necesary.
:return: The session ID or None if no session is active and `auto_start` is False.
"""
session_id = bottle.request.get_cookie('SID')
session_id = bottle.request.get_cookie('SID', secret=COOKIE_SECRET)
if session_id is None:
return session_start() if auto_start else None

return str(session_id)


def session_get_data(session_id=None) -> SessionData:
"""
Returns the SessionData instance for the current session or the given session ID.
:param session_id: The session ID to get the data for.
If not provided, the current session ID will be used.
:return: The `SessionData` instance for the current or given session.
:raises RuntimeError: If no session is active and no `session_id` is provided.
"""
def session_get_data(session_id: str = None) -> SessionData:
if not session_id:
session_id = session_get_id(auto_start=False)

if not session_id:
raise RuntimeError('No valid session and no session_id provided!')

redis_data = redis_client().get(redis_session_data_key(session_id))
logging.debug("Session data: ", end=" ")
logging.debug({session_id: redis_data})
if redis_data:
return SessionData.from_json(redis_data)
if is_redis_enabled():
redis_data = redis_client().get(redis_session_data_key(session_id))
if redis_data:
return SessionData.from_json(redis_data)
else:
try:
with open(get_file_storage_path(session_id), 'r') as file:
return SessionData(json.load(file))
except FileNotFoundError:
return SessionData()

return SessionData()

def session_set_data(data: SessionData, session_id: str = None) -> bool:
"""
Sets the session data for the current or given session.
:param data: The `SessionData` instance to set for the session.
:param session_id: The session ID to set the data for.
If not provided, the current session ID will be used.

:return: True if the data was successfully set, or False otherwise.
:raises RuntimeError: If no session is active and no `session_id` is provided.
"""
def session_set_data(data: SessionData, session_id: str = None) -> bool:
if not session_id:
session_id = session_get_id(False)

if not session_id:
raise RuntimeError('No valid session and no session_id provided!')

redis_key = redis_session_data_key(session_id)
redis_data = data.to_json()
logging.debug("Set session data: ", end=" ")
logging.debug({session_id: redis_data})
if not redis_client().set(name=redis_key, value=redis_data):
return False
if is_redis_enabled():
redis_key = redis_session_data_key(session_id)
redis_data = data.to_json()
return redis_client().set(name=redis_key, value=redis_data)
else:
os.makedirs(FILE_STORAGE_PATH, exist_ok=True)
with open(get_file_storage_path(session_id), 'w') as file:
json.dump(data.all(), file)
return True

return True

def session_get_oauth_token() -> tuple(SessionData, str):
"""
Expand Down
29 changes: 20 additions & 9 deletions swa/spotify_weekly.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,27 @@
from typing import Dict, List, Optional
from spotipy import Spotify


class SwaError(RuntimeError):
"""Generic App error."""


class PlaylistError(SwaError):
"""Playlist generic error."""


class DiscoverWeeklyError(PlaylistError):
"""Generic error about 'Discover Weekly' playlist."""


class DiscoverWeeklyNotFoundError(DiscoverWeeklyError):
"""Playlist 'Discover Weekly' not found."""


class DiscoverWeeklyMultipleMatchesError(DiscoverWeeklyError):
"""Playlist 'Discover Weekly' has multiple matches."""


class SwaRunner:
"""A class to run a script that fetches tracks from the user's "Discover Weekly" playlist,
and adds them to a new playlist with only albums.
Expand Down Expand Up @@ -86,7 +92,8 @@ def get_user_playlists(self, sort_by_author: bool = False) -> List[Dict]:
"""
key: str = 'user_playlists'
if key not in self._cache or self._cache[key] is None:
self._cache[key] = self._spy_client.current_user_playlists()['items']
self._cache[key] = self._spy_client.current_user_playlists()[
'items']

if sort_by_author:
return self._sort_playlists_by_author(self._cache[key])
Expand Down Expand Up @@ -118,7 +125,8 @@ def get_discover_weekly(self, allow_multiple: bool = False) -> list or dict:

playlist_name: str = 'Discover Weekly'
if playlist_name not in self._cache or self._cache[playlist_name] is None:
self._cache[playlist_name] = self.get_playlist_by_name(playlist_name, multiple=True)
self._cache[playlist_name] = self.get_playlist_by_name(
playlist_name, multiple=True)

if len(self._cache[playlist_name]) <= 0:
raise DiscoverWeeklyNotFoundError()
Expand All @@ -132,9 +140,11 @@ def prepare_weekly_album_playlist(self) -> dict:
"""
Attempts to find the "Weekly Album discovery", cleaning it up is needed.
"""
album_playlist = self.get_playlist_by_name(self._special_playlist['name'])
album_playlist = self.get_playlist_by_name(
self._special_playlist['name'])
if not album_playlist:
logging.debug("Creating playlist: '%s'", self._special_playlist['name'])
logging.debug("Creating playlist: '%s'",
self._special_playlist['name'])
return self._spy_client.user_playlist_create(
self.get_username(),
name=self._special_playlist['name'],
Expand All @@ -144,16 +154,16 @@ def prepare_weekly_album_playlist(self) -> dict:

logging.info("Found playlist '%s:'", self._special_playlist['name'])
if album_playlist['tracks']['total'] > 0:
logging.info("Contains %s tracks to remove.", album_playlist['tracks']['total'])
logging.info("Contains %s tracks to remove.",
album_playlist['tracks']['total'])
self._playlist_cleanup(album_playlist['id'])

return album_playlist

def _playlist_cleanup(self, playlist_id: str):
logging.info('Cleaning up:', end=' ')
logging.info('Cleaning up playlist: %s', playlist_id)
while self._do_playlist_cleanup(playlist_id):
logging.info('.', end='')
logging.info('!')
pass

def _do_playlist_cleanup(self, playlist_id: str):
playlist_tracks = self._spy_client.playlist_tracks(
Expand Down Expand Up @@ -188,7 +198,8 @@ def get_all_albums_tracks(self, album_ids: list):
"""
tracks = []
for album_id in album_ids:
tracks.extend([t['id'] for t in self._spy_client.album_tracks(album_id)['items']])
tracks.extend(
[t['id'] for t in self._spy_client.album_tracks(album_id)['items']])

return tracks

Expand Down
2 changes: 1 addition & 1 deletion swa/spotifyoauthredis.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def spotify_oauth(email: str) -> spotipy.SpotifyOAuth:
redirect_url = f'http://{hostname}/oauth/callback'

cache_handler = None
if getenv('REDIS_URL') is not None:
if getenv('REDIS_URL'):
rclient = redis.Redis().from_url(url=getenv('REDIS_URL'), decode_responses=True)
cache_handler = spotipy.cache_handler.RedisCacheHandler(
rclient,
Expand Down
4 changes: 3 additions & 1 deletion swa_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def login():
def do_login():
"""Handles user login and redirects to Spotify authorization page."""
session_id = sws.session_get_id(auto_start=True)
logging.debug(session_id)
email = str(bottle.request.forms.get('email'))
session_data = sws.session_get_data(session_id).add('email', email)
logging.debug(session_data)
Expand Down Expand Up @@ -238,7 +239,6 @@ def check_requirements():
required_vars = [
"SPOTIPY_CLIENT_ID",
"SPOTIPY_CLIENT_SECRET",
"SPOTIPY_REDIRECT_URI",
]
for var in required_vars:
if var not in os.environ:
Expand All @@ -254,6 +254,8 @@ def main():
server_host, server_port = swutil.http_server_info()
enable_debug = bool(os.getenv('DEBUG'))
check_requirements()
if enable_debug:
logging.basicConfig(level=logging.DEBUG)
bottle.run(
host=server_host, port=server_port,
debug=enable_debug, reloader=(not swutil.is_prod())
Expand Down

0 comments on commit 296caeb

Please sign in to comment.