diff --git a/music/api/decorators.py b/music/api/decorators.py index 10fa67a..9259332 100644 --- a/music/api/decorators.py +++ b/music/api/decorators.py @@ -91,7 +91,7 @@ def lastfm_username_required(func): user_dict = database.get_user_doc_ref(kwargs.get('username')).get().to_dict() if user_dict: - if user_dict.get('lastfm_username'): + if user_dict.get('lastfm_username') and len(user_dict.get('lastfm_username')) > 0: return func(*args, **kwargs) else: logger.warning(f'no last.fm username for {user_dict["username"]}') diff --git a/music/api/spotfm.py b/music/api/spotfm.py index 1215409..3026b1b 100644 --- a/music/api/spotfm.py +++ b/music/api/spotfm.py @@ -1,15 +1,23 @@ from flask import Blueprint, jsonify, request import logging +import json +import os -from music.api.decorators import login_or_basic_auth, lastfm_username_required, spotify_link_required +from music.api.decorators import login_or_basic_auth, lastfm_username_required, spotify_link_required, cloud_task import music.db.database as database +from music.tasks.refresh_lastfm_stats import refresh_lastfm_stats from spotfm.maths.counter import Counter from spotframework.model.uri import Uri +from google.cloud import tasks_v2 + blueprint = Blueprint('spotfm-api', __name__) logger = logging.getLogger(__name__) +tasker = tasks_v2.CloudTasksClient() +task_path = tasker.queue_path('sarsooxyz', 'europe-west2', 'spotify-executions') + @blueprint.route('/count', methods=['GET']) @login_or_basic_auth @@ -55,3 +63,56 @@ def count(username=None): }), 200 else: return jsonify({'error': f'playlist {playlist_name} not found'}), 404 + + +@blueprint.route('/playlist/refresh', methods=['GET']) +@login_or_basic_auth +@spotify_link_required +@lastfm_username_required +def playlist_refresh(username=None): + + playlist_name = request.args.get('name', None) + + if playlist_name: + + if os.environ.get('DEPLOY_DESTINATION', None) == 'PROD': + create_refresh_playlist_task(username, playlist_name) + else: + refresh_lastfm_stats(username, playlist_name) + + return jsonify({'message': 'execution requested', 'status': 'success'}), 200 + + else: + logger.warning('no playlist requested') + return jsonify({"error": 'no name requested'}), 400 + + +@blueprint.route('/playlist/refresh/task', methods=['POST']) +@cloud_task +def run_playlist_task(): + + payload = request.get_data(as_text=True) + if payload: + payload = json.loads(payload) + + logger.info(f'running {payload["username"]} / {payload["name"]}') + + refresh_lastfm_stats(payload['username'], payload['name']) + + return jsonify({'message': 'executed playlist', 'status': 'success'}), 200 + + +def create_refresh_playlist_task(username, playlist_name): + + task = { + 'app_engine_http_request': { # Specify the type of request. + 'http_method': 'POST', + 'relative_uri': '/api/spotfm/playlist/refresh/task', + 'body': json.dumps({ + 'username': username, + 'name': playlist_name + }).encode() + } + } + + tasker.create_task(task_path, task) diff --git a/music/db/database.py b/music/db/database.py index a08fea1..a656b74 100644 --- a/music/db/database.py +++ b/music/db/database.py @@ -123,7 +123,7 @@ def get_user_playlists_collection(user_id: str) -> firestore.CollectionReference return playlists -def get_user_playlist_ref_by_username(user: str, playlist: str) -> Optional[firestore.CollectionReference]: +def get_user_playlist_ref_by_username(user: str, playlist: str) -> Optional[firestore.DocumentReference]: user_ref = get_user_doc_ref(user) @@ -137,7 +137,7 @@ def get_user_playlist_ref_by_username(user: str, playlist: str) -> Optional[fire def get_user_playlist_ref_by_user_ref(user_ref: firestore.DocumentReference, - playlist: str) -> Optional[firestore.CollectionReference]: + playlist: str) -> Optional[firestore.DocumentReference]: playlist_collection = get_user_playlists_collection(user_ref.id) diff --git a/music/tasks/refresh_lastfm_stats.py b/music/tasks/refresh_lastfm_stats.py new file mode 100644 index 0000000..ba71f32 --- /dev/null +++ b/music/tasks/refresh_lastfm_stats.py @@ -0,0 +1,36 @@ +from google.cloud import firestore + +import logging +from datetime import datetime + +import music.db.database as database + +from spotfm.maths.counter import Counter +from spotframework.model.uri import Uri + +db = firestore.Client() + +logger = logging.getLogger(__name__) + + +def refresh_lastfm_stats(username, playlist_name): + + logger.info(f'refreshing {playlist_name} stats for {username}') + + fmnet = database.get_authed_lastfm_network(username=username) + spotnet = database.get_authed_spotify_network(username=username) + counter = Counter(fmnet=fmnet, spotnet=spotnet) + + database_ref = database.get_user_playlist_ref_by_username(user=username, playlist=playlist_name) + + playlist_dict = database_ref.get().to_dict() + count = counter.count(Uri(playlist_dict['uri'])) + + user_count = fmnet.get_user_scrobble_count() + percent = round((count * 100) / user_count, 2) + + database_ref.update({ + 'lastfm_stat_count': count, + 'lastfm_stat_percent': percent, + 'lastfm_stat_last_refresh': datetime.utcnow() + }) diff --git a/src/js/Playlist/PlaylistsView.js b/src/js/Playlist/PlaylistsView.js index b235346..6dc6fdc 100644 --- a/src/js/Playlist/PlaylistsView.js +++ b/src/js/Playlist/PlaylistsView.js @@ -146,7 +146,7 @@ function PlaylistLink(props){ } function getPlaylistLink(playlistName){ - return '/app/playlist/' + playlistName; + return `/app/playlist/${playlistName}/edit`; } export default PlaylistsView; \ No newline at end of file diff --git a/src/js/Playlist/View/Count.js b/src/js/Playlist/View/Count.js new file mode 100644 index 0000000..d50cd95 --- /dev/null +++ b/src/js/Playlist/View/Count.js @@ -0,0 +1,79 @@ +import React, { Component } from "react"; +const axios = require('axios'); + +import showMessage from "../../Toast.js" + +class Count extends Component { + + constructor(props){ + super(props); + this.state = { + name: props.name, + lastfm_refresh: 'never', + lastfm_percent: 0, + count: 0, + isLoading: true + } + this.getUserInfo(); + + this.updateStats = this.updateStats.bind(this); + } + + getUserInfo(){ + axios.get(`/api/playlist?name=${ this.state.name }`) + .then((response) => { + if(response.data.lastfm_stat_last_refresh != undefined){ + this.setState({ + count: response.data.lastfm_stat_count, + lastfm_refresh: response.data.lastfm_stat_last_refresh, + lastfm_percent: response.data.lastfm_stat_percent, + isLoading: false + }) + }else{ + showMessage('no stats for this playlist'); + } + }) + .catch((error) => { + showMessage(`error getting playlist info (${error.response.status})`); + }); + } + + updateStats(){ + axios.get(`/api/spotfm/playlist/refresh?name=${ this.state.name }`) + .then((response) => { + showMessage('stats refresh queued'); + }) + .catch((error) => { + if(error.response.status == 401){ + showMessage('missing either spotify or last.fm link'); + }else{ + showMessage(`error refreshing (${error.response.status})`); + } + }); + } + + render() { + return ( + + + scrobble count: {this.state.count.toLocaleString()} + + + that's {this.state.lastfm_percent}% of all scrobbles + + + last updated {this.state.lastfm_refresh.toLocaleString()} + + + + + + + + ); + } +} + + + +export default Count; \ No newline at end of file diff --git a/src/js/Playlist/PlaylistView.js b/src/js/Playlist/View/Edit.js similarity index 61% rename from src/js/Playlist/PlaylistView.js rename to src/js/Playlist/View/Edit.js index 408dddb..1d05420 100644 --- a/src/js/Playlist/PlaylistView.js +++ b/src/js/Playlist/View/Edit.js @@ -1,7 +1,7 @@ import React, { Component } from "react"; const axios = require('axios'); -import showMessage from "../Toast.js" +import showMessage from "../../Toast.js" var thisMonth = [ 'january', @@ -33,12 +33,12 @@ var lastMonth = [ 'november' ]; -class PlaylistView extends Component{ +class Edit extends Component{ constructor(props){ super(props); this.state = { - name: this.props.match.params.name, + name: this.props.name, parts: [], playlists: [], filteredPlaylists: [], @@ -352,162 +352,157 @@ class PlaylistView extends Component{ var date = new Date(); const table = ( - - - - - - - { this.state.playlist_references.length > 0 && } - { this.state.parts.length > 0 && } - - - - - - - - - - - - - - - - - - - - - - - + { this.state.playlist_references.length > 0 && } + { this.state.playlist_references.length > 0 && } + + { this.state.parts.length > 0 && } + { this.state.parts.length > 0 && } + + + + + + + + + + + + + + + + + + + + + + + + { this.state.type == 'recents' && + + + - - { this.state.type == 'recents' && - - - - - } - { this.state.type == 'recents' && - - - - - } - { this.state.type == 'recents' && - - - - - } - - - - - - - - -

{ this.state.name }

-

spotify playlist can be the name of either your own created playlist or one you follow, names are case sensitive -
- - - -
- - - -
- shuffle output? - - -
- include recommendations? - - -
- number of recommendations - +
managed
spotify
+

spotify playlist can be the name of either your own created playlist or one you follow, names are case sensitive +
+ + + +
+ + + +
+ shuffle output? + + +
+ include recommendations? + + +
+ number of recommendations + + +
+ added since (days) + -
- added since (days) - - -
- include {thisMonth[date.getMonth()]} playlist - - -
- include {lastMonth[date.getMonth()]} playlist - - -
- playlist type - - -
- -
+ name="day_boundary" + className="full-width" + value={this.state.day_boundary} + onChange={this.handleInputChange}> + + + } + { this.state.type == 'recents' && + + + include {thisMonth[date.getMonth()]} playlist + + + + + + } + { this.state.type == 'recents' && + + + include {lastMonth[date.getMonth()]} playlist + + + + + + } + + + playlist type + + + + + + + + + + + ); - const error =

