added playlist counting

This commit is contained in:
aj 2019-10-19 20:35:37 +01:00
parent 849c0a5fa6
commit b54ef10541
9 changed files with 362 additions and 155 deletions

View File

@ -91,7 +91,7 @@ def lastfm_username_required(func):
user_dict = database.get_user_doc_ref(kwargs.get('username')).get().to_dict() user_dict = database.get_user_doc_ref(kwargs.get('username')).get().to_dict()
if user_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) return func(*args, **kwargs)
else: else:
logger.warning(f'no last.fm username for {user_dict["username"]}') logger.warning(f'no last.fm username for {user_dict["username"]}')

View File

@ -1,15 +1,23 @@
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
import logging 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 import music.db.database as database
from music.tasks.refresh_lastfm_stats import refresh_lastfm_stats
from spotfm.maths.counter import Counter from spotfm.maths.counter import Counter
from spotframework.model.uri import Uri from spotframework.model.uri import Uri
from google.cloud import tasks_v2
blueprint = Blueprint('spotfm-api', __name__) blueprint = Blueprint('spotfm-api', __name__)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
tasker = tasks_v2.CloudTasksClient()
task_path = tasker.queue_path('sarsooxyz', 'europe-west2', 'spotify-executions')
@blueprint.route('/count', methods=['GET']) @blueprint.route('/count', methods=['GET'])
@login_or_basic_auth @login_or_basic_auth
@ -55,3 +63,56 @@ def count(username=None):
}), 200 }), 200
else: else:
return jsonify({'error': f'playlist {playlist_name} not found'}), 404 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)

View File

@ -123,7 +123,7 @@ def get_user_playlists_collection(user_id: str) -> firestore.CollectionReference
return playlists 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) 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, 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) playlist_collection = get_user_playlists_collection(user_ref.id)

View File

@ -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()
})

View File

@ -146,7 +146,7 @@ function PlaylistLink(props){
} }
function getPlaylistLink(playlistName){ function getPlaylistLink(playlistName){
return '/app/playlist/' + playlistName; return `/app/playlist/${playlistName}/edit`;
} }
export default PlaylistsView; export default PlaylistsView;

View File

@ -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 (
<tbody>
<tr>
<td className="ui-text center-text text-no-select">scrobble count: <b>{this.state.count.toLocaleString()}</b></td>
</tr>
<tr>
<td className="ui-text center-text text-no-select">that's <b>{this.state.lastfm_percent}%</b> of all scrobbles</td>
</tr>
<tr>
<td className="ui-text center-text text-no-select">last updated <b>{this.state.lastfm_refresh.toLocaleString()}</b></td>
</tr>
<tr>
<td colSpan="2">
<button style={{width: "100%"}} className="button" onClick={this.updateStats}>update</button>
</td>
</tr>
</tbody>
);
}
}
export default Count;

View File

