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()
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"]}')

View File

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

View File

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

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){
return '/app/playlist/' + playlistName;
return `/app/playlist/${playlistName}/edit`;
}
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";
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 = (
<table className="app-table max-width">
<thead>
<tr>
<th colSpan="2"><h1 className="text-no-select">{ this.state.name }</h1></th>
</tr>
</thead>
{ this.state.playlist_references.length > 0 && <ListBlock name="managed" handler={this.handleRemoveReference} list={this.state.playlist_references}/> }
{ this.state.parts.length > 0 && <ListBlock name="spotify" handler={this.handleRemovePart} list={this.state.parts}/> }
<tbody>
<tr>
<td colSpan="2" className="center-text ui-text text-no-select" style={{fontStyle: "italic"}}>
<br></br>spotify playlist can be the name of either your own created playlist or one you follow, names are case sensitive
</td>
</tr>
<tr>
<td>
<input type="text"
name="newPlaylistName"
className="full-width"
value={this.state.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"
value={this.state.newReferenceName}
onChange={this.handleInputChange}>
{ this.state.playlists
.filter((entry) => entry.name != this.state.name)
.map((entry) => <ReferenceEntry name={entry.name} key={entry.name} />) }
</select>
</td>
<td>
<button className="button full-width" onClick={this.handleAddReference}>add</button>
</td>
</tr>
<tr>
<td className="center-text ui-text text-no-select">
shuffle output?
</td>
<td>
<input type="checkbox"
checked={this.state.shuffle}
onChange={this.handleShuffleChange}></input>
</td>
</tr>
<tr>
<td className="center-text ui-text text-no-select">
include recommendations?
</td>
<td>
<input type="checkbox"
checked={this.state.include_recommendations}
onChange={this.handleIncludeRecommendationsChange}></input>
</td>
</tr>
<tr>
<td className="center-text ui-text text-no-select">
number of recommendations
</td>
<td>
<tbody>
{ this.state.playlist_references.length > 0 && <tr><td colSpan="2" className="ui-text center-text text-no-select" style={{fontStyle: 'italic'}}>managed</td></tr> }
{ this.state.playlist_references.length > 0 && <ListBlock handler={this.handleRemoveReference} list={this.state.playlist_references}/> }
{ this.state.parts.length > 0 && <tr><td colSpan="2" className="ui-text center-text text-no-select" style={{fontStyle: 'italic'}}>spotify</td></tr> }
{ this.state.parts.length > 0 && <ListBlock handler={this.handleRemovePart} list={this.state.parts}/> }
<tr>
<td colSpan="2" className="center-text ui-text text-no-select" style={{fontStyle: "italic"}}>
<br></br>spotify playlist can be the name of either your own created playlist or one you follow, names are case sensitive
</td>
</tr>
<tr>
<td>
<input type="text"
name="newPlaylistName"
className="full-width"
value={this.state.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"
value={this.state.newReferenceName}
onChange={this.handleInputChange}>
{ this.state.playlists
.filter((entry) => entry.name != this.state.name)
.map((entry) => <ReferenceEntry name={entry.name} key={entry.name} />) }
</select>
</td>
<td>
<button className="button full-width" onClick={this.handleAddReference}>add</button>
</td>
</tr>
<tr>
<td className="center-text ui-text text-no-select">
shuffle output?
</td>
<td>
<input type="checkbox"
checked={this.state.shuffle}
onChange={this.handleShuffleChange}></input>
</td>
</tr>
<tr>
<td className="center-text ui-text text-no-select">
include recommendations?
</td>
<td>
<input type="checkbox"
checked={this.state.include_recommendations}
onChange={this.handleIncludeRecommendationsChange}></input>
</td>
</tr>
<tr>
<td className="center-text ui-text text-no-select">
number of recommendations
</td>
<td>
<input type="number"
name="recommendation_sample"
className="full-width"
value={this.state.recommendation_sample}
onChange={this.handleInputChange}></input>
</td>
</tr>
{ this.state.type == 'recents' &&
<tr>
<td className="center-text ui-text text-no-select">
added since (days)
</td>
<td>
<input type="number"
name="recommendation_sample"
className="full-width"
value={this.state.recommendation_sample}
onChange={this.handleInputChange}></input>
</td>
</tr>
{ this.state.type == 'recents' &&
<tr>
<td className="center-text ui-text text-no-select">
added since (days)
</td>
<td>
<input type="number"
name="day_boundary"
className="full-width"
value={this.state.day_boundary}
onChange={this.handleInputChange}></input>
</td>
</tr>
}
{ this.state.type == 'recents' &&
<tr>
<td className="center-text ui-text text-no-select">
include {thisMonth[date.getMonth()]} playlist
</td>
<td>
<input type="checkbox"
checked={this.state.add_this_month}
onChange={this.handleThisMonthChange}></input>
</td>
</tr>
}
{ this.state.type == 'recents' &&
<tr>
<td className="center-text ui-text text-no-select">
include {lastMonth[date.getMonth()]} playlist
</td>
<td>
<input type="checkbox"
checked={this.state.add_last_month}
onChange={this.handleLastMonthChange}></input>
</td>
</tr>
}
<tr>
<td className="center-text ui-text text-no-select">
playlist type
</td>
<td>
<select className="full-width"
name="type"
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>
name="day_boundary"
className="full-width"
value={this.state.day_boundary}
onChange={this.handleInputChange}></input>
</td>
</tr>
}
{ this.state.type == 'recents' &&
<tr>
<td className="center-text ui-text text-no-select">
include {thisMonth[date.getMonth()]} playlist
</td>
<td>
<input type="checkbox"
checked={this.state.add_this_month}
onChange={this.handleThisMonthChange}></input>
</td>
</tr>
}
{ this.state.type == 'recents' &&
<tr>
<td className="center-text ui-text text-no-select">
include {lastMonth[date.getMonth()]} playlist
</td>
<td>
<input type="checkbox"
checked={this.state.add_last_month}
onChange={this.handleLastMonthChange}></input>
</td>
</tr>
}
<tr>
<td className="center-text ui-text text-no-select">
playlist type
</td>
<td>
<select className="full-width"
name="type"
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>
);
const error = <p style={{textAlign: "center"}}>{ this.state.error_text }</p>;
const loadingMessage = <p className="center-text">loading...</p>;
const loadingMessage =
<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) {
return (
<option value={props.name}>{props.name}</option>
);
return <option value={props.name}>{props.name}</option>;
}
function ListBlock(props) {
return (
<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>
);
return props.list.map((part) => <Row part={ part } key={ part } handler={props.handler}/>);
}
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 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 {
<tbody>
<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/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>
{ 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>