{ this.state.error_text }

; - const loadingMessage =

loading...

; + const loadingMessage = + + + +

loading...

+ + + ; - return this.state.isLoading ? loadingMessage : ( this.state.error ? error : table ); + return this.state.isLoading ? loadingMessage : table; } } function ReferenceEntry(props) { - return ( - - ); + return ; } function ListBlock(props) { - return ( - - {props.name} - { props.list.map((part) => ) } - - ); + return props.list.map((part) => ); } function Row (props) { @@ -519,4 +514,4 @@ function Row (props) { ); } -export default PlaylistView; \ No newline at end of file +export default Edit; \ No newline at end of file diff --git a/src/js/Playlist/View/View.js b/src/js/Playlist/View/View.js new file mode 100644 index 0000000..22f7c3a --- /dev/null +++ b/src/js/Playlist/View/View.js @@ -0,0 +1,36 @@ +import React, { Component } from "react"; +import { BrowserRouter as Router, Route, Link, Switch, Redirect} from "react-router-dom"; +const axios = require('axios'); + +import Edit from "./Edit.js"; +import Count from "./Count.js"; + +class View extends Component{ + + render() { + return ( + + + + + + + + + + } /> + } /> +

{ this.props.match.params.name }

+
+
    +
  • edit
  • +
  • count
  • +
+
+
+ ); + } + +} + +export default View; \ No newline at end of file diff --git a/src/js/PlaylistManager.js b/src/js/PlaylistManager.js index 993da8d..093452f 100644 --- a/src/js/PlaylistManager.js +++ b/src/js/PlaylistManager.js @@ -4,7 +4,7 @@ import { BrowserRouter as Router, Route, Link, Switch, Redirect} from "react-rou import Index from "./Index/Index.js"; import Maths from "./Maths/Maths.js"; import Playlists from "./Playlist/Playlists.js"; -import PlaylistView from "./Playlist/PlaylistView.js"; +import PlaylistView from "./Playlist/View/View.js"; import Settings from "./Settings/Settings.js"; import Admin from "./Admin/Admin.js"; @@ -58,7 +58,7 @@ class PlaylistManager extends Component { home playlists - maths + {/* maths */} settings { this.state.type == 'admin' && admin } logout