@ -1,7 +1,7 @@
import React, { Component } from "react"; import React, { Component } from "react";
const axios = require('axios'); const axios = require('axios');
import showMessage from "../Toast.js" import showMessage from "../../Toast.js"
var thisMonth = [ var thisMonth = [
'january', 'january',
@ -33,12 +33,12 @@ var lastMonth = [
'november' 'november'
]; ];
class PlaylistView extends Component{ class Edit extends Component{
constructor(props){ constructor(props){
super(props); super(props);
this.state = { this.state = {
name: this.props.match.params.name, name: this.props.name,
parts: [], parts: [],
playlists: [], playlists: [],
filteredPlaylists: [], filteredPlaylists: [],
@ -352,162 +352,157 @@ class PlaylistView extends Component{
var date = new Date(); var date = new Date();
const table = ( const table = (
<table className="app-table max-width"> <tbody>
<thead> { this.state.playlist_references.length > 0 && <tr><td colSpan="2" className="ui-text center-text text-no-select" style={{fontStyle: 'italic'}}>managed</td></tr> }
<tr> { this.state.playlist_references.length > 0 && <ListBlock handler={this.handleRemoveReference} list={this.state.playlist_references}/> }
<th colSpan="2"><h1 className="text-no-select">{ this.state.name }</h1></th>
</tr> { this.state.parts.length > 0 && <tr><td colSpan="2" className="ui-text center-text text-no-select" style={{fontStyle: 'italic'}}>spotify</td></tr> }
</thead> { this.state.parts.length > 0 && <ListBlock handler={this.handleRemovePart} list={this.state.parts}/> }
{ this.state.playlist_references.length > 0 && <ListBlock name="managed" handler={this.handleRemoveReference} list={this.state.playlist_references}/> } <tr>
{ this.state.parts.length > 0 && <ListBlock name="spotify" handler={this.handleRemovePart} list={this.state.parts}/> } <td colSpan="2" className="center-text ui-text text-no-select" style={{fontStyle: "italic"}}>
<tbody> <br></br>spotify playlist can be the name of either your own created playlist or one you follow, names are case sensitive
<tr> </td>
<td colSpan="2" className="center-text ui-text text-no-select" style={{fontStyle: "italic"}}> </tr>
<br></br>spotify playlist can be the name of either your own created playlist or one you follow, names are case sensitive <tr>
</td> <td>
</tr> <input type="text"
<tr> name="newPlaylistName"
<td> className="full-width"
<input type="text" value={this.state.newPlaylistName}
name="newPlaylistName" onChange={this.handleInputChange}
placeholder="spotify playlist name"></input>
</td>
<td>
<button className="button full-width" onClick={this.handleAddPart}>add</button>
</td>
</tr>
<tr>
<td>
<select name="newReferenceName"
className="full-width" className="full-width"
value={this.state.newPlaylistName} value={this.state.newReferenceName}
onChange={this.handleInputChange} onChange={this.handleInputChange}>
placeholder="spotify playlist name"></input> { this.state.playlists
</td> .filter((entry) => entry.name != this.state.name)
<td> .map((entry) => <ReferenceEntry name={entry.name} key={entry.name} />) }
<button className="button full-width" onClick={this.handleAddPart}>add</button> </select>
</td> </td>
</tr> <td>
<tr> <button className="button full-width" onClick={this.handleAddReference}>add</button>
<td> </td>
<select name="newReferenceName" </tr>
className="full-width" <tr>
value={this.state.newReferenceName} <td className="center-text ui-text text-no-select">
onChange={this.handleInputChange}> shuffle output?
{ this.state.playlists </td>
.filter((entry) => entry.name != this.state.name) <td>
.map((entry) => <ReferenceEntry name={entry.name} key={entry.name} />) } <input type="checkbox"
</select> checked={this.state.shuffle}
</td> onChange={this.handleShuffleChange}></input>
<td> </td>
<button className="button full-width" onClick={this.handleAddReference}>add</button> </tr>
</td> <tr>
</tr> <td className="center-text ui-text text-no-select">
<tr> include recommendations?
<td className="center-text ui-text text-no-select"> </td>
shuffle output? <td>
</td> <input type="checkbox"
<td> checked={this.state.include_recommendations}
<input type="checkbox" onChange={this.handleIncludeRecommendationsChange}></input>
checked={this.state.shuffle} </td>
onChange={this.handleShuffleChange}></input> </tr>
</td> <tr>
</tr> <td className="center-text ui-text text-no-select">
<tr> number of recommendations
<td className="center-text ui-text text-no-select"> </td>
include recommendations? <td>
</td> <input type="number"
<td> name="recommendation_sample"
<input type="checkbox" className="full-width"
checked={this.state.include_recommendations} value={this.state.recommendation_sample}
onChange={this.handleIncludeRecommendationsChange}></input> onChange={this.handleInputChange}></input>
</td> </td>
</tr> </tr>
<tr> { this.state.type == 'recents' &&
<td className="center-text ui-text text-no-select"> <tr>
number of recommendations <td className="center-text ui-text text-no-select">
</td> added since (days)
<td> </td>
<td>
<input type="number" <input type="number"
name="recommendation_sample" name="day_boundary"
className="full-width" className="full-width"
value={this.state.recommendation_sample} value={this.state.day_boundary}
onChange={this.handleInputChange}></input> onChange={this.handleInputChange}></input>
</td> </td>
</tr> </tr>
{ this.state.type == 'recents' && }
<tr> { this.state.type == 'recents' &&
<td className="center-text ui-text text-no-select"> <tr>
added since (days) <td className="center-text ui-text text-no-select">
</td> include {thisMonth[date.getMonth()]} playlist
<td> </td>
<input type="number" <td>
name="day_boundary" <input type="checkbox"
className="full-width" checked={this.state.add_this_month}
value={this.state.day_boundary} onChange={this.handleThisMonthChange}></input>
onChange={this.handleInputChange}></input> </td>
</td> </tr>
</tr> }
} { this.state.type == 'recents' &&
{ this.state.type == 'recents' && <tr>
<tr> <td className="center-text ui-text text-no-select">
<td className="center-text ui-text text-no-select"> include {lastMonth[date.getMonth()]} playlist
include {thisMonth[date.getMonth()]} playlist </td>
</td> <td>
<td> <input type="checkbox"
<input type="checkbox" checked={this.state.add_last_month}
checked={this.state.add_this_month} onChange={this.handleLastMonthChange}></input>
onChange={this.handleThisMonthChange}></input> </td>
</td> </tr>
</tr> }
} <tr>
{ this.state.type == 'recents' && <td className="center-text ui-text text-no-select">
<tr> playlist type
<td className="center-text ui-text text-no-select"> </td>
include {lastMonth[date.getMonth()]} playlist <td>
</td> <select className="full-width"
<td> name="type"
<input type="checkbox" onChange={this.handleInputChange}
checked={this.state.add_last_month} value={this.state.type}>
onChange={this.handleLastMonthChange}></input> <option value="default">default</option>
</td> <option value="recents">recents</option>
</tr> </select>
} </td>
<tr> </tr>
<td className="center-text ui-text text-no-select"> <tr>
playlist type <td colSpan="2">
</td> <button className="button full-width" onClick={this.handleRun}>run</button>
<td> </td>
<select className="full-width" </tr>
name="type" </tbody>
onChange={this.handleInputChange}
value={this.state.type}>
<option value="default">default</option>
<option value="recents">recents</option>
</select>
</td>
</tr>
<tr>
<td colSpan="2">
<button className="button full-width" onClick={this.handleRun}>run</button>
</td>
</tr>
</tbody>
</table>
); );
const error = <p style={{textAlign: "center"}}>{ this.state.error_text }</p>; const loadingMessage =
const loadingMessage = <p className="center-text">loading...</p>; <tbody>
<tr>
<td>
<p className="center-text">loading...</p>
</td>
</tr>
</tbody>;
return this.state.isLoading ? loadingMessage : ( this.state.error ? error : table ); return this.state.isLoading ? loadingMessage : table;
} }
} }
function ReferenceEntry(props) { function ReferenceEntry(props) {
return ( return <option value={props.name}>{props.name}</option>;
<option value={props.name}>{props.name}</option>
);
} }
function ListBlock(props) { function ListBlock(props) {
return ( return props.list.map((part) => <Row part={ part } key={ part } handler={props.handler}/>);
<tbody>
<tr><td colSpan="2" className="ui-text center-text text-no-select" style={{fontStyle: 'italic'}}>{props.name}</td></tr>
{ props.list.map((part) => <Row part={ part } key={ part } handler={props.handler}/>) }
</tbody>
);
} }
function Row (props) { function Row (props) {
@ -519,4 +514,4 @@ function Row (props) {
); );
} }
export default PlaylistView; export default Edit;

View File

@ -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 (
<table className="app-table max-width">
<thead>
<tr>
<th colSpan="2"><h1 className="text-no-select">{ this.props.match.params.name }</h1></th>
</tr>
<tr>
<th colSpan="2">
<div>
<ul className="navbar" style={{width: "100%"}}>
<li><Link to={`${this.props.match.url}/edit`}>edit</Link></li>
<li><Link to={`${this.props.match.url}/count`}>count</Link></li>
</ul>
</div>
</th>
</tr>
</thead>
<Route path={`${this.props.match.url}/edit`} render={(props) => <Edit {...props} name={this.props.match.params.name}/>} />
<Route path={`${this.props.match.url}/count`} render={(props) => <Count {...props} name={this.props.match.params.name}/>} />
</table>
);
}
}
export default View;

View File

@ -4,7 +4,7 @@ import { BrowserRouter as Router, Route, Link, Switch, Redirect} from "react-rou
import Index from "./Index/Index.js"; import Index from "./Index/Index.js";
import Maths from "./Maths/Maths.js"; import Maths from "./Maths/Maths.js";
import Playlists from "./Playlist/Playlists.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 Settings from "./Settings/Settings.js";
import Admin from "./Admin/Admin.js"; import Admin from "./Admin/Admin.js";
@ -58,7 +58,7 @@ class PlaylistManager extends Component {
<tbody> <tbody>
<tr><td><span><Link to="/app">home</Link></span></td></tr> <tr><td><span><Link to="/app">home</Link></span></td></tr>
<tr><td><Link to="/app/playlists">playlists</Link></td></tr> <tr><td><Link to="/app/playlists">playlists</Link></td></tr>
<tr><td><Link to="/app/maths">maths</Link></td></tr> {/* <tr><td><Link to="/app/maths">maths</Link></td></tr> */}
<tr><td><Link to="/app/settings/password">settings</Link></td></tr> <tr><td><Link to="/app/settings/password">settings</Link></td></tr>
{ this.state.type == 'admin' && <tr><td><Link to="/app/admin/lock">admin</Link></td></tr> } { this.state.type == 'admin' && <tr><td><Link to="/app/admin/lock">admin</Link></td></tr> }
<tr><td><a href="/auth/logout">logout</a></td></tr> <tr><td><a href="/auth/logout">logout</a></td></tr>