This commit is contained in:
Sarsoo 2021-03-23 22:46:23 +00:00
parent bc1ad78d25
commit a3b82dd616
142 changed files with 19521 additions and 22234 deletions

View File

@ -1,3 +0,0 @@
{
"presets": ["@babel/env", "@babel/preset-react"]
}

4
.buildinfo Normal file
View File

@ -0,0 +1,4 @@
# Sphinx build info version 1
# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
config: 7186c4270e3799a6d05bc88e234018d2
tags: 645f666f9bcd5a90fca523b33c5a78b7

Binary file not shown.

BIN
.doctrees/index.doctree Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
.doctrees/src/music.doctree Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,26 +0,0 @@
# This file specifies files that are *not* uploaded to Google Cloud Platform
# using gcloud. It follows the same syntax as .gitignore, with the addition of
# "#!include" directives (which insert the entries of the given .gitignore-style
# file at that point).
#
# For more information, run:
# $ gcloud topic gcloudignore
#
.gcloudignore
# If you would like to upload your .git directory, .gitignore file or files
# from your .gitignore file, remove the corresponding line
# below:
.git
.idea
.gitignore
.vscode
service.json
docs
venv
node_modules
# Python pycache:
__pycache__/
# Ignored by the build system
/setup.cfg

View File

@ -1,145 +0,0 @@
name: test and deploy
on: [push, pull_request]
jobs:
build:
strategy:
fail-fast: false
matrix:
python-version: [3.8]
poetry-version: [1.1.4]
node: [14]
os: [ubuntu-20.04, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v2 # get source
# PYTHON
- name: Install Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
# PYTHON dependency management
- name: Install Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2.1.0
with:
poetry-version: ${{ matrix.poetry-version }}
# PYTHON install dependencies
- name: Install Python Dependencies
run: poetry install
# PYTHON for authentication when testing
- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@master
with:
project_id: ${{ secrets.GCP_PROJECT_ID }}
service_account_key: ${{ secrets.GCP_SA_KEY }}
export_default_credentials: true # <===
# PYTHON run unit tests
- name: Run Python Tests
run: poetry run python -m unittest discover -s tests
# JS setup for testing
- name: Install Node ${{ matrix.node }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
# JS install from lock.json
- name: Install Node Packages
run: npm ci
# JS build for checking errors
- name: Compile Front-end
run: npm run build --if-present
# JS tests
# - name: Run JavaScript Tests
# run: npm test
deploy:
runs-on: ubuntu-20.04
needs: build # for ignoring bad builds
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v2 # get source
# PYTHON
- name: Install Python 3.8
uses: actions/setup-python@v2
with:
python-version: 3.8
# PYTHON for dependency export only, not installing
- name: Install Poetry 1.1.4
uses: abatilo/actions-poetry@v2.1.0
with:
poetry-version: 1.1.4
# PYTHON Export Poetry dependencies as requirements.txt
- name: Export Poetry Dependencies
run: python admin.py pydepend
# JS setup
- name: Install Node 14
uses: actions/setup-node@v2
with:
node-version: 14
# JS install from lock.json
- name: Install Node Packages
run: npm ci
# JS will be built again, for flagging errors
- name: Compile Front-end
run: npm run build --if-present
# JS for compiling scss
- name: Compile Sass
uses: gha-utilities/sass-build@v0.3.5
with:
source: src/scss/style.scss
destination: build/style.css
# DEPLOY for setting up cloud API
- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@master
with:
project_id: ${{ secrets.GCP_PROJECT_ID }}
service_account_key: ${{ secrets.GCP_SA_KEY }}
export_default_credentials: true
# DEPLOY set project
- name: Set GCP Project
run: python admin.py set_project
# DEPLOY app engine service, -nb for skipping compile
- name: Deploy App Engine Service
run: python admin.py app -nb
### MAIN FUNCTIONS
# DEPLOY update_tag function
- name: Deploy update_tag Function
run: python admin.py tag
# DEPLOY run_user_playlist function
- name: Deploy run_user_playlist Function
run: python admin.py playlist
### CRON FUNCTIONS
# DEPLOY run_all_playlists function
- name: Deploy run_all_playlists Function
run: python admin.py playlist_cron
# DEPLOY run_all_playlist_stats function
- name: Deploy run_all_playlist_stats Function
run: python admin.py playlist_stats_cron
# DEPLOY run_all_tags function
- name: Deploy run_all_tags Function
run: python admin.py tags_cron

129
.gitignore vendored
View File

@ -1,129 +0,0 @@
scratch.py
service.json
requirements.txt
main.py
*~*
*#
node_modules/
# Byte-compiled / optimized / DLL files
*/__pycache__/*
*.py[cod]
*$py.class
.idea
launch.json
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/

12
.vscode/settings.json vendored
View File

@ -1,12 +0,0 @@
{
"python.testing.unittestArgs": [
"-v",
"-s",
"./tests",
"-p",
"test*.py"
],
"python.testing.pytestEnabled": false,
"python.testing.nosetestsEnabled": false,
"python.testing.unittestEnabled": true
}

View File

@ -1,53 +0,0 @@
[Music Tools](https://music.sarsoo.xyz)
==================
![Python Tests](https://github.com/sarsoo/music-tools/workflows/test%20and%20deploy/badge.svg)
Set of utility tools for Spotify and Last.fm.
Built on my other libraries for Spotify ([spotframework](https://github.com/Sarsoo/spotframework)), Last.fm ([fmframework](https://github.com/Sarsoo/pyfmframework)) and interfacing utility tools for the two ([spotfm](https://github.com/Sarsoo/pyfmframework)). Currently running on a suite of Google Cloud Platform services. An iOS client is currently under development [here](https://github.com/Sarsoo/Music-Tools-iOS).
# Smart Playlists
Create smart playlists for Spotify including tracks from playlists, library and Spotify recommendations.
![Playlists List](docs/Playlists.png)
![Playlist Example](docs/PlaylistExample.png)
Playlists can pull tracks from multiple sources with some extra ones based on the playlist's type.
* Spotify playlists
- Currently referenced by case-sensitive names of those followed by the user
- Plan to include reference by Spotify URI
* Other Music Tools playlists
- Dynamically include the Spotify playlists of other managed playlists
- Used to allow hierarchy playlists such as for genre (as seen above for multiple rap playlists)
* Spotify Library Tracks
* Monthly Playlists
- ONLY for "Recents" type playlists
- Find user playlists by name in the format "month year" e.g. february 20 (lowercase)
- Can dynamically include this month's and/or last month's playlist at runtime
* Last.fm track chart data
- ONLY for "Last.fm Chart" type playlists
- Include variable number of top tracks in the last date range
When not shuffled, playlists are date sorted with newest at the top for a rolling album artwork of newest releases.
Playlists are updated using the [spotframework](https://github.com/Sarsoo/spotframework) playlist engine three times a day.
# Tags
Groups of Last.fm objects for summing of scrobble counts and listening statistics.
![Tag Example](docs/TagExample.png)
## Structure
This repo consists of a front-end written in React.js and Material-UI being served by a back-end written in Flask.
The application is hosted on Google Cloud Infrastructure.
As this codebase stands it's not really re-useable as is, references to the cloud infrastructure are hard-coded.
## Acknowledgements
Took inspiration from Paul Lamere's [smarter playlists](http://smarterplaylists.playlistmachinery.com/).

30
_sources/index.rst.txt Normal file
View File

@ -0,0 +1,30 @@
Music Tools
=======================================
.. toctree::
:maxdepth: 1
:caption: Contents:
Modules <src/modules>
src/music
src/music.api
src/music.auth
src/music.cloud
src/music.db
src/music.model
src/music.tasks
Music Tools
-------------
.. image:: https://github.com/sarsoo/music-tools/workflows/test%20and%20deploy/badge.svg
Music Tools is a web app for creating smart Spotify playlists.
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

View File

@ -0,0 +1,7 @@
music
=====
.. toctree::
:maxdepth: 4
music

View File

@ -0,0 +1,77 @@
music.api package
=================
Submodules
----------
music.api.admin module
----------------------
.. automodule:: music.api.admin
:members:
:undoc-members:
:show-inheritance:
music.api.api module
--------------------
.. automodule:: music.api.api
:members:
:undoc-members:
:show-inheritance:
music.api.decorators module
---------------------------
.. automodule:: music.api.decorators
:members:
:undoc-members:
:show-inheritance:
music.api.fm module
-------------------
.. automodule:: music.api.fm
:members:
:undoc-members:
:show-inheritance:
music.api.player module
-----------------------
.. automodule:: music.api.player
:members:
:undoc-members:
:show-inheritance:
music.api.spotfm module
-----------------------
.. automodule:: music.api.spotfm
:members:
:undoc-members:
:show-inheritance:
music.api.spotify module
------------------------
.. automodule:: music.api.spotify
:members:
:undoc-members:
:show-inheritance:
music.api.tag module
--------------------
.. automodule:: music.api.tag
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: music.api
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,21 @@
music.auth package
==================
Submodules
----------
music.auth.auth module
----------------------
.. automodule:: music.auth.auth
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: music.auth
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,29 @@
music.cloud package
===================
Submodules
----------
music.cloud.function module
---------------------------
.. automodule:: music.cloud.function
:members:
:undoc-members:
:show-inheritance:
music.cloud.tasks module
------------------------
.. automodule:: music.cloud.tasks
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: music.cloud
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,29 @@
music.db package
================
Submodules
----------
music.db.database module
------------------------
.. automodule:: music.db.database
:members:
:undoc-members:
:show-inheritance:
music.db.part\_generator module
-------------------------------
.. automodule:: music.db.part_generator
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: music.db
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,45 @@
music.model package
===================
Submodules
----------
music.model.config module
-------------------------
.. automodule:: music.model.config
:members:
:undoc-members:
:show-inheritance:
music.model.playlist module
---------------------------
.. automodule:: music.model.playlist
:members:
:undoc-members:
:show-inheritance:
music.model.tag module
----------------------
.. automodule:: music.model.tag
:members:
:undoc-members:
:show-inheritance:
music.model.user module
-----------------------
.. automodule:: music.model.user
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: music.model
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,34 @@
music package
=============
Subpackages
-----------
.. toctree::
:maxdepth: 4
music.api
music.auth
music.cloud
music.db
music.model
music.tasks
Submodules
----------
music.music module
------------------
.. automodule:: music.music
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: music
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,45 @@
music.tasks package
===================
Submodules
----------
music.tasks.create\_playlist module
-----------------------------------
.. automodule:: music.tasks.create_playlist
:members:
:undoc-members:
:show-inheritance:
music.tasks.refresh\_lastfm\_stats module
-----------------------------------------
.. automodule:: music.tasks.refresh_lastfm_stats
:members:
:undoc-members:
:show-inheritance:
music.tasks.run\_user\_playlist module
--------------------------------------
.. automodule:: music.tasks.run_user_playlist
:members:
:undoc-members:
:show-inheritance:
music.tasks.update\_tag module
------------------------------
.. automodule:: music.tasks.update_tag
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: music.tasks
:members:
:undoc-members:
:show-inheritance:

701
_static/alabaster.css Normal file
View File

@ -0,0 +1,701 @@
@import url("basic.css");
/* -- page layout ----------------------------------------------------------- */
body {
font-family: Georgia, serif;
font-size: 17px;
background-color: #fff;
color: #000;
margin: 0;
padding: 0;
}
div.document {
width: 940px;
margin: 30px auto 0 auto;
}
div.documentwrapper {
float: left;
width: 100%;
}
div.bodywrapper {
margin: 0 0 0 220px;
}
div.sphinxsidebar {
width: 220px;
font-size: 14px;
line-height: 1.5;
}
hr {
border: 1px solid #B1B4B6;
}
div.body {
background-color: #fff;
color: #3E4349;
padding: 0 30px 0 30px;
}
div.body > .section {
text-align: left;
}
div.footer {
width: 940px;
margin: 20px auto 30px auto;
font-size: 14px;
color: #888;
text-align: right;
}
div.footer a {
color: #888;
}
p.caption {
font-family: inherit;
font-size: inherit;
}
div.relations {
display: none;
}
div.sphinxsidebar a {
color: #444;
text-decoration: none;
border-bottom: 1px dotted #999;
}
div.sphinxsidebar a:hover {
border-bottom: 1px solid #999;
}
div.sphinxsidebarwrapper {
padding: 18px 10px;
}
div.sphinxsidebarwrapper p.logo {
padding: 0;
margin: -10px 0 0 0px;
text-align: center;
}
div.sphinxsidebarwrapper h1.logo {
margin-top: -10px;
text-align: center;
margin-bottom: 5px;
text-align: left;
}
div.sphinxsidebarwrapper h1.logo-name {
margin-top: 0px;
}
div.sphinxsidebarwrapper p.blurb {
margin-top: 0;
font-style: normal;
}
div.sphinxsidebar h3,
div.sphinxsidebar h4 {
font-family: Georgia, serif;
color: #444;
font-size: 24px;
font-weight: normal;
margin: 0 0 5px 0;
padding: 0;
}
div.sphinxsidebar h4 {
font-size: 20px;
}
div.sphinxsidebar h3 a {
color: #444;
}
div.sphinxsidebar p.logo a,
div.sphinxsidebar h3 a,
div.sphinxsidebar p.logo a:hover,
div.sphinxsidebar h3 a:hover {
border: none;
}
div.sphinxsidebar p {
color: #555;
margin: 10px 0;
}
div.sphinxsidebar ul {
margin: 10px 0;
padding: 0;
color: #000;
}
div.sphinxsidebar ul li.toctree-l1 > a {
font-size: 120%;
}
div.sphinxsidebar ul li.toctree-l2 > a {
font-size: 110%;
}
div.sphinxsidebar input {
border: 1px solid #CCC;
font-family: Georgia, serif;
font-size: 1em;
}
div.sphinxsidebar hr {
border: none;
height: 1px;
color: #AAA;
background: #AAA;
text-align: left;
margin-left: 0;
width: 50%;
}
div.sphinxsidebar .badge {
border-bottom: none;
}
div.sphinxsidebar .badge:hover {
border-bottom: none;
}
/* To address an issue with donation coming after search */
div.sphinxsidebar h3.donation {
margin-top: 10px;
}
/* -- body styles ----------------------------------------------------------- */
a {
color: #004B6B;
text-decoration: underline;
}
a:hover {
color: #6D4100;
text-decoration: underline;
}
div.body h1,
div.body h2,
div.body h3,
div.body h4,
div.body h5,
div.body h6 {
font-family: Georgia, serif;
font-weight: normal;
margin: 30px 0px 10px 0px;
padding: 0;
}
div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; }
div.body h2 { font-size: 180%; }
div.body h3 { font-size: 150%; }
div.body h4 { font-size: 130%; }
div.body h5 { font-size: 100%; }
div.body h6 { font-size: 100%; }
a.headerlink {
color: #DDD;
padding: 0 4px;
text-decoration: none;
}
a.headerlink:hover {
color: #444;
background: #EAEAEA;
}
div.body p, div.body dd, div.body li {
line-height: 1.4em;
}
div.admonition {
margin: 20px 0px;
padding: 10px 30px;
background-color: #EEE;
border: 1px solid #CCC;
}
div.admonition tt.xref, div.admonition code.xref, div.admonition a tt {
background-color: #FBFBFB;
border-bottom: 1px solid #fafafa;
}
div.admonition p.admonition-title {
font-family: Georgia, serif;
font-weight: normal;
font-size: 24px;
margin: 0 0 10px 0;
padding: 0;
line-height: 1;
}
div.admonition p.last {
margin-bottom: 0;
}
div.highlight {
background-color: #fff;
}
dt:target, .highlight {
background: #FAF3E8;
}
div.warning {
background-color: #FCC;
border: 1px solid #FAA;
}
div.danger {
background-color: #FCC;
border: 1px solid #FAA;
-moz-box-shadow: 2px 2px 4px #D52C2C;
-webkit-box-shadow: 2px 2px 4px #D52C2C;
box-shadow: 2px 2px 4px #D52C2C;
}
div.error {
background-color: #FCC;
border: 1px solid #FAA;
-moz-box-shadow: 2px 2px 4px #D52C2C;
-webkit-box-shadow: 2px 2px 4px #D52C2C;
box-shadow: 2px 2px 4px #D52C2C;
}
div.caution {
background-color: #FCC;
border: 1px solid #FAA;
}
div.attention {
background-color: #FCC;
border: 1px solid #FAA;
}
div.important {
background-color: #EEE;
border: 1px solid #CCC;
}
div.note {
background-color: #EEE;
border: 1px solid #CCC;
}
div.tip {
background-color: #EEE;
border: 1px solid #CCC;
}
div.hint {
background-color: #EEE;
border: 1px solid #CCC;
}
div.seealso {
background-color: #EEE;
border: 1px solid #CCC;
}
div.topic {
background-color: #EEE;
}
p.admonition-title {
display: inline;
}
p.admonition-title:after {
content: ":";
}
pre, tt, code {
font-family: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace;
font-size: 0.9em;
}
.hll {
background-color: #FFC;
margin: 0 -12px;
padding: 0 12px;
display: block;
}
img.screenshot {
}
tt.descname, tt.descclassname, code.descname, code.descclassname {
font-size: 0.95em;
}
tt.descname, code.descname {
padding-right: 0.08em;
}
img.screenshot {
-moz-box-shadow: 2px 2px 4px #EEE;
-webkit-box-shadow: 2px 2px 4px #EEE;
box-shadow: 2px 2px 4px #EEE;
}
table.docutils {
border: 1px solid #888;
-moz-box-shadow: 2px 2px 4px #EEE;
-webkit-box-shadow: 2px 2px 4px #EEE;
box-shadow: 2px 2px 4px #EEE;
}
table.docutils td, table.docutils th {
border: 1px solid #888;
padding: 0.25em 0.7em;
}
table.field-list, table.footnote {
border: none;
-moz-box-shadow: none;
-webkit-box-shadow: none;
box-shadow: none;
}
table.footnote {
margin: 15px 0;
width: 100%;
border: 1px solid #EEE;
background: #FDFDFD;
font-size: 0.9em;
}
table.footnote + table.footnote {
margin-top: -15px;
border-top: none;
}
table.field-list th {
padding: 0 0.8em 0 0;
}
table.field-list td {
padding: 0;
}
table.field-list p {
margin-bottom: 0.8em;
}
/* Cloned from
* https://github.com/sphinx-doc/sphinx/commit/ef60dbfce09286b20b7385333d63a60321784e68
*/
.field-name {
-moz-hyphens: manual;
-ms-hyphens: manual;
-webkit-hyphens: manual;
hyphens: manual;
}
table.footnote td.label {
width: .1px;
padding: 0.3em 0 0.3em 0.5em;
}
table.footnote td {
padding: 0.3em 0.5em;
}
dl {
margin: 0;
padding: 0;
}
dl dd {
margin-left: 30px;
}
blockquote {
margin: 0 0 0 30px;
padding: 0;
}
ul, ol {
/* Matches the 30px from the narrow-screen "li > ul" selector below */
margin: 10px 0 10px 30px;
padding: 0;
}
pre {
background: #EEE;
padding: 7px 30px;
margin: 15px 0px;
line-height: 1.3em;
}
div.viewcode-block:target {
background: #ffd;
}
dl pre, blockquote pre, li pre {
margin-left: 0;
padding-left: 30px;
}
tt, code {
background-color: #ecf0f3;
color: #222;
/* padding: 1px 2px; */
}
tt.xref, code.xref, a tt {
background-color: #FBFBFB;
border-bottom: 1px solid #fff;
}
a.reference {
text-decoration: none;
border-bottom: 1px dotted #004B6B;
}
/* Don't put an underline on images */
a.image-reference, a.image-reference:hover {
border-bottom: none;
}
a.reference:hover {
border-bottom: 1px solid #6D4100;
}
a.footnote-reference {
text-decoration: none;
font-size: 0.7em;
vertical-align: top;
border-bottom: 1px dotted #004B6B;
}
a.footnote-reference:hover {
border-bottom: 1px solid #6D4100;
}
a:hover tt, a:hover code {
background: #EEE;
}
@media screen and (max-width: 870px) {
div.sphinxsidebar {
display: none;
}
div.document {
width: 100%;
}
div.documentwrapper {
margin-left: 0;
margin-top: 0;
margin-right: 0;
margin-bottom: 0;
}
div.bodywrapper {
margin-top: 0;
margin-right: 0;
margin-bottom: 0;
margin-left: 0;
}
ul {
margin-left: 0;
}
li > ul {
/* Matches the 30px from the "ul, ol" selector above */
margin-left: 30px;
}
.document {
width: auto;
}
.footer {
width: auto;
}
.bodywrapper {
margin: 0;
}
.footer {
width: auto;
}
.github {
display: none;
}
}
@media screen and (max-width: 875px) {
body {
margin: 0;
padding: 20px 30px;
}
div.documentwrapper {
float: none;
background: #fff;
}
div.sphinxsidebar {
display: block;
float: none;
width: 102.5%;
margin: 50px -30px -20px -30px;
padding: 10px 20px;
background: #333;
color: #FFF;
}
div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p,
div.sphinxsidebar h3 a {
color: #fff;
}
div.sphinxsidebar a {
color: #AAA;
}
div.sphinxsidebar p.logo {
display: none;
}
div.document {
width: 100%;
margin: 0;
}
div.footer {
display: none;
}
div.bodywrapper {
margin: 0;
}
div.body {
min-height: 0;
padding: 0;
}
.rtd_doc_footer {
display: none;
}
.document {
width: auto;
}
.footer {
width: auto;
}
.footer {
width: auto;
}
.github {
display: none;
}
}
/* misc. */
.revsys-inline {
display: none!important;
}
/* Make nested-list/multi-paragraph items look better in Releases changelog
* pages. Without this, docutils' magical list fuckery causes inconsistent
* formatting between different release sub-lists.
*/
div#changelog > div.section > ul > li > p:only-child {
margin-bottom: 0;
}
/* Hide fugly table cell borders in ..bibliography:: directive output */
table.docutils.citation, table.docutils.citation td, table.docutils.citation th {
border: none;
/* Below needed in some edge cases; if not applied, bottom shadows appear */
-moz-box-shadow: none;
-webkit-box-shadow: none;
box-shadow: none;
}
/* relbar */
.related {
line-height: 30px;
width: 100%;
font-size: 0.9rem;
}
.related.top {
border-bottom: 1px solid #EEE;
margin-bottom: 20px;
}
.related.bottom {
border-top: 1px solid #EEE;
}
.related ul {
padding: 0;
margin: 0;
list-style: none;
}
.related li {
display: inline;
}
nav#rellinks {
float: right;
}
nav#rellinks li+li:before {
content: "|";
}
nav#breadcrumbs li+li:before {
content: "\00BB";
}
/* Hide certain items when printing */
@media print {
div.related {
display: none;
}
}

856
_static/basic.css Normal file
View File

@ -0,0 +1,856 @@
/*
* basic.css
* ~~~~~~~~~
*
* Sphinx stylesheet -- basic theme.
*
* :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS.
* :license: BSD, see LICENSE for details.
*
*/
/* -- main layout ----------------------------------------------------------- */
div.clearer {
clear: both;
}
div.section::after {
display: block;
content: '';
clear: left;
}
/* -- relbar ---------------------------------------------------------------- */
div.related {
width: 100%;
font-size: 90%;
}
div.related h3 {
display: none;
}
div.related ul {
margin: 0;
padding: 0 0 0 10px;
list-style: none;
}
div.related li {
display: inline;
}
div.related li.right {
float: right;
margin-right: 5px;
}
/* -- sidebar --------------------------------------------------------------- */
div.sphinxsidebarwrapper {
padding: 10px 5px 0 10px;
}
div.sphinxsidebar {
float: left;
width: 230px;
margin-left: -100%;
font-size: 90%;
word-wrap: break-word;
overflow-wrap : break-word;
}
div.sphinxsidebar ul {
list-style: none;
}
div.sphinxsidebar ul ul,
div.sphinxsidebar ul.want-points {
margin-left: 20px;
list-style: square;
}
div.sphinxsidebar ul ul {
margin-top: 0;
margin-bottom: 0;
}
div.sphinxsidebar form {
margin-top: 10px;
}
div.sphinxsidebar input {
border: 1px solid #98dbcc;
font-family: sans-serif;
font-size: 1em;
}
div.sphinxsidebar #searchbox form.search {
overflow: hidden;
}
div.sphinxsidebar #searchbox input[type="text"] {
float: left;
width: 80%;
padding: 0.25em;
box-sizing: border-box;
}
div.sphinxsidebar #searchbox input[type="submit"] {
float: left;
width: 20%;
border-left: none;
padding: 0.25em;
box-sizing: border-box;
}
img {
border: 0;
max-width: 100%;
}
/* -- search page ----------------------------------------------------------- */
ul.search {
margin: 10px 0 0 20px;
padding: 0;
}
ul.search li {
padding: 5px 0 5px 20px;
background-image: url(file.png);
background-repeat: no-repeat;
background-position: 0 7px;
}
ul.search li a {
font-weight: bold;
}
ul.search li div.context {
color: #888;
margin: 2px 0 0 30px;
text-align: left;
}
ul.keywordmatches li.goodmatch a {
font-weight: bold;
}
/* -- index page ------------------------------------------------------------ */
table.contentstable {
width: 90%;
margin-left: auto;
margin-right: auto;
}
table.contentstable p.biglink {
line-height: 150%;
}
a.biglink {
font-size: 1.3em;
}
span.linkdescr {
font-style: italic;
padding-top: 5px;
font-size: 90%;
}
/* -- general index --------------------------------------------------------- */
table.indextable {
width: 100%;
}
table.indextable td {
text-align: left;
vertical-align: top;
}
table.indextable ul {
margin-top: 0;
margin-bottom: 0;
list-style-type: none;
}
table.indextable > tbody > tr > td > ul {
padding-left: 0em;
}
table.indextable tr.pcap {
height: 10px;
}
table.indextable tr.cap {
margin-top: 10px;
background-color: #f2f2f2;
}
img.toggler {
margin-right: 3px;
margin-top: 3px;
cursor: pointer;
}
div.modindex-jumpbox {
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
margin: 1em 0 1em 0;
padding: 0.4em;
}
div.genindex-jumpbox {
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
margin: 1em 0 1em 0;
padding: 0.4em;
}
/* -- domain module index --------------------------------------------------- */
table.modindextable td {
padding: 2px;
border-collapse: collapse;
}
/* -- general body styles --------------------------------------------------- */
div.body {
min-width: 450px;
max-width: 800px;
}
div.body p, div.body dd, div.body li, div.body blockquote {
-moz-hyphens: auto;
-ms-hyphens: auto;
-webkit-hyphens: auto;
hyphens: auto;
}
a.headerlink {
visibility: hidden;
}
a.brackets:before,
span.brackets > a:before{
content: "[";
}
a.brackets:after,
span.brackets > a:after {
content: "]";
}
h1:hover > a.headerlink,
h2:hover > a.headerlink,
h3:hover > a.headerlink,
h4:hover > a.headerlink,
h5:hover > a.headerlink,
h6:hover > a.headerlink,
dt:hover > a.headerlink,
caption:hover > a.headerlink,
p.caption:hover > a.headerlink,
div.code-block-caption:hover > a.headerlink {
visibility: visible;
}
div.body p.caption {
text-align: inherit;
}
div.body td {
text-align: left;
}
.first {
margin-top: 0 !important;
}
p.rubric {
margin-top: 30px;
font-weight: bold;
}
img.align-left, .figure.align-left, object.align-left {
clear: left;
float: left;
margin-right: 1em;
}
img.align-right, .figure.align-right, object.align-right {
clear: right;
float: right;
margin-left: 1em;
}
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
img.align-default, .figure.align-default {
display: block;
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
.align-default {
text-align: center;
}
.align-right {
text-align: right;
}
/* -- sidebars -------------------------------------------------------------- */
div.sidebar {
margin: 0 0 0.5em 1em;
border: 1px solid #ddb;
padding: 7px;
background-color: #ffe;
width: 40%;
float: right;
clear: right;
overflow-x: auto;
}
p.sidebar-title {
font-weight: bold;
}
div.admonition, div.topic, blockquote {
clear: left;
}
/* -- topics ---------------------------------------------------------------- */
div.topic {
border: 1px solid #ccc;
padding: 7px;
margin: 10px 0 10px 0;
}
p.topic-title {
font-size: 1.1em;
font-weight: bold;
margin-top: 10px;
}
/* -- admonitions ----------------------------------------------------------- */
div.admonition {
margin-top: 10px;
margin-bottom: 10px;
padding: 7px;
}
div.admonition dt {
font-weight: bold;
}
p.admonition-title {
margin: 0px 10px 5px 0px;
font-weight: bold;
}
div.body p.centered {
text-align: center;
margin-top: 25px;
}
/* -- content of sidebars/topics/admonitions -------------------------------- */
div.sidebar > :last-child,
div.topic > :last-child,
div.admonition > :last-child {
margin-bottom: 0;
}
div.sidebar::after,
div.topic::after,
div.admonition::after,
blockquote::after {
display: block;
content: '';
clear: both;
}
/* -- tables ---------------------------------------------------------------- */
table.docutils {
margin-top: 10px;
margin-bottom: 10px;
border: 0;
border-collapse: collapse;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
table.align-default {
margin-left: auto;
margin-right: auto;
}
table caption span.caption-number {
font-style: italic;
}
table caption span.caption-text {
}
table.docutils td, table.docutils th {
padding: 1px 8px 1px 5px;
border-top: 0;
border-left: 0;
border-right: 0;
border-bottom: 1px solid #aaa;
}
table.footnote td, table.footnote th {
border: 0 !important;
}
th {
text-align: left;
padding-right: 5px;
}
table.citation {
border-left: solid 1px gray;
margin-left: 1px;
}
table.citation td {
border-bottom: none;
}
th > :first-child,
td > :first-child {
margin-top: 0px;
}
th > :last-child,
td > :last-child {
margin-bottom: 0px;
}
/* -- figures --------------------------------------------------------------- */
div.figure {
margin: 0.5em;
padding: 0.5em;
}
div.figure p.caption {
padding: 0.3em;
}
div.figure p.caption span.caption-number {
font-style: italic;
}
div.figure p.caption span.caption-text {
}
/* -- field list styles ----------------------------------------------------- */
table.field-list td, table.field-list th {
border: 0 !important;
}
.field-list ul {
margin: 0;
padding-left: 1em;
}
.field-list p {
margin: 0;
}
.field-name {
-moz-hyphens: manual;
-ms-hyphens: manual;
-webkit-hyphens: manual;
hyphens: manual;
}
/* -- hlist styles ---------------------------------------------------------- */
table.hlist {
margin: 1em 0;
}
table.hlist td {
vertical-align: top;
}
/* -- other body styles ----------------------------------------------------- */
ol.arabic {
list-style: decimal;
}
ol.loweralpha {
list-style: lower-alpha;
}
ol.upperalpha {
list-style: upper-alpha;
}
ol.lowerroman {
list-style: lower-roman;
}
ol.upperroman {
list-style: upper-roman;
}
:not(li) > ol > li:first-child > :first-child,
:not(li) > ul > li:first-child > :first-child {
margin-top: 0px;
}
:not(li) > ol > li:last-child > :last-child,
:not(li) > ul > li:last-child > :last-child {
margin-bottom: 0px;
}
ol.simple ol p,
ol.simple ul p,
ul.simple ol p,
ul.simple ul p {
margin-top: 0;
}
ol.simple > li:not(:first-child) > p,
ul.simple > li:not(:first-child) > p {
margin-top: 0;
}
ol.simple p,
ul.simple p {
margin-bottom: 0;
}
dl.footnote > dt,
dl.citation > dt {
float: left;
margin-right: 0.5em;
}
dl.footnote > dd,
dl.citation > dd {
margin-bottom: 0em;
}
dl.footnote > dd:after,
dl.citation > dd:after {
content: "";
clear: both;
}
dl.field-list {
display: grid;
grid-template-columns: fit-content(30%) auto;
}
dl.field-list > dt {
font-weight: bold;
word-break: break-word;
padding-left: 0.5em;
padding-right: 5px;
}
dl.field-list > dt:after {
content: ":";
}
dl.field-list > dd {
padding-left: 0.5em;
margin-top: 0em;
margin-left: 0em;
margin-bottom: 0em;
}
dl {
margin-bottom: 15px;
}
dd > :first-child {
margin-top: 0px;
}
dd ul, dd table {
margin-bottom: 10px;
}
dd {
margin-top: 3px;
margin-bottom: 10px;
margin-left: 30px;
}
dl > dd:last-child,
dl > dd:last-child > :last-child {
margin-bottom: 0;
}
dt:target, span.highlighted {
background-color: #fbe54e;
}
rect.highlighted {
fill: #fbe54e;
}
dl.glossary dt {
font-weight: bold;
font-size: 1.1em;
}
.optional {
font-size: 1.3em;
}
.sig-paren {
font-size: larger;
}
.versionmodified {
font-style: italic;
}
.system-message {
background-color: #fda;
padding: 5px;
border: 3px solid red;
}
.footnote:target {
background-color: #ffa;
}
.line-block {
display: block;
margin-top: 1em;
margin-bottom: 1em;
}
.line-block .line-block {
margin-top: 0;
margin-bottom: 0;
margin-left: 1.5em;
}
.guilabel, .menuselection {
font-family: sans-serif;
}
.accelerator {
text-decoration: underline;
}
.classifier {
font-style: oblique;
}
.classifier:before {
font-style: normal;
margin: 0.5em;
content: ":";
}
abbr, acronym {
border-bottom: dotted 1px;
cursor: help;
}
/* -- code displays --------------------------------------------------------- */
pre {
overflow: auto;
overflow-y: hidden; /* fixes display issues on Chrome browsers */
}
pre, div[class*="highlight-"] {
clear: both;
}
span.pre {
-moz-hyphens: none;
-ms-hyphens: none;
-webkit-hyphens: none;
hyphens: none;
}
div[class*="highlight-"] {
margin: 1em 0;
}
td.linenos pre {
border: 0;
background-color: transparent;
color: #aaa;
}
table.highlighttable {
display: block;
}
table.highlighttable tbody {
display: block;
}
table.highlighttable tr {
display: flex;
}
table.highlighttable td {
margin: 0;
padding: 0;
}
table.highlighttable td.linenos {
padding-right: 0.5em;
}
table.highlighttable td.code {
flex: 1;
overflow: hidden;
}
.highlight .hll {
display: block;
}
div.highlight pre,
table.highlighttable pre {
margin: 0;
}
div.code-block-caption + div {
margin-top: 0;
}
div.code-block-caption {
margin-top: 1em;
padding: 2px 5px;
font-size: small;
}
div.code-block-caption code {
background-color: transparent;
}
table.highlighttable td.linenos,
span.linenos,
div.doctest > div.highlight span.gp { /* gp: Generic.Prompt */
user-select: none;
}
div.code-block-caption span.caption-number {
padding: 0.1em 0.3em;
font-style: italic;
}
div.code-block-caption span.caption-text {
}
div.literal-block-wrapper {
margin: 1em 0;
}
code.descname {
background-color: transparent;
font-weight: bold;
font-size: 1.2em;
}
code.descclassname {
background-color: transparent;
}
code.xref, a code {
background-color: transparent;
font-weight: bold;
}
h1 code, h2 code, h3 code, h4 code, h5 code, h6 code {
background-color: transparent;
}
.viewcode-link {
float: right;
}
.viewcode-back {
float: right;
font-family: sans-serif;
}
div.viewcode-block:target {
margin: -1px -10px;
padding: 0 10px;
}
/* -- math display ---------------------------------------------------------- */
img.math {
vertical-align: middle;
}
div.body div.math p {
text-align: center;
}
span.eqno {
float: right;
}
span.eqno a.headerlink {
position: absolute;
z-index: 1;
}
div.math:hover a.headerlink {
visibility: visible;
}
/* -- printout stylesheet --------------------------------------------------- */
@media print {
div.document,
div.documentwrapper,
div.bodywrapper {
margin: 0 !important;
width: 100%;
}
div.sphinxsidebar,
div.related,
div.footer,
#top-link {
display: none;
}
}

1
_static/custom.css Normal file
View File

@ -0,0 +1 @@
/* This file intentionally left blank. */

321
_static/doctools.js Normal file
View File

@ -0,0 +1,321 @@
/*
* doctools.js
* ~~~~~~~~~~~
*
* Sphinx JavaScript utilities for all documentation.
*
* :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS.
* :license: BSD, see LICENSE for details.
*
*/
/**
* select a different prefix for underscore
*/
$u = _.noConflict();
/**
* make the code below compatible with browsers without
* an installed firebug like debugger
if (!window.console || !console.firebug) {
var names = ["log", "debug", "info", "warn", "error", "assert", "dir",
"dirxml", "group", "groupEnd", "time", "timeEnd", "count", "trace",
"profile", "profileEnd"];
window.console = {};
for (var i = 0; i < names.length; ++i)
window.console[names[i]] = function() {};
}
*/
/**
* small helper function to urldecode strings
*
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#Decoding_query_parameters_from_a_URL
*/
jQuery.urldecode = function(x) {
if (!x) {
return x
}
return decodeURIComponent(x.replace(/\+/g, ' '));
};
/**
* small helper function to urlencode strings
*/
jQuery.urlencode = encodeURIComponent;
/**
* This function returns the parsed url parameters of the
* current request. Multiple values per key are supported,
* it will always return arrays of strings for the value parts.
*/
jQuery.getQueryParameters = function(s) {
if (typeof s === 'undefined')
s = document.location.search;
var parts = s.substr(s.indexOf('?') + 1).split('&');
var result = {};
for (var i = 0; i < parts.length; i++) {
var tmp = parts[i].split('=', 2);
var key = jQuery.urldecode(tmp[0]);
var value = jQuery.urldecode(tmp[1]);
if (key in result)
result[key].push(value);
else
result[key] = [value];
}
return result;
};
/**
* highlight a given string on a jquery object by wrapping it in
* span elements with the given class name.
*/
jQuery.fn.highlightText = function(text, className) {
function highlight(node, addItems) {
if (node.nodeType === 3) {
var val = node.nodeValue;
var pos = val.toLowerCase().indexOf(text);
if (pos >= 0 &&
!jQuery(node.parentNode).hasClass(className) &&
!jQuery(node.parentNode).hasClass("nohighlight")) {
var span;
var isInSVG = jQuery(node).closest("body, svg, foreignObject").is("svg");
if (isInSVG) {
span = document.createElementNS("http://www.w3.org/2000/svg", "tspan");
} else {
span = document.createElement("span");
span.className = className;
}
span.appendChild(document.createTextNode(val.substr(pos, text.length)));
node.parentNode.insertBefore(span, node.parentNode.insertBefore(
document.createTextNode(val.substr(pos + text.length)),
node.nextSibling));
node.nodeValue = val.substr(0, pos);
if (isInSVG) {
var rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
var bbox = node.parentElement.getBBox();
rect.x.baseVal.value = bbox.x;
rect.y.baseVal.value = bbox.y;
rect.width.baseVal.value = bbox.width;
rect.height.baseVal.value = bbox.height;
rect.setAttribute('class', className);
addItems.push({
"parent": node.parentNode,
"target": rect});
}
}
}
else if (!jQuery(node).is("button, select, textarea")) {
jQuery.each(node.childNodes, function() {
highlight(this, addItems);
});
}
}
var addItems = [];
var result = this.each(function() {
highlight(this, addItems);
});
for (var i = 0; i < addItems.length; ++i) {
jQuery(addItems[i].parent).before(addItems[i].target);
}
return result;
};
/*
* backward compatibility for jQuery.browser
* This will be supported until firefox bug is fixed.
*/
if (!jQuery.browser) {
jQuery.uaMatch = function(ua) {
ua = ua.toLowerCase();
var match = /(chrome)[ \/]([\w.]+)/.exec(ua) ||
/(webkit)[ \/]([\w.]+)/.exec(ua) ||
/(opera)(?:.*version|)[ \/]([\w.]+)/.exec(ua) ||
/(msie) ([\w.]+)/.exec(ua) ||
ua.indexOf("compatible") < 0 && /(mozilla)(?:.*? rv:([\w.]+)|)/.exec(ua) ||
[];
return {
browser: match[ 1 ] || "",
version: match[ 2 ] || "0"
};
};
jQuery.browser = {};
jQuery.browser[jQuery.uaMatch(navigator.userAgent).browser] = true;
}
/**
* Small JavaScript module for the documentation.
*/
var Documentation = {
init : function() {
this.fixFirefoxAnchorBug();
this.highlightSearchWords();
this.initIndexTable();
if (DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) {
this.initOnKeyListeners();
}
},
/**
* i18n support
*/
TRANSLATIONS : {},
PLURAL_EXPR : function(n) { return n === 1 ? 0 : 1; },
LOCALE : 'unknown',
// gettext and ngettext don't access this so that the functions
// can safely bound to a different name (_ = Documentation.gettext)
gettext : function(string) {
var translated = Documentation.TRANSLATIONS[string];
if (typeof translated === 'undefined')
return string;
return (typeof translated === 'string') ? translated : translated[0];
},
ngettext : function(singular, plural, n) {
var translated = Documentation.TRANSLATIONS[singular];
if (typeof translated === 'undefined')
return (n == 1) ? singular : plural;
return translated[Documentation.PLURALEXPR(n)];
},
addTranslations : function(catalog) {
for (var key in catalog.messages)
this.TRANSLATIONS[key] = catalog.messages[key];
this.PLURAL_EXPR = new Function('n', 'return +(' + catalog.plural_expr + ')');
this.LOCALE = catalog.locale;
},
/**
* add context elements like header anchor links
*/
addContextElements : function() {
$('div[id] > :header:first').each(function() {
$('<a class="headerlink">\u00B6</a>').
attr('href', '#' + this.id).
attr('title', _('Permalink to this headline')).
appendTo(this);
});
$('dt[id]').each(function() {
$('<a class="headerlink">\u00B6</a>').
attr('href', '#' + this.id).
attr('title', _('Permalink to this definition')).
appendTo(this);
});
},
/**
* workaround a firefox stupidity
* see: https://bugzilla.mozilla.org/show_bug.cgi?id=645075
*/
fixFirefoxAnchorBug : function() {
if (document.location.hash && $.browser.mozilla)
window.setTimeout(function() {
document.location.href += '';
}, 10);
},
/**
* highlight the search words provided in the url in the text
*/
highlightSearchWords : function() {
var params = $.getQueryParameters();
var terms = (params.highlight) ? params.highlight[0].split(/\s+/) : [];
if (terms.length) {
var body = $('div.body');
if (!body.length) {
body = $('body');
}
window.setTimeout(function() {
$.each(terms, function() {
body.highlightText(this.toLowerCase(), 'highlighted');
});
}, 10);
$('<p class="highlight-link"><a href="javascript:Documentation.' +
'hideSearchWords()">' + _('Hide Search Matches') + '</a></p>')
.appendTo($('#searchbox'));
}
},
/**
* init the domain index toggle buttons
*/
initIndexTable : function() {
var togglers = $('img.toggler').click(function() {
var src = $(this).attr('src');
var idnum = $(this).attr('id').substr(7);
$('tr.cg-' + idnum).toggle();
if (src.substr(-9) === 'minus.png')
$(this).attr('src', src.substr(0, src.length-9) + 'plus.png');
else
$(this).attr('src', src.substr(0, src.length-8) + 'minus.png');
}).css('display', '');
if (DOCUMENTATION_OPTIONS.COLLAPSE_INDEX) {
togglers.click();
}
},
/**
* helper function to hide the search marks again
*/
hideSearchWords : function() {
$('#searchbox .highlight-link').fadeOut(300);
$('span.highlighted').removeClass('highlighted');
},
/**
* make the url absolute
*/
makeURL : function(relativeURL) {
return DOCUMENTATION_OPTIONS.URL_ROOT + '/' + relativeURL;
},
/**
* get the current relative url
*/
getCurrentURL : function() {
var path = document.location.pathname;
var parts = path.split(/\//);
$.each(DOCUMENTATION_OPTIONS.URL_ROOT.split(/\//), function() {
if (this === '..')
parts.pop();
});
var url = parts.join('/');
return path.substring(url.lastIndexOf('/') + 1, path.length - 1);
},
initOnKeyListeners: function() {
$(document).keydown(function(event) {
var activeElementType = document.activeElement.tagName;
// don't navigate when in search box, textarea, dropdown or button
if (activeElementType !== 'TEXTAREA' && activeElementType !== 'INPUT' && activeElementType !== 'SELECT'
&& activeElementType !== 'BUTTON' && !event.altKey && !event.ctrlKey && !event.metaKey
&& !event.shiftKey) {
switch (event.keyCode) {
case 37: // left
var prevHref = $('link[rel="prev"]').prop('href');
if (prevHref) {
window.location.href = prevHref;
return false;
}
case 39: // right
var nextHref = $('link[rel="next"]').prop('href');
if (nextHref) {
window.location.href = nextHref;
return false;
}
}
}
});
}
};
// quick alias for translations
_ = Documentation.gettext;
$(document).ready(function() {
Documentation.init();
});

View File

@ -0,0 +1,12 @@
var DOCUMENTATION_OPTIONS = {
URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'),
VERSION: '',
LANGUAGE: 'None',
COLLAPSE_INDEX: false,
BUILDER: 'html',
FILE_SUFFIX: '.html',
LINK_SUFFIX: '.html',
HAS_SOURCE: true,
SOURCELINK_SUFFIX: '.txt',
NAVIGATION_WITH_KEYS: false
};

BIN
_static/file.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

10872
_static/jquery-3.5.1.js vendored Normal file

File diff suppressed because it is too large Load Diff

2
_static/jquery.js vendored Normal file

File diff suppressed because one or more lines are too long

297
_static/language_data.js Normal file
View File

@ -0,0 +1,297 @@
/*
* language_data.js
* ~~~~~~~~~~~~~~~~
*
* This script contains the language-specific data used by searchtools.js,
* namely the list of stopwords, stemmer, scorer and splitter.
*
* :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS.
* :license: BSD, see LICENSE for details.
*
*/
var stopwords = ["a","and","are","as","at","be","but","by","for","if","in","into","is","it","near","no","not","of","on","or","such","that","the","their","then","there","these","they","this","to","was","will","with"];
/* Non-minified version is copied as a separate JS file, is available */
/**
* Porter Stemmer
*/
var Stemmer = function() {
var step2list = {
ational: 'ate',
tional: 'tion',
enci: 'ence',
anci: 'ance',
izer: 'ize',
bli: 'ble',
alli: 'al',
entli: 'ent',
eli: 'e',
ousli: 'ous',
ization: 'ize',
ation: 'ate',
ator: 'ate',
alism: 'al',
iveness: 'ive',
fulness: 'ful',
ousness: 'ous',
aliti: 'al',
iviti: 'ive',
biliti: 'ble',
logi: 'log'
};
var step3list = {
icate: 'ic',
ative: '',
alize: 'al',
iciti: 'ic',
ical: 'ic',
ful: '',
ness: ''
};
var c = "[^aeiou]"; // consonant
var v = "[aeiouy]"; // vowel
var C = c + "[^aeiouy]*"; // consonant sequence
var V = v + "[aeiou]*"; // vowel sequence
var mgr0 = "^(" + C + ")?" + V + C; // [C]VC... is m>0
var meq1 = "^(" + C + ")?" + V + C + "(" + V + ")?$"; // [C]VC[V] is m=1
var mgr1 = "^(" + C + ")?" + V + C + V + C; // [C]VCVC... is m>1
var s_v = "^(" + C + ")?" + v; // vowel in stem
this.stemWord = function (w) {
var stem;
var suffix;
var firstch;
var origword = w;
if (w.length < 3)
return w;
var re;
var re2;
var re3;
var re4;
firstch = w.substr(0,1);
if (firstch == "y")
w = firstch.toUpperCase() + w.substr(1);
// Step 1a
re = /^(.+?)(ss|i)es$/;
re2 = /^(.+?)([^s])s$/;
if (re.test(w))
w = w.replace(re,"$1$2");
else if (re2.test(w))
w = w.replace(re2,"$1$2");
// Step 1b
re = /^(.+?)eed$/;
re2 = /^(.+?)(ed|ing)$/;
if (re.test(w)) {
var fp = re.exec(w);
re = new RegExp(mgr0);
if (re.test(fp[1])) {
re = /.$/;
w = w.replace(re,"");
}
}
else if (re2.test(w)) {
var fp = re2.exec(w);
stem = fp[1];
re2 = new RegExp(s_v);
if (re2.test(stem)) {
w = stem;
re2 = /(at|bl|iz)$/;
re3 = new RegExp("([^aeiouylsz])\\1$");
re4 = new RegExp("^" + C + v + "[^aeiouwxy]$");
if (re2.test(w))
w = w + "e";
else if (re3.test(w)) {
re = /.$/;
w = w.replace(re,"");
}
else if (re4.test(w))
w = w + "e";
}
}
// Step 1c
re = /^(.+?)y$/;
if (re.test(w)) {
var fp = re.exec(w);
stem = fp[1];
re = new RegExp(s_v);
if (re.test(stem))
w = stem + "i";
}
// Step 2
re = /^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/;
if (re.test(w)) {
var fp = re.exec(w);
stem = fp[1];
suffix = fp[2];
re = new RegExp(mgr0);
if (re.test(stem))
w = stem + step2list[suffix];
}
// Step 3
re = /^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/;
if (re.test(w)) {
var fp = re.exec(w);
stem = fp[1];
suffix = fp[2];
re = new RegExp(mgr0);
if (re.test(stem))
w = stem + step3list[suffix];
}
// Step 4
re = /^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/;
re2 = /^(.+?)(s|t)(ion)$/;
if (re.test(w)) {
var fp = re.exec(w);
stem = fp[1];
re = new RegExp(mgr1);
if (re.test(stem))
w = stem;
}
else if (re2.test(w)) {
var fp = re2.exec(w);
stem = fp[1] + fp[2];
re2 = new RegExp(mgr1);
if (re2.test(stem))
w = stem;
}
// Step 5
re = /^(.+?)e$/;
if (re.test(w)) {
var fp = re.exec(w);
stem = fp[1];
re = new RegExp(mgr1);
re2 = new RegExp(meq1);
re3 = new RegExp("^" + C + v + "[^aeiouwxy]$");
if (re.test(stem) || (re2.test(stem) && !(re3.test(stem))))
w = stem;
}
re = /ll$/;
re2 = new RegExp(mgr1);
if (re.test(w) && re2.test(w)) {
re = /.$/;
w = w.replace(re,"");
}
// and turn initial Y back to y
if (firstch == "y")
w = firstch.toLowerCase() + w.substr(1);
return w;
}
}
var splitChars = (function() {
var result = {};
var singles = [96, 180, 187, 191, 215, 247, 749, 885, 903, 907, 909, 930, 1014, 1648,
1748, 1809, 2416, 2473, 2481, 2526, 2601, 2609, 2612, 2615, 2653, 2702,
2706, 2729, 2737, 2740, 2857, 2865, 2868, 2910, 2928, 2948, 2961, 2971,
2973, 3085, 3089, 3113, 3124, 3213, 3217, 3241, 3252, 3295, 3341, 3345,
3369, 3506, 3516, 3633, 3715, 3721, 3736, 3744, 3748, 3750, 3756, 3761,
3781, 3912, 4239, 4347, 4681, 4695, 4697, 4745, 4785, 4799, 4801, 4823,
4881, 5760, 5901, 5997, 6313, 7405, 8024, 8026, 8028, 8030, 8117, 8125,
8133, 8181, 8468, 8485, 8487, 8489, 8494, 8527, 11311, 11359, 11687, 11695,
11703, 11711, 11719, 11727, 11735, 12448, 12539, 43010, 43014, 43019, 43587,
43696, 43713, 64286, 64297, 64311, 64317, 64319, 64322, 64325, 65141];
var i, j, start, end;
for (i = 0; i < singles.length; i++) {
result[singles[i]] = true;
}
var ranges = [[0, 47], [58, 64], [91, 94], [123, 169], [171, 177], [182, 184], [706, 709],
[722, 735], [741, 747], [751, 879], [888, 889], [894, 901], [1154, 1161],
[1318, 1328], [1367, 1368], [1370, 1376], [1416, 1487], [1515, 1519], [1523, 1568],
[1611, 1631], [1642, 1645], [1750, 1764], [1767, 1773], [1789, 1790], [1792, 1807],
[1840, 1868], [1958, 1968], [1970, 1983], [2027, 2035], [2038, 2041], [2043, 2047],
[2070, 2073], [2075, 2083], [2085, 2087], [2089, 2307], [2362, 2364], [2366, 2383],
[2385, 2391], [2402, 2405], [2419, 2424], [2432, 2436], [2445, 2446], [2449, 2450],
[2483, 2485], [2490, 2492], [2494, 2509], [2511, 2523], [2530, 2533], [2546, 2547],
[2554, 2564], [2571, 2574], [2577, 2578], [2618, 2648], [2655, 2661], [2672, 2673],
[2677, 2692], [2746, 2748], [2750, 2767], [2769, 2783], [2786, 2789], [2800, 2820],
[2829, 2830], [2833, 2834], [2874, 2876], [2878, 2907], [2914, 2917], [2930, 2946],
[2955, 2957], [2966, 2968], [2976, 2978], [2981, 2983], [2987, 2989], [3002, 3023],
[3025, 3045], [3059, 3076], [3130, 3132], [3134, 3159], [3162, 3167], [3170, 3173],
[3184, 3191], [3199, 3204], [3258, 3260], [3262, 3293], [3298, 3301], [3312, 3332],
[3386, 3388], [3390, 3423], [3426, 3429], [3446, 3449], [3456, 3460], [3479, 3481],
[3518, 3519], [3527, 3584], [3636, 3647], [3655, 3663], [3674, 3712], [3717, 3718],
[3723, 3724], [3726, 3731], [3752, 3753], [3764, 3772], [3774, 3775], [3783, 3791],
[3802, 3803], [3806, 3839], [3841, 3871], [3892, 3903], [3949, 3975], [3980, 4095],
[4139, 4158], [4170, 4175], [4182, 4185], [4190, 4192], [4194, 4196], [4199, 4205],
[4209, 4212], [4226, 4237], [4250, 4255], [4294, 4303], [4349, 4351], [4686, 4687],
[4702, 4703], [4750, 4751], [4790, 4791], [4806, 4807], [4886, 4887], [4955, 4968],
[4989, 4991], [5008, 5023], [5109, 5120], [5741, 5742], [5787, 5791], [5867, 5869],
[5873, 5887], [5906, 5919], [5938, 5951], [5970, 5983], [6001, 6015], [6068, 6102],
[6104, 6107], [6109, 6111], [6122, 6127], [6138, 6159], [6170, 6175], [6264, 6271],
[6315, 6319], [6390, 6399], [6429, 6469], [6510, 6511], [6517, 6527], [6572, 6592],
[6600, 6607], [6619, 6655], [6679, 6687], [6741, 6783], [6794, 6799], [6810, 6822],
[6824, 6916], [6964, 6980], [6988, 6991], [7002, 7042], [7073, 7085], [7098, 7167],
[7204, 7231], [7242, 7244], [7294, 7400], [7410, 7423], [7616, 7679], [7958, 7959],
[7966, 7967], [8006, 8007], [8014, 8015], [8062, 8063], [8127, 8129], [8141, 8143],
[8148, 8149], [8156, 8159], [8173, 8177], [8189, 8303], [8306, 8307], [8314, 8318],
[8330, 8335], [8341, 8449], [8451, 8454], [8456, 8457], [8470, 8472], [8478, 8483],
[8506, 8507], [8512, 8516], [8522, 8525], [8586, 9311], [9372, 9449], [9472, 10101],
[10132, 11263], [11493, 11498], [11503, 11516], [11518, 11519], [11558, 11567],
[11622, 11630], [11632, 11647], [11671, 11679], [11743, 11822], [11824, 12292],
[12296, 12320], [12330, 12336], [12342, 12343], [12349, 12352], [12439, 12444],
[12544, 12548], [12590, 12592], [12687, 12689], [12694, 12703], [12728, 12783],
[12800, 12831], [12842, 12880], [12896, 12927], [12938, 12976], [12992, 13311],
[19894, 19967], [40908, 40959], [42125, 42191], [42238, 42239], [42509, 42511],
[42540, 42559], [42592, 42593], [42607, 42622], [42648, 42655], [42736, 42774],
[42784, 42785], [42889, 42890], [42893, 43002], [43043, 43055], [43062, 43071],
[43124, 43137], [43188, 43215], [43226, 43249], [43256, 43258], [43260, 43263],
[43302, 43311], [43335, 43359], [43389, 43395], [43443, 43470], [43482, 43519],
[43561, 43583], [43596, 43599], [43610, 43615], [43639, 43641], [43643, 43647],
[43698, 43700], [43703, 43704], [43710, 43711], [43715, 43738], [43742, 43967],
[44003, 44015], [44026, 44031], [55204, 55215], [55239, 55242], [55292, 55295],
[57344, 63743], [64046, 64047], [64110, 64111], [64218, 64255], [64263, 64274],
[64280, 64284], [64434, 64466], [64830, 64847], [64912, 64913], [64968, 65007],
[65020, 65135], [65277, 65295], [65306, 65312], [65339, 65344], [65371, 65381],
[65471, 65473], [65480, 65481], [65488, 65489], [65496, 65497]];
for (i = 0; i < ranges.length; i++) {
start = ranges[i][0];
end = ranges[i][1];
for (j = start; j <= end; j++) {
result[j] = true;
}
}
return result;
})();
function splitQuery(query) {
var result = [];
var start = -1;
for (var i = 0; i < query.length; i++) {
if (splitChars[query.charCodeAt(i)]) {
if (start !== -1) {
result.push(query.slice(start, i));
start = -1;
}
} else if (start === -1) {
start = i;
}
}
if (start !== -1) {
result.push(query.slice(start));
}
return result;
}

BIN
_static/minus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 B

BIN
_static/plus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 B

82
_static/pygments.css Normal file
View File

@ -0,0 +1,82 @@
pre { line-height: 125%; }
td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
.highlight .hll { background-color: #ffffcc }
.highlight { background: #f8f8f8; }
.highlight .c { color: #8f5902; font-style: italic } /* Comment */
.highlight .err { color: #a40000; border: 1px solid #ef2929 } /* Error */
.highlight .g { color: #000000 } /* Generic */
.highlight .k { color: #004461; font-weight: bold } /* Keyword */
.highlight .l { color: #000000 } /* Literal */
.highlight .n { color: #000000 } /* Name */
.highlight .o { color: #582800 } /* Operator */
.highlight .x { color: #000000 } /* Other */
.highlight .p { color: #000000; font-weight: bold } /* Punctuation */
.highlight .ch { color: #8f5902; font-style: italic } /* Comment.Hashbang */
.highlight .cm { color: #8f5902; font-style: italic } /* Comment.Multiline */
.highlight .cp { color: #8f5902 } /* Comment.Preproc */
.highlight .cpf { color: #8f5902; font-style: italic } /* Comment.PreprocFile */
.highlight .c1 { color: #8f5902; font-style: italic } /* Comment.Single */
.highlight .cs { color: #8f5902; font-style: italic } /* Comment.Special */
.highlight .gd { color: #a40000 } /* Generic.Deleted */
.highlight .ge { color: #000000; font-style: italic } /* Generic.Emph */
.highlight .gr { color: #ef2929 } /* Generic.Error */
.highlight .gh { color: #000080; font-weight: bold } /* Generic.Heading */
.highlight .gi { color: #00A000 } /* Generic.Inserted */
.highlight .go { color: #888888 } /* Generic.Output */
.highlight .gp { color: #745334 } /* Generic.Prompt */
.highlight .gs { color: #000000; font-weight: bold } /* Generic.Strong */
.highlight .gu { color: #800080; font-weight: bold } /* Generic.Subheading */
.highlight .gt { color: #a40000; font-weight: bold } /* Generic.Traceback */
.highlight .kc { color: #004461; font-weight: bold } /* Keyword.Constant */
.highlight .kd { color: #004461; font-weight: bold } /* Keyword.Declaration */
.highlight .kn { color: #004461; font-weight: bold } /* Keyword.Namespace */
.highlight .kp { color: #004461; font-weight: bold } /* Keyword.Pseudo */
.highlight .kr { color: #004461; font-weight: bold } /* Keyword.Reserved */
.highlight .kt { color: #004461; font-weight: bold } /* Keyword.Type */
.highlight .ld { color: #000000 } /* Literal.Date */
.highlight .m { color: #990000 } /* Literal.Number */
.highlight .s { color: #4e9a06 } /* Literal.String */
.highlight .na { color: #c4a000 } /* Name.Attribute */
.highlight .nb { color: #004461 } /* Name.Builtin */
.highlight .nc { color: #000000 } /* Name.Class */
.highlight .no { color: #000000 } /* Name.Constant */
.highlight .nd { color: #888888 } /* Name.Decorator */
.highlight .ni { color: #ce5c00 } /* Name.Entity */
.highlight .ne { color: #cc0000; font-weight: bold } /* Name.Exception */
.highlight .nf { color: #000000 } /* Name.Function */
.highlight .nl { color: #f57900 } /* Name.Label */
.highlight .nn { color: #000000 } /* Name.Namespace */
.highlight .nx { color: #000000 } /* Name.Other */
.highlight .py { color: #000000 } /* Name.Property */
.highlight .nt { color: #004461; font-weight: bold } /* Name.Tag */
.highlight .nv { color: #000000 } /* Name.Variable */
.highlight .ow { color: #004461; font-weight: bold } /* Operator.Word */
.highlight .w { color: #f8f8f8; text-decoration: underline } /* Text.Whitespace */
.highlight .mb { color: #990000 } /* Literal.Number.Bin */
.highlight .mf { color: #990000 } /* Literal.Number.Float */
.highlight .mh { color: #990000 } /* Literal.Number.Hex */
.highlight .mi { color: #990000 } /* Literal.Number.Integer */
.highlight .mo { color: #990000 } /* Literal.Number.Oct */
.highlight .sa { color: #4e9a06 } /* Literal.String.Affix */
.highlight .sb { color: #4e9a06 } /* Literal.String.Backtick */
.highlight .sc { color: #4e9a06 } /* Literal.String.Char */
.highlight .dl { color: #4e9a06 } /* Literal.String.Delimiter */
.highlight .sd { color: #8f5902; font-style: italic } /* Literal.String.Doc */
.highlight .s2 { color: #4e9a06 } /* Literal.String.Double */
.highlight .se { color: #4e9a06 } /* Literal.String.Escape */
.highlight .sh { color: #4e9a06 } /* Literal.String.Heredoc */
.highlight .si { color: #4e9a06 } /* Literal.String.Interpol */
.highlight .sx { color: #4e9a06 } /* Literal.String.Other */
.highlight .sr { color: #4e9a06 } /* Literal.String.Regex */
.highlight .s1 { color: #4e9a06 } /* Literal.String.Single */
.highlight .ss { color: #4e9a06 } /* Literal.String.Symbol */
.highlight .bp { color: #3465a4 } /* Name.Builtin.Pseudo */
.highlight .fm { color: #000000 } /* Name.Function.Magic */
.highlight .vc { color: #000000 } /* Name.Variable.Class */
.highlight .vg { color: #000000 } /* Name.Variable.Global */
.highlight .vi { color: #000000 } /* Name.Variable.Instance */
.highlight .vm { color: #000000 } /* Name.Variable.Magic */
.highlight .il { color: #990000 } /* Literal.Number.Integer.Long */

522
_static/searchtools.js Normal file
View File

@ -0,0 +1,522 @@
/*
* searchtools.js
* ~~~~~~~~~~~~~~~~
*
* Sphinx JavaScript utilities for the full-text search.
*
* :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS.
* :license: BSD, see LICENSE for details.
*
*/
if (!Scorer) {
/**
* Simple result scoring code.
*/
var Scorer = {
// Implement the following function to further tweak the score for each result
// The function takes a result array [filename, title, anchor, descr, score]
// and returns the new score.
/*
score: function(result) {
return result[4];
},
*/
// query matches the full name of an object
objNameMatch: 11,
// or matches in the last dotted part of the object name
objPartialMatch: 6,
// Additive scores depending on the priority of the object
objPrio: {0: 15, // used to be importantResults
1: 5, // used to be objectResults
2: -5}, // used to be unimportantResults
// Used when the priority is not in the mapping.
objPrioDefault: 0,
// query found in title
title: 15,
partialTitle: 7,
// query found in terms
term: 5,
partialTerm: 2
};
}
if (!splitQuery) {
function splitQuery(query) {
return query.split(/\s+/);
}
}
/**
* Search Module
*/
var Search = {
_index : null,
_queued_query : null,
_pulse_status : -1,
htmlToText : function(htmlString) {
var virtualDocument = document.implementation.createHTMLDocument('virtual');
var htmlElement = $(htmlString, virtualDocument);
htmlElement.find('.headerlink').remove();
docContent = htmlElement.find('[role=main]')[0];
if(docContent === undefined) {
console.warn("Content block not found. Sphinx search tries to obtain it " +
"via '[role=main]'. Could you check your theme or template.");
return "";
}
return docContent.textContent || docContent.innerText;
},
init : function() {
var params = $.getQueryParameters();
if (params.q) {
var query = params.q[0];
$('input[name="q"]')[0].value = query;
this.performSearch(query);
}
},
loadIndex : function(url) {
$.ajax({type: "GET", url: url, data: null,
dataType: "script", cache: true,
complete: function(jqxhr, textstatus) {
if (textstatus != "success") {
document.getElementById("searchindexloader").src = url;
}
}});
},
setIndex : function(index) {
var q;
this._index = index;
if ((q = this._queued_query) !== null) {
this._queued_query = null;
Search.query(q);
}
},
hasIndex : function() {
return this._index !== null;
},
deferQuery : function(query) {
this._queued_query = query;
},
stopPulse : function() {
this._pulse_status = 0;
},
startPulse : function() {
if (this._pulse_status >= 0)
return;
function pulse() {
var i;
Search._pulse_status = (Search._pulse_status + 1) % 4;
var dotString = '';
for (i = 0; i < Search._pulse_status; i++)
dotString += '.';
Search.dots.text(dotString);
if (Search._pulse_status > -1)
window.setTimeout(pulse, 500);
}
pulse();
},
/**
* perform a search for something (or wait until index is loaded)
*/
performSearch : function(query) {
// create the required interface elements
this.out = $('#search-results');
this.title = $('<h2>' + _('Searching') + '</h2>').appendTo(this.out);
this.dots = $('<span></span>').appendTo(this.title);
this.status = $('<p class="search-summary">&nbsp;</p>').appendTo(this.out);
this.output = $('<ul class="search"/>').appendTo(this.out);
$('#search-progress').text(_('Preparing search...'));
this.startPulse();
// index already loaded, the browser was quick!
if (this.hasIndex())
this.query(query);
else
this.deferQuery(query);
},
/**
* execute search (requires search index to be loaded)
*/
query : function(query) {
var i;
// stem the searchterms and add them to the correct list
var stemmer = new Stemmer();
var searchterms = [];
var excluded = [];
var hlterms = [];
var tmp = splitQuery(query);
var objectterms = [];
for (i = 0; i < tmp.length; i++) {
if (tmp[i] !== "") {
objectterms.push(tmp[i].toLowerCase());
}
if ($u.indexOf(stopwords, tmp[i].toLowerCase()) != -1 || tmp[i] === "") {
// skip this "word"
continue;
}
// stem the word
var word = stemmer.stemWord(tmp[i].toLowerCase());
// prevent stemmer from cutting word smaller than two chars
if(word.length < 3 && tmp[i].length >= 3) {
word = tmp[i];
}
var toAppend;
// select the correct list
if (word[0] == '-') {
toAppend = excluded;
word = word.substr(1);
}
else {
toAppend = searchterms;
hlterms.push(tmp[i].toLowerCase());
}
// only add if not already in the list
if (!$u.contains(toAppend, word))
toAppend.push(word);
}
var highlightstring = '?highlight=' + $.urlencode(hlterms.join(" "));
// console.debug('SEARCH: searching for:');
// console.info('required: ', searchterms);
// console.info('excluded: ', excluded);
// prepare search
var terms = this._index.terms;
var titleterms = this._index.titleterms;
// array of [filename, title, anchor, descr, score]
var results = [];
$('#search-progress').empty();
// lookup as object
for (i = 0; i < objectterms.length; i++) {
var others = [].concat(objectterms.slice(0, i),
objectterms.slice(i+1, objectterms.length));
results = results.concat(this.performObjectSearch(objectterms[i], others));
}
// lookup as search terms in fulltext
results = results.concat(this.performTermsSearch(searchterms, excluded, terms, titleterms));
// let the scorer override scores with a custom scoring function
if (Scorer.score) {
for (i = 0; i < results.length; i++)
results[i][4] = Scorer.score(results[i]);
}
// now sort the results by score (in opposite order of appearance, since the
// display function below uses pop() to retrieve items) and then
// alphabetically
results.sort(function(a, b) {
var left = a[4];
var right = b[4];
if (left > right) {
return 1;
} else if (left < right) {
return -1;
} else {
// same score: sort alphabetically
left = a[1].toLowerCase();
right = b[1].toLowerCase();
return (left > right) ? -1 : ((left < right) ? 1 : 0);
}
});
// for debugging
//Search.lastresults = results.slice(); // a copy
//console.info('search results:', Search.lastresults);
// print the results
var resultCount = results.length;
function displayNextItem() {
// results left, load the summary and display it
if (results.length) {
var item = results.pop();
var listItem = $('<li></li>');
var requestUrl = "";
var linkUrl = "";
if (DOCUMENTATION_OPTIONS.BUILDER === 'dirhtml') {
// dirhtml builder
var dirname = item[0] + '/';
if (dirname.match(/\/index\/$/)) {
dirname = dirname.substring(0, dirname.length-6);
} else if (dirname == 'index/') {
dirname = '';
}
requestUrl = DOCUMENTATION_OPTIONS.URL_ROOT + dirname;
linkUrl = requestUrl;
} else {
// normal html builders
requestUrl = DOCUMENTATION_OPTIONS.URL_ROOT + item[0] + DOCUMENTATION_OPTIONS.FILE_SUFFIX;
linkUrl = item[0] + DOCUMENTATION_OPTIONS.LINK_SUFFIX;
}
listItem.append($('<a/>').attr('href',
linkUrl +
highlightstring + item[2]).html(item[1]));
if (item[3]) {
listItem.append($('<span> (' + item[3] + ')</span>'));
Search.output.append(listItem);
setTimeout(function() {
displayNextItem();
}, 5);
} else if (DOCUMENTATION_OPTIONS.HAS_SOURCE) {
$.ajax({url: requestUrl,
dataType: "text",
complete: function(jqxhr, textstatus) {
var data = jqxhr.responseText;
if (data !== '' && data !== undefined) {
listItem.append(Search.makeSearchSummary(data, searchterms, hlterms));
}
Search.output.append(listItem);
setTimeout(function() {
displayNextItem();
}, 5);
}});
} else {
// no source available, just display title
Search.output.append(listItem);
setTimeout(function() {
displayNextItem();
}, 5);
}
}
// search finished, update title and status message
else {
Search.stopPulse();
Search.title.text(_('Search Results'));
if (!resultCount)
Search.status.text(_('Your search did not match any documents. Please make sure that all words are spelled correctly and that you\'ve selected enough categories.'));
else
Search.status.text(_('Search finished, found %s page(s) matching the search query.').replace('%s', resultCount));
Search.status.fadeIn(500);
}
}
displayNextItem();
},
/**
* search for object names
*/
performObjectSearch : function(object, otherterms) {
var filenames = this._index.filenames;
var docnames = this._index.docnames;
var objects = this._index.objects;
var objnames = this._index.objnames;
var titles = this._index.titles;
var i;
var results = [];
for (var prefix in objects) {
for (var name in objects[prefix]) {
var fullname = (prefix ? prefix + '.' : '') + name;
var fullnameLower = fullname.toLowerCase()
if (fullnameLower.indexOf(object) > -1) {
var score = 0;
var parts = fullnameLower.split('.');
// check for different match types: exact matches of full name or
// "last name" (i.e. last dotted part)
if (fullnameLower == object || parts[parts.length - 1] == object) {
score += Scorer.objNameMatch;
// matches in last name
} else if (parts[parts.length - 1].indexOf(object) > -1) {
score += Scorer.objPartialMatch;
}
var match = objects[prefix][name];
var objname = objnames[match[1]][2];
var title = titles[match[0]];
// If more than one term searched for, we require other words to be
// found in the name/title/description
if (otherterms.length > 0) {
var haystack = (prefix + ' ' + name + ' ' +
objname + ' ' + title).toLowerCase();
var allfound = true;
for (i = 0; i < otherterms.length; i++) {
if (haystack.indexOf(otherterms[i]) == -1) {
allfound = false;
break;
}
}
if (!allfound) {
continue;
}
}
var descr = objname + _(', in ') + title;
var anchor = match[3];
if (anchor === '')
anchor = fullname;
else if (anchor == '-')
anchor = objnames[match[1]][1] + '-' + fullname;
// add custom score for some objects according to scorer
if (Scorer.objPrio.hasOwnProperty(match[2])) {
score += Scorer.objPrio[match[2]];
} else {
score += Scorer.objPrioDefault;
}
results.push([docnames[match[0]], fullname, '#'+anchor, descr, score, filenames[match[0]]]);
}
}
}
return results;
},
/**
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
*/
escapeRegExp : function(string) {
return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
},
/**
* search for full-text terms in the index
*/
performTermsSearch : function(searchterms, excluded, terms, titleterms) {
var docnames = this._index.docnames;
var filenames = this._index.filenames;
var titles = this._index.titles;
var i, j, file;
var fileMap = {};
var scoreMap = {};
var results = [];
// perform the search on the required terms
for (i = 0; i < searchterms.length; i++) {
var word = searchterms[i];
var files = [];
var _o = [
{files: terms[word], score: Scorer.term},
{files: titleterms[word], score: Scorer.title}
];
// add support for partial matches
if (word.length > 2) {
var word_regex = this.escapeRegExp(word);
for (var w in terms) {
if (w.match(word_regex) && !terms[word]) {
_o.push({files: terms[w], score: Scorer.partialTerm})
}
}
for (var w in titleterms) {
if (w.match(word_regex) && !titleterms[word]) {
_o.push({files: titleterms[w], score: Scorer.partialTitle})
}
}
}
// no match but word was a required one
if ($u.every(_o, function(o){return o.files === undefined;})) {
break;
}
// found search word in contents
$u.each(_o, function(o) {
var _files = o.files;
if (_files === undefined)
return
if (_files.length === undefined)
_files = [_files];
files = files.concat(_files);
// set score for the word in each file to Scorer.term
for (j = 0; j < _files.length; j++) {
file = _files[j];
if (!(file in scoreMap))
scoreMap[file] = {};
scoreMap[file][word] = o.score;
}
});
// create the mapping
for (j = 0; j < files.length; j++) {
file = files[j];
if (file in fileMap && fileMap[file].indexOf(word) === -1)
fileMap[file].push(word);
else
fileMap[file] = [word];
}
}
// now check if the files don't contain excluded terms
for (file in fileMap) {
var valid = true;
// check if all requirements are matched
var filteredTermCount = // as search terms with length < 3 are discarded: ignore
searchterms.filter(function(term){return term.length > 2}).length
if (
fileMap[file].length != searchterms.length &&
fileMap[file].length != filteredTermCount
) continue;
// ensure that none of the excluded terms is in the search result
for (i = 0; i < excluded.length; i++) {
if (terms[excluded[i]] == file ||
titleterms[excluded[i]] == file ||
$u.contains(terms[excluded[i]] || [], file) ||
$u.contains(titleterms[excluded[i]] || [], file)) {
valid = false;
break;
}
}
// if we have still a valid result we can add it to the result list
if (valid) {
// select one (max) score for the file.
// for better ranking, we should calculate ranking by using words statistics like basic tf-idf...
var score = $u.max($u.map(fileMap[file], function(w){return scoreMap[file][w]}));
results.push([docnames[file], titles[file], '', null, score, filenames[file]]);
}
}
return results;
},
/**
* helper function to return a node containing the
* search summary for a given text. keywords is a list
* of stemmed words, hlwords is the list of normal, unstemmed
* words. the first one is used to find the occurrence, the
* latter for highlighting it.
*/
makeSearchSummary : function(htmlText, keywords, hlwords) {
var text = Search.htmlToText(htmlText);
var textLower = text.toLowerCase();
var start = 0;
$.each(keywords, function() {
var i = textLower.indexOf(this.toLowerCase());
if (i > -1)
start = i;
});
start = Math.max(start - 120, 0);
var excerpt = ((start > 0) ? '...' : '') +
$.trim(text.substr(start, 240)) +
((start + 240 - text.length) ? '...' : '');
var rv = $('<div class="context"></div>').text(excerpt);
$.each(hlwords, function() {
rv = rv.highlightText(this, 'highlighted');
});
return rv;
}
};
$(document).ready(function() {
Search.init();
});

2027
_static/underscore-1.12.0.js Normal file

File diff suppressed because it is too large Load Diff

6
_static/underscore.js Normal file

File diff suppressed because one or more lines are too long

257
admin.py
View File

@ -1,257 +0,0 @@
#!/usr/bin/env python3
import shutil
import os
from pathlib import Path
import sys
import subprocess
from cmd import Cmd
stage_dir = '.music-tools'
scss_rel_path = Path('src', 'scss', 'style.scss')
css_rel_path = Path('build', 'style.css')
folders_to_ignore = ['venv', 'docs', '.git', '.idea', 'node_modules']
"""
COMPONENTS:
* App Engine
* Cloud Functions:
run_user_playlist
update_tag
run_all_playlists
run_all_playlist_stats
run_all_tags
"""
class Admin(Cmd):
intro = 'Music Tools Admin... ? for help'
prompt = '> '
locals = ['spotframework', 'fmframework', 'spotfm']
def do_prepare_local_stage(self, args):
"""
Prepare local working directory for deployment using static sarsoolib injections
"""
# Now done via online repos, not static injection
print('>> backing up a directory')
os.chdir(Path(__file__).absolute().parent.parent)
print('>> deleting old deployment stage')
shutil.rmtree(stage_dir, ignore_errors=True)
print('>> copying main source')
shutil.copytree('playlist-manager' if Path('playlist-manager').exists() else 'Music-Tools',
stage_dir,
ignore=lambda path, contents:
contents if any(i in Path(path).parts for i in folders_to_ignore) else []
)
for dependency in Admin.locals:
print(f'>> injecting {dependency}')
shutil.copytree(
Path(dependency, dependency),
Path(stage_dir, dependency)
)
os.chdir(stage_dir)
# ADMIN
def do_set_project(self, args):
"""
Set project setting in gcloud console
"""
print('>> setting project')
subprocess.check_call('gcloud config set project sarsooxyz', shell=True)
def deploy_function(self, name, timeout: int = 60, region='europe-west2'):
"""
Deploy function with required environment variables
"""
subprocess.check_call(
f'gcloud functions deploy {name} '
f'--region {region} '
'--runtime=python38 '
f'--trigger-topic {name} '
'--set-env-vars DEPLOY_DESTINATION=PROD '
f'--timeout={timeout}s', shell=True
)
def do_rename(self, args):
"""
Rename playlist in firestore
"""
from music.model.user import User
from music.model.playlist import Playlist
username = input('enter username: ')
user = User.collection.filter('username', '==', username).get()
if user is None:
print('>> user not found')
name = input('enter playlist name: ')
playlist = Playlist.collection.parent(user.key).filter('name', '==', name).get()
if playlist is None:
print('>> playlist not found')
new_name = input('enter new name: ')
playlist.name = new_name
playlist.update()
# PYTHON
def copy_main_file(self, path):
"""
Copy main.{path}.py file corresponding to Python build stage
"""
print('>> preparing main.py')
shutil.copy(f'main.{path}.py', 'main.py')
def do_main_group(self, args):
"""
Compile front-end and deploy to App Engine. Deploy primary functions (run_user_playlist, update_tag)
"""
self.do_set_project(None)
self.export_filtered_dependencies()
self.do_app(args)
self.do_tag(None)
self.do_playlist(None)
def do_app(self, args):
"""
Compile front-end and deploy to App Engine
"""
if not '-nb' in args.strip().split(' '):
print(">> compiling frontend")
self.compile_frontend()
self.copy_main_file('api')
print('>> deploying app engine service')
subprocess.check_call('gcloud app deploy', shell=True)
def function_deploy(self, main, function_id):
self.copy_main_file(main)
print(f'>> deploying {function_id}')
self.deploy_function(function_id)
def do_tag(self, args):
"""
Deploy update_tag function
"""
self.function_deploy('update_tag', 'update_tag')
def do_playlist(self, args):
"""
Deploy run_user_playlist function
"""
self.function_deploy('run_playlist', 'run_user_playlist')
# all playlists cron job
def do_playlist_cron(self, args):
"""
Deploy run_all_playlists function
"""
self.function_deploy('cron', 'run_all_playlists')
# all stats refresh cron job
def do_playlist_stats_cron(self, args):
"""
Deploy run_all_playlist_stats function
"""
self.function_deploy('cron', 'run_all_playlist_stats')
# all tags cron job
def do_tags_cron(self, args):
"""
Deploy run_all_tags function
"""
self.function_deploy('cron', 'run_all_tags')
# redeploy all cron job functions
def do_cron_functions(self, args):
"""
Deploy background functions including cron job scheduling for update actions (run_all_playlists, run_all_playlist_stats, run_all_tags)
"""
self.do_set_project(None)
self.export_filtered_dependencies()
self.do_playlist_cron(None)
self.do_playlist_stats_cron(None)
self.do_tags_cron(None)
def do_pydepend(self, args):
"""
Generate and export requirements.txt from Poetry manifest
"""
self.export_filtered_dependencies()
def export_filtered_dependencies(self):
string = subprocess.check_output('poetry export -f requirements.txt --without-hashes', shell=True, text=True)
depend = string.split('\n')
# filtered = [i for i in depend if not any(i.startswith(local) for local in Admin.locals)]
# filtered = [i for i in filtered if '==' in i]
# filtered = [i[:-2] for i in filtered] # get rid of space and slash at end of line
filtered = [i.split(';')[0] for i in depend]
with open('requirements.txt', 'w') as f:
f.write("\n".join(filtered))
# FRONT-END
def compile_frontend(self):
"""
Compile sass to css and run npm build task
"""
print('>> building css')
subprocess.check_call(f'sass --style=compressed {str(scss_rel_path)} {str(css_rel_path)}', shell=True)
print('>> building javascript')
subprocess.check_call('npm run build', shell=True)
def do_sass(self, args):
"""
Compile sass to css
"""
subprocess.check_call(f'sass --style=compressed {str(scss_rel_path)} {str(css_rel_path)}', shell=True)
def do_watchsass(self, args):
"""
Run sass compiler with watch argument to begin watching source folder for changes
"""
subprocess.check_call(f'sass --style=compressed --watch {str(scss_rel_path)} {str(css_rel_path)}', shell=True)
def do_exit(self, args):
"""
Exit script
"""
exit(0)
def test():
os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = 'service.json'
subprocess.check_call("python -u -m unittest discover -s tests", shell=True)
def run():
os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = 'service.json'
subprocess.check_call("python main.api.py", shell=True)
if __name__ == '__main__':
console = Admin()
if len(sys.argv) > 1:
console.onecmd(' '.join(sys.argv[1:]))
else:
console.cmdloop()

View File

@ -1,15 +0,0 @@
runtime: python38
service: spotify
#instance_class: F1
handlers:
- url: /static
static_dir: build
- url: /.*
script: auto
secure: always
env_variables:
DEPLOY_DESTINATION: 'PROD'

View File

@ -1,8 +0,0 @@
dispatch:
# Default service serves the typical web resources and all static resources.
#- url: "sarsoo.xyz/*"
# service: default
- url: "music.sarsoo.xyz/*"
service: spotify

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

File diff suppressed because one or more lines are too long

View File

@ -1,179 +0,0 @@
#LyX 2.3 created this file. For more info see http://www.lyx.org/
\lyxformat 544
\begin_document
\begin_header
\save_transient_properties true
\origin unavailable
\textclass IEEEtran
\use_default_options true
\begin_modules
minimalistic
todonotes
\end_modules
\maintain_unincluded_children false
\language english
\language_package default
\inputencoding auto
\fontencoding global
\font_roman "default" "default"
\font_sans "default" "default"
\font_typewriter "default" "default"
\font_math "auto" "auto"
\font_default_family default
\use_non_tex_fonts false
\font_sc false
\font_osf false
\font_sf_scale 100 100
\font_tt_scale 100 100
\use_microtype false
\use_dash_ligatures true
\graphics default
\default_output_format default
\output_sync 0
\bibtex_command biber
\index_command default
\paperfontsize default
\spacing onehalf
\use_hyperref false
\papersize default
\use_geometry true
\use_package amsmath 1
\use_package amssymb 1
\use_package cancel 1
\use_package esint 1
\use_package mathdots 1
\use_package mathtools 1
\use_package mhchem 1
\use_package stackrel 1
\use_package stmaryrd 1
\use_package undertilde 1
\cite_engine biblatex
\cite_engine_type authoryear
\biblio_style plain
\biblatex_bibstyle ieee
\biblatex_citestyle ieee
\use_bibtopic false
\use_indices false
\paperorientation portrait
\suppress_date true
\justification true
\use_refstyle 1
\use_minted 0
\index Index
\shortcut idx
\color #008000
\end_index
\leftmargin 1cm
\topmargin 1.5cm
\rightmargin 1cm
\bottommargin 1.5cm
\secnumdepth 3
\tocdepth 3
\paragraph_separation skip
\defskip medskip
\is_math_indent 0
\math_numbering_side default
\quotes_style english
\dynamic_quotes 0
\papercolumns 1
\papersides 1
\paperpagestyle fancy
\bullet 1 0 9 -1
\tracking_changes false
\output_changes false
\html_math_output 0
\html_css_as_file 0
\html_be_strict false
\end_header
\begin_body
\begin_layout Title
Sarsoo Music Tools: A cloud-based web app for smarter playlists
\end_layout
\begin_layout Author
Andy Pack
\end_layout
\begin_layout Abstract
Online music streaming services have allowed the consumption of a wider
breadth of music than ever before.
These services, however, typically do not lend themselves to the structured
listening of a library of music categorised by genre and sub-genre.
Sarsoo Music Tools presents a method for doing so via smart playlists with
emphasis on drawing tracks from other playlists facilitating a hierarchy
well suited to listening by genre.
\end_layout
\begin_layout Section
Introduction
\end_layout
\begin_layout Section
Literature Review
\end_layout
\begin_layout Subsection
iTunes Smart Playlists
\end_layout
\begin_layout Subsection
Smarter Playlists
\end_layout
\begin_layout Section
Proof of Concept
\end_layout
\begin_layout Section
Server
\end_layout
\begin_layout Section
Client
\end_layout
\begin_layout Subsection
Material Rewrite
\end_layout
\begin_layout Section
Infrastructure
\end_layout
\begin_layout Section
iOS client
\end_layout
\begin_layout Section
Results
\end_layout
\begin_layout Section
Future work
\end_layout
\begin_layout Section
Conclusion
\end_layout
\begin_layout Section
\start_of_appendix
References
\end_layout
\begin_layout Standard
\begin_inset CommandInset bibtex
LatexCommand bibtex
btprint "btPrintCited"
bibfiles "ref"
options "bibtotoc"
\end_inset
\end_layout
\end_body
\end_document

View File

@ -1,31 +0,0 @@
@misc{smarter-playlists,
abstract = {Smarter Playlists is a web app that lets you build complex programs by assembling simple components. With Smarter Playlists you can create new playlists by combining a wide range of music sources - artists, albums, genres, pre-programmed playlists and filtering and manipulating them with a nifty graph-based UI.},
author = {Paul Lamere},
date = {24 June},
subtitle = {Smarter Playlists lets you automate the process of making complex playlists.},
title = {Smarter Playlists},
url = {http://smarterplaylists.playlistmachinery.com},
urldate = {23 March 2020},
year = {2015}
}
@misc{plylst,
author = {Shpigford},
date = {11 June},
subtitle = {Create smart playlists for your Spotify library!},
title = {Plylst},
titleaddon = {Stop relying on fancy pants algorithms to organize your library and instead build up playlists the way you want them.},
url = {https://plylst.app},
urldate = {23 March 2020},
year = {2019}
}
@misc{smart-playlists-for-spotify,
author = {James Fairhurst},
date = {25 January},
title = {Smart Playlists for Spotify},
url = {https://smartplaylistsforspotify.co.uk},
urldate = {23 March 2020},
year = {2016}
}

884
genindex.html Normal file
View File

@ -0,0 +1,884 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Index &#8212; Music Tools documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
<script src="_static/jquery.js"></script>
<script src="_static/underscore.js"></script>
<script src="_static/doctools.js"></script>
<link rel="index" title="Index" href="#" />
<link rel="search" title="Search" href="search.html" />
<link rel="stylesheet" href="_static/custom.css" type="text/css" />
<meta name="viewport" content="width=device-width, initial-scale=0.9, maximum-scale=0.9" />
</head><body>
<div class="document">
<div class="documentwrapper">
<div class="bodywrapper">
<div class="body" role="main">
<h1 id="index">Index</h1>
<div class="genindex-jumpbox">
<a href="#A"><strong>A</strong></a>
| <a href="#C"><strong>C</strong></a>
| <a href="#D"><strong>D</strong></a>
| <a href="#E"><strong>E</strong></a>
| <a href="#G"><strong>G</strong></a>
| <a href="#I"><strong>I</strong></a>
| <a href="#L"><strong>L</strong></a>
| <a href="#M"><strong>M</strong></a>
| <a href="#N"><strong>N</strong></a>
| <a href="#O"><strong>O</strong></a>
| <a href="#P"><strong>P</strong></a>
| <a href="#Q"><strong>Q</strong></a>
| <a href="#R"><strong>R</strong></a>
| <a href="#S"><strong>S</strong></a>
| <a href="#T"><strong>T</strong></a>
| <a href="#U"><strong>U</strong></a>
| <a href="#V"><strong>V</strong></a>
</div>
<h2 id="A">A</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.model.html#music.model.user.User.access_token">access_token (music.model.user.User attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.add_last_month">add_last_month (music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.add_this_month">add_this_month (music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.api.html#music.api.decorators.admin_required">admin_required() (in module music.api.decorators)</a>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.model.html#music.model.tag.Tag.albums">albums (music.model.tag.Tag attribute)</a>
</li>
<li><a href="src/music.api.html#music.api.api.all_playlists_route">all_playlists_route() (in module music.api.api)</a>
</li>
<li><a href="src/music.api.html#music.api.api.all_users_route">all_users_route() (in module music.api.api)</a>
</li>
<li><a href="src/music.model.html#music.model.tag.Tag.artists">artists (music.model.tag.Tag attribute)</a>
</li>
<li><a href="src/music.auth.html#music.auth.auth.auth">auth() (in module music.auth.auth)</a>
</li>
</ul></td>
</tr></table>
<h2 id="C">C</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.api.html#music.api.api.change_password">change_password() (in module music.api.api)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.chart_limit">chart_limit (music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.chart_range">chart_range (music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.api.html#music.api.decorators.check_dict">check_dict() (in module music.api.decorators)</a>
</li>
<li><a href="src/music.model.html#music.model.user.User.check_password">check_password() (music.model.user.User method)</a>
</li>
<li><a href="src/music.api.html#music.api.decorators.cloud_task">cloud_task() (in module music.api.decorators)</a>
</li>
<li><a href="src/music.model.html#music.model.config.Config.collection">collection (music.model.config.Config attribute)</a>
<ul>
<li><a href="src/music.model.html#music.model.playlist.Playlist.collection">(music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.tag.Tag.collection">(music.model.tag.Tag attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.user.User.collection">(music.model.user.User attribute)</a>
</li>
</ul></li>
<li><a href="src/music.model.html#music.model.config.Config.collection_name">collection_name (music.model.config.Config attribute)</a>
<ul>
<li><a href="src/music.model.html#music.model.config.Config.Meta.collection_name">(music.model.config.Config.Meta attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.collection_name">(music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.Meta.collection_name">(music.model.playlist.Playlist.Meta attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.tag.Tag.collection_name">(music.model.tag.Tag attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.tag.Tag.Meta.collection_name">(music.model.tag.Tag.Meta attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.user.User.collection_name">(music.model.user.User attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.user.User.Meta.collection_name">(music.model.user.User.Meta attribute)</a>
</li>
</ul></li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.model.html#music.model.config.Config">Config (class in music.model.config)</a>
</li>
<li><a href="src/music.model.html#music.model.config.Config.Meta">Config.Meta (class in music.model.config)</a>
</li>
<li><a href="src/music.model.html#music.model.tag.Tag.count">count (music.model.tag.Tag attribute)</a>
</li>
<li><a href="src/music.api.html#music.api.spotfm.count">count() (in module music.api.spotfm)</a>
</li>
<li><a href="src/music.html#music.music.create_app">create_app() (in module music.music)</a>
</li>
<li><a href="src/music.tasks.html#music.tasks.create_playlist.create_playlist">create_playlist() (in module music.tasks.create_playlist)</a>
</li>
</ul></td>
</tr></table>
<h2 id="D">D</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.api.html#music.api.fm.daily_scrobbles">daily_scrobbles() (in module music.api.fm)</a>
</li>
<li><a href="src/music.db.html#music.db.database.DatabaseUser">DatabaseUser (class in music.db.database)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.day_boundary">day_boundary (music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.auth.html#music.auth.auth.deauth">deauth() (in module music.auth.auth)</a>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.model.html#music.model.playlist.Sort.default">default (music.model.playlist.Sort attribute)</a>
</li>
<li><a href="src/music.api.html#music.api.tag.delete_tag">delete_tag() (in module music.api.tag)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.description_overwrite">description_overwrite (music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.description_suffix">description_suffix (music.model.playlist.Playlist attribute)</a>
</li>
</ul></td>
</tr></table>
<h2 id="E">E</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.model.html#music.model.user.User.email">email (music.model.user.User attribute)</a>
</li>
</ul></td>
</tr></table>
<h2 id="G">G</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.api.html#music.api.decorators.gae_cron">gae_cron() (in module music.api.decorators)</a>
</li>
<li><a href="src/music.db.html#music.db.database.get_authed_lastfm_network">get_authed_lastfm_network() (in module music.db.database)</a>
</li>
<li><a href="src/music.db.html#music.db.database.get_authed_spotify_network">get_authed_spotify_network() (in module music.db.database)</a>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.db.html#music.db.part_generator.PartGenerator.get_recursive_parts">get_recursive_parts() (music.db.part_generator.PartGenerator method)</a>
</li>
<li><a href="src/music.api.html#music.api.tag.get_tag">get_tag() (in module music.api.tag)</a>
</li>
<li><a href="src/music.api.html#music.api.admin.get_tasks">get_tasks() (in module music.api.admin)</a>
</li>
</ul></td>
</tr></table>
<h2 id="I">I</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.api.html#music.api.api.image">image() (in module music.api.api)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.include_library_tracks">include_library_tracks (music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.include_recommendations">include_recommendations (music.model.playlist.Playlist attribute)</a>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.html#music.init_log">init_log() (in module music)</a>
</li>
<li><a href="src/music.api.html#music.api.decorators.is_basic_authed">is_basic_authed() (in module music.api.decorators)</a>
</li>
<li><a href="src/music.api.html#music.api.decorators.is_logged_in">is_logged_in() (in module music.api.decorators)</a>
</li>
</ul></td>
</tr></table>
<h2 id="L">L</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.model.html#music.model.config.Config.last_fm_client_id">last_fm_client_id (music.model.config.Config attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.user.User.last_login">last_login (music.model.user.User attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.user.User.last_refreshed">last_refreshed (music.model.user.User attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.last_updated">last_updated (music.model.playlist.Playlist attribute)</a>
<ul>
<li><a href="src/music.model.html#music.model.tag.Tag.last_updated">(music.model.tag.Tag attribute)</a>
</li>
</ul></li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.lastfm_stat_album_count">lastfm_stat_album_count (music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.lastfm_stat_album_percent">lastfm_stat_album_percent (music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.lastfm_stat_artist_count">lastfm_stat_artist_count (music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.lastfm_stat_artist_percent">lastfm_stat_artist_percent (music.model.playlist.Playlist attribute)</a>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.model.html#music.model.playlist.Playlist.lastfm_stat_count">lastfm_stat_count (music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.lastfm_stat_last_refresh">lastfm_stat_last_refresh (music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.lastfm_stat_percent">lastfm_stat_percent (music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.user.User.lastfm_username">lastfm_username (music.model.user.User attribute)</a>
</li>
<li><a href="src/music.api.html#music.api.decorators.lastfm_username_required">lastfm_username_required() (in module music.api.decorators)</a>
</li>
<li><a href="src/music.model.html#music.model.user.User.locked">locked (music.model.user.User attribute)</a>
</li>
<li><a href="src/music.auth.html#music.auth.auth.login">login() (in module music.auth.auth)</a>
</li>
<li><a href="src/music.api.html#music.api.decorators.login_or_basic_auth">login_or_basic_auth() (in module music.api.decorators)</a>
</li>
<li><a href="src/music.api.html#music.api.decorators.login_required">login_required() (in module music.api.decorators)</a>
</li>
<li><a href="src/music.auth.html#music.auth.auth.logout">logout() (in module music.auth.auth)</a>
</li>
</ul></td>
</tr></table>
<h2 id="M">M</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li>
module
<ul>
<li><a href="src/music.html#module-music">music</a>
</li>
<li><a href="src/music.api.html#module-music.api">music.api</a>
</li>
<li><a href="src/music.api.html#module-music.api.admin">music.api.admin</a>
</li>
<li><a href="src/music.api.html#module-music.api.api">music.api.api</a>
</li>
<li><a href="src/music.api.html#module-music.api.decorators">music.api.decorators</a>
</li>
<li><a href="src/music.api.html#module-music.api.fm">music.api.fm</a>
</li>
<li><a href="src/music.api.html#module-music.api.player">music.api.player</a>
</li>
<li><a href="src/music.api.html#module-music.api.spotfm">music.api.spotfm</a>
</li>
<li><a href="src/music.api.html#module-music.api.spotify">music.api.spotify</a>
</li>
<li><a href="src/music.api.html#module-music.api.tag">music.api.tag</a>
</li>
<li><a href="src/music.auth.html#module-music.auth">music.auth</a>
</li>
<li><a href="src/music.auth.html#module-music.auth.auth">music.auth.auth</a>
</li>
<li><a href="src/music.cloud.html#module-music.cloud">music.cloud</a>
</li>
<li><a href="src/music.cloud.html#module-music.cloud.function">music.cloud.function</a>
</li>
<li><a href="src/music.cloud.html#module-music.cloud.tasks">music.cloud.tasks</a>
</li>
<li><a href="src/music.db.html#module-music.db">music.db</a>
</li>
<li><a href="src/music.db.html#module-music.db.database">music.db.database</a>
</li>
<li><a href="src/music.db.html#module-music.db.part_generator">music.db.part_generator</a>
</li>
<li><a href="src/music.model.html#module-music.model">music.model</a>
</li>
<li><a href="src/music.model.html#module-music.model.config">music.model.config</a>
</li>
<li><a href="src/music.model.html#module-music.model.playlist">music.model.playlist</a>
</li>
<li><a href="src/music.model.html#module-music.model.tag">music.model.tag</a>
</li>
<li><a href="src/music.model.html#module-music.model.user">music.model.user</a>
</li>
<li><a href="src/music.html#module-music.music">music.music</a>
</li>
<li><a href="src/music.tasks.html#module-music.tasks">music.tasks</a>
</li>
<li><a href="src/music.tasks.html#module-music.tasks.create_playlist">music.tasks.create_playlist</a>
</li>
<li><a href="src/music.tasks.html#module-music.tasks.refresh_lastfm_stats">music.tasks.refresh_lastfm_stats</a>
</li>
<li><a href="src/music.tasks.html#module-music.tasks.run_user_playlist">music.tasks.run_user_playlist</a>
</li>
<li><a href="src/music.tasks.html#module-music.tasks.update_tag">music.tasks.update_tag</a>
</li>
</ul></li>
<li>
music
<ul>
<li><a href="src/music.html#module-music">module</a>
</li>
</ul></li>
<li>
music.api
<ul>
<li><a href="src/music.api.html#module-music.api">module</a>
</li>
</ul></li>
<li>
music.api.admin
<ul>
<li><a href="src/music.api.html#module-music.api.admin">module</a>
</li>
</ul></li>
<li>
music.api.api
<ul>
<li><a href="src/music.api.html#module-music.api.api">module</a>
</li>
</ul></li>
<li>
music.api.decorators
<ul>
<li><a href="src/music.api.html#module-music.api.decorators">module</a>
</li>
</ul></li>
<li>
music.api.fm
<ul>
<li><a href="src/music.api.html#module-music.api.fm">module</a>
</li>
</ul></li>
<li>
music.api.player
<ul>
<li><a href="src/music.api.html#module-music.api.player">module</a>
</li>
</ul></li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li>
music.api.spotfm
<ul>
<li><a href="src/music.api.html#module-music.api.spotfm">module</a>
</li>
</ul></li>
<li>
music.api.spotify
<ul>
<li><a href="src/music.api.html#module-music.api.spotify">module</a>
</li>
</ul></li>
<li>
music.api.tag
<ul>
<li><a href="src/music.api.html#module-music.api.tag">module</a>
</li>
</ul></li>
<li>
music.auth
<ul>
<li><a href="src/music.auth.html#module-music.auth">module</a>
</li>
</ul></li>
<li>
music.auth.auth
<ul>
<li><a href="src/music.auth.html#module-music.auth.auth">module</a>
</li>
</ul></li>
<li>
music.cloud
<ul>
<li><a href="src/music.cloud.html#module-music.cloud">module</a>
</li>
</ul></li>
<li>
music.cloud.function
<ul>
<li><a href="src/music.cloud.html#module-music.cloud.function">module</a>
</li>
</ul></li>
<li>
music.cloud.tasks
<ul>
<li><a href="src/music.cloud.html#module-music.cloud.tasks">module</a>
</li>
</ul></li>
<li>
music.db
<ul>
<li><a href="src/music.db.html#module-music.db">module</a>
</li>
</ul></li>
<li>
music.db.database
<ul>
<li><a href="src/music.db.html#module-music.db.database">module</a>
</li>
</ul></li>
<li>
music.db.part_generator
<ul>
<li><a href="src/music.db.html#module-music.db.part_generator">module</a>
</li>
</ul></li>
<li>
music.model
<ul>
<li><a href="src/music.model.html#module-music.model">module</a>
</li>
</ul></li>
<li>
music.model.config
<ul>
<li><a href="src/music.model.html#module-music.model.config">module</a>
</li>
</ul></li>
<li>
music.model.playlist
<ul>
<li><a href="src/music.model.html#module-music.model.playlist">module</a>
</li>
</ul></li>
<li>
music.model.tag
<ul>
<li><a href="src/music.model.html#module-music.model.tag">module</a>
</li>
</ul></li>
<li>
music.model.user
<ul>
<li><a href="src/music.model.html#module-music.model.user">module</a>
</li>
</ul></li>
<li>
music.music
<ul>
<li><a href="src/music.html#module-music.music">module</a>
</li>
</ul></li>
<li>
music.tasks
<ul>
<li><a href="src/music.tasks.html#module-music.tasks">module</a>
</li>
</ul></li>
<li>
music.tasks.create_playlist
<ul>
<li><a href="src/music.tasks.html#module-music.tasks.create_playlist">module</a>
</li>
</ul></li>
<li>
music.tasks.refresh_lastfm_stats
<ul>
<li><a href="src/music.tasks.html#module-music.tasks.refresh_lastfm_stats">module</a>
</li>
</ul></li>
<li>
music.tasks.run_user_playlist
<ul>
<li><a href="src/music.tasks.html#module-music.tasks.run_user_playlist">module</a>
</li>
</ul></li>
<li>
music.tasks.update_tag
<ul>
<li><a href="src/music.tasks.html#module-music.tasks.update_tag">module</a>
</li>
</ul></li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.mutable_keys">mutable_keys (music.model.playlist.Playlist attribute)</a>
</li>
</ul></td>
</tr></table>
<h2 id="N">N</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.model.html#music.model.playlist.Playlist.name">name (music.model.playlist.Playlist attribute)</a>
<ul>
<li><a href="src/music.model.html#music.model.tag.Tag.name">(music.model.tag.Tag attribute)</a>
</li>
</ul></li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.api.html#music.api.player.next_track">next_track() (in module music.api.player)</a>
</li>
</ul></td>
</tr></table>
<h2 id="O">O</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.cloud.html#music.cloud.offload_or_run_user_playlist">offload_or_run_user_playlist() (in module music.cloud)</a>
</li>
</ul></td>
</tr></table>
<h2 id="P">P</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.db.html#music.db.part_generator.PartGenerator">PartGenerator (class in music.db.part_generator)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.parts">parts (music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.user.User.password">password (music.model.user.User attribute)</a>
</li>
<li><a href="src/music.api.html#music.api.player.play">play() (in module music.api.player)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist">Playlist (class in music.model.playlist)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.Meta">Playlist.Meta (class in music.model.playlist)</a>
</li>
<li><a href="src/music.model.html#music.model.config.Config.playlist_cloud_operating_mode">playlist_cloud_operating_mode (music.model.config.Config attribute)</a>
</li>
<li><a href="src/music.api.html#music.api.api.playlist_get_delete_route">playlist_get_delete_route() (in module music.api.api)</a>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.api.html#music.api.api.playlist_post_put_route">playlist_post_put_route() (in module music.api.api)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.playlist_references">playlist_references (music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.api.html#music.api.spotfm.playlist_refresh">playlist_refresh() (in module music.api.spotfm)</a>
</li>
<li><a href="src/music.api.html#music.api.tag.post_tag">post_tag() (in module music.api.tag)</a>
</li>
<li><a href="src/music.db.html#music.db.part_generator.PartGenerator.process_reference_by_name">process_reference_by_name() (music.db.part_generator.PartGenerator method)</a>
</li>
<li><a href="src/music.db.html#music.db.part_generator.PartGenerator.process_reference_by_reference">process_reference_by_reference() (music.db.part_generator.PartGenerator method)</a>
</li>
<li><a href="src/music.model.html#music.model.tag.Tag.proportion">proportion (music.model.tag.Tag attribute)</a>
</li>
<li><a href="src/music.api.html#music.api.tag.put_tag">put_tag() (in module music.api.tag)</a>
</li>
</ul></td>
</tr></table>
<h2 id="Q">Q</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.cloud.html#music.cloud.queue_run_user_playlist">queue_run_user_playlist() (in module music.cloud)</a>
</li>
</ul></td>
</tr></table>
<h2 id="R">R</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.model.html#music.model.playlist.Playlist.recommendation_sample">recommendation_sample (music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.cloud.html#music.cloud.tasks.refresh_all_user_playlist_stats">refresh_all_user_playlist_stats() (in module music.cloud.tasks)</a>
</li>
<li><a href="src/music.tasks.html#music.tasks.refresh_lastfm_stats.refresh_lastfm_album_stats">refresh_lastfm_album_stats() (in module music.tasks.refresh_lastfm_stats)</a>
</li>
<li><a href="src/music.tasks.html#music.tasks.refresh_lastfm_stats.refresh_lastfm_artist_stats">refresh_lastfm_artist_stats() (in module music.tasks.refresh_lastfm_stats)</a>
</li>
<li><a href="src/music.tasks.html#music.tasks.refresh_lastfm_stats.refresh_lastfm_track_stats">refresh_lastfm_track_stats() (in module music.tasks.refresh_lastfm_stats)</a>
</li>
<li><a href="src/music.cloud.html#music.cloud.tasks.refresh_playlist_task">refresh_playlist_task() (in module music.cloud.tasks)</a>
</li>
<li><a href="src/music.model.html#music.model.user.User.refresh_token">refresh_token (music.model.user.User attribute)</a>
</li>
<li><a href="src/music.db.html#music.db.database.refresh_token_database_callback">refresh_token_database_callback() (in module music.db.database)</a>
</li>
<li><a href="src/music.cloud.html#music.cloud.tasks.refresh_user_playlist_stats">refresh_user_playlist_stats() (in module music.cloud.tasks)</a>
</li>
<li><a href="src/music.cloud.html#music.cloud.tasks.refresh_user_stats_task">refresh_user_stats_task() (in module music.cloud.tasks)</a>
</li>
<li><a href="src/music.auth.html#music.auth.auth.register">register() (in module music.auth.auth)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Sort.release_date">release_date (music.model.playlist.Sort attribute)</a>
</li>
<li><a href="src/music.db.html#music.db.part_generator.PartGenerator.reset">reset() (music.db.part_generator.PartGenerator method)</a>
</li>
<li><a href="src/music.api.html#music.api.api.run_playlist">run_playlist() (in module music.api.api)</a>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.api.html#music.api.spotfm.run_playlist_album_task">run_playlist_album_task() (in module music.api.spotfm)</a>
</li>
<li><a href="src/music.api.html#music.api.spotfm.run_playlist_artist_task">run_playlist_artist_task() (in module music.api.spotfm)</a>
</li>
<li><a href="src/music.api.html#music.api.api.run_playlist_task">run_playlist_task() (in module music.api.api)</a>
</li>
<li><a href="src/music.api.html#music.api.spotfm.run_playlist_track_task">run_playlist_track_task() (in module music.api.spotfm)</a>
</li>
<li><a href="src/music.api.html#music.api.tag.run_tag_task">run_tag_task() (in module music.api.tag)</a>
</li>
<li><a href="src/music.api.html#music.api.api.run_user">run_user() (in module music.api.api)</a>
<ul>
<li><a href="src/music.api.html#music.api.spotfm.run_user">(in module music.api.spotfm)</a>
</li>
</ul></li>
<li><a href="src/music.tasks.html#music.tasks.run_user_playlist.run_user_playlist">run_user_playlist() (in module music.tasks.run_user_playlist)</a>
</li>
<li><a href="src/music.cloud.html#music.cloud.function.run_user_playlist_function">run_user_playlist_function() (in module music.cloud.function)</a>
</li>
<li><a href="src/music.cloud.html#music.cloud.tasks.run_user_playlist_task">run_user_playlist_task() (in module music.cloud.tasks)</a>
</li>
<li><a href="src/music.api.html#music.api.api.run_user_task">run_user_task() (in module music.api.api)</a>
<ul>
<li><a href="src/music.api.html#music.api.spotfm.run_user_task">(in module music.api.spotfm)</a>
</li>
</ul></li>
<li><a href="src/music.api.html#music.api.api.run_users">run_users() (in module music.api.api)</a>
<ul>
<li><a href="src/music.api.html#music.api.spotfm.run_users">(in module music.api.spotfm)</a>
</li>
</ul></li>
</ul></td>
</tr></table>
<h2 id="S">S</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.model.html#music.model.config.Config.secret_key">secret_key (music.model.config.Config attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.shuffle">shuffle (music.model.playlist.Playlist attribute)</a>
<ul>
<li><a href="src/music.model.html#music.model.playlist.Sort.shuffle">(music.model.playlist.Sort attribute)</a>
</li>
</ul></li>
<li><a href="src/music.api.html#music.api.player.shuffle">shuffle() (in module music.api.player)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Sort">Sort (class in music.model.playlist)</a>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.model.html#music.model.playlist.Playlist.sort">sort (music.model.playlist.Playlist attribute)</a>
</li>
<li><a href="src/music.api.html#music.api.spotify.sort">sort() (in module music.api.spotify)</a>
</li>
<li><a href="src/music.model.html#music.model.config.Config.spotify_client_id">spotify_client_id (music.model.config.Config attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.config.Config.spotify_client_secret">spotify_client_secret (music.model.config.Config attribute)</a>
</li>
<li><a href="src/music.api.html#music.api.decorators.spotify_link_required">spotify_link_required() (in module music.api.decorators)</a>
</li>
<li><a href="src/music.model.html#music.model.user.User.spotify_linked">spotify_linked (music.model.user.User attribute)</a>
</li>
</ul></td>
</tr></table>
<h2 id="T">T</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.model.html#music.model.tag.Tag">Tag (class in music.model.tag)</a>
</li>
<li><a href="src/music.model.html#music.model.tag.Tag.Meta">Tag.Meta (class in music.model.tag)</a>
</li>
<li><a href="src/music.model.html#music.model.tag.Tag.tag_id">tag_id (music.model.tag.Tag attribute)</a>
</li>
<li><a href="src/music.api.html#music.api.tag.tag_refresh">tag_refresh() (in module music.api.tag)</a>
</li>
<li><a href="src/music.api.html#music.api.tag.tag_route">tag_route() (in module music.api.tag)</a>
</li>
<li><a href="src/music.api.html#music.api.tag.tags">tags() (in module music.api.tag)</a>
</li>
<li><a href="src/music.model.html#music.model.tag.Tag.time_objects">time_objects (music.model.tag.Tag attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.to_dict">to_dict() (music.model.playlist.Playlist method)</a>
<ul>
<li><a href="src/music.model.html#music.model.tag.Tag.to_dict">(music.model.tag.Tag method)</a>
</li>
<li><a href="src/music.model.html#music.model.user.User.to_dict">(music.model.user.User method)</a>
</li>
</ul></li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.auth.html#music.auth.auth.token">token() (in module music.auth.auth)</a>
</li>
<li><a href="src/music.model.html#music.model.user.User.token_expiry">token_expiry (music.model.user.User attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.tag.Tag.total_time">total_time (music.model.tag.Tag attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.tag.Tag.total_time_ms">total_time_ms (music.model.tag.Tag attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.tag.Tag.total_user_scrobbles">total_user_scrobbles (music.model.tag.Tag attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.tag.Tag.tracks">tracks (music.model.tag.Tag attribute)</a>
</li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.type">type (music.model.playlist.Playlist attribute)</a>
<ul>
<li><a href="src/music.model.html#music.model.user.User.type">(music.model.user.User attribute)</a>
</li>
</ul></li>
</ul></td>
</tr></table>
<h2 id="U">U</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.cloud.html#music.cloud.tasks.update_all_user_playlists">update_all_user_playlists() (in module music.cloud.tasks)</a>
</li>
<li><a href="src/music.cloud.html#music.cloud.tasks.update_all_user_tags">update_all_user_tags() (in module music.cloud.tasks)</a>
</li>
<li><a href="src/music.cloud.html#music.cloud.tasks.update_playlists">update_playlists() (in module music.cloud.tasks)</a>
</li>
<li><a href="src/music.cloud.html#music.cloud.function.update_tag">update_tag() (in module music.cloud.function)</a>
<ul>
<li><a href="src/music.tasks.html#music.tasks.update_tag.update_tag">(in module music.tasks.update_tag)</a>
</li>
</ul></li>
<li><a href="src/music.model.html#music.model.playlist.Playlist.uri">uri (music.model.playlist.Playlist attribute)</a>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.model.html#music.model.user.User">User (class in music.model.user)</a>
</li>
<li><a href="src/music.model.html#music.model.user.User.Meta">User.Meta (class in music.model.user)</a>
</li>
<li><a href="src/music.db.html#music.db.database.DatabaseUser.user_id">user_id (music.db.database.DatabaseUser attribute)</a>
</li>
<li><a href="src/music.api.html#music.api.api.user_route">user_route() (in module music.api.api)</a>
</li>
<li><a href="src/music.model.html#music.model.tag.Tag.username">username (music.model.tag.Tag attribute)</a>
<ul>
<li><a href="src/music.model.html#music.model.user.User.username">(music.model.user.User attribute)</a>
</li>
</ul></li>
</ul></td>
</tr></table>
<h2 id="V">V</h2>
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.api.html#music.api.decorators.validate_args">validate_args() (in module music.api.decorators)</a>
</li>
<li><a href="src/music.api.html#music.api.decorators.validate_json">validate_json() (in module music.api.decorators)</a>
</li>
</ul></td>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="src/music.model.html#music.model.user.User.validated">validated (music.model.user.User attribute)</a>
</li>
<li><a href="src/music.api.html#music.api.player.volume">volume() (in module music.api.player)</a>
</li>
</ul></td>
</tr></table>
</div>
</div>
</div>
<div class="sphinxsidebar" role="navigation" aria-label="main navigation">
<div class="sphinxsidebarwrapper">
<h1 class="logo"><a href="index.html">Music Tools</a></h1>
<h3>Navigation</h3>
<p class="caption"><span class="caption-text">Contents:</span></p>
<ul>
<li class="toctree-l1"><a class="reference internal" href="src/modules.html">Modules</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.html">music package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.api.html">music.api package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.auth.html">music.auth package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.cloud.html">music.cloud package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.db.html">music.db package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.model.html">music.model package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.tasks.html">music.tasks package</a></li>
</ul>
<div class="relations">
<h3>Related Topics</h3>
<ul>
<li><a href="index.html">Documentation overview</a><ul>
</ul></li>
</ul>
</div>
<div id="searchbox" style="display: none" role="search">
<h3 id="searchlabel">Quick search</h3>
<div class="searchformwrapper">
<form class="search" action="search.html" method="get">
<input type="text" name="q" aria-labelledby="searchlabel" />
<input type="submit" value="Go" />
</form>
</div>
</div>
<script>$('#searchbox').show(0);</script>
</div>
</div>
<div class="clearer"></div>
</div>
<div class="footer">
&copy;2021, Sarsoo.
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.5.3</a>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
</div>
</body>
</html>

138
index.html Normal file
View File

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Music Tools &#8212; Music Tools documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
<script src="_static/jquery.js"></script>
<script src="_static/underscore.js"></script>
<script src="_static/doctools.js"></script>
<link rel="index" title="Index" href="genindex.html" />
<link rel="search" title="Search" href="search.html" />
<link rel="next" title="music" href="src/modules.html" />
<link rel="stylesheet" href="_static/custom.css" type="text/css" />
<meta name="viewport" content="width=device-width, initial-scale=0.9, maximum-scale=0.9" />
</head><body>
<div class="document">
<div class="documentwrapper">
<div class="bodywrapper">
<div class="body" role="main">
<div class="section" id="music-tools">
<h1>Music Tools<a class="headerlink" href="#music-tools" title="Permalink to this headline"></a></h1>
<div class="toctree-wrapper compound">
<p class="caption"><span class="caption-text">Contents:</span></p>
<ul>
<li class="toctree-l1"><a class="reference internal" href="src/modules.html">Modules</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.html">music package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.api.html">music.api package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.auth.html">music.auth package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.cloud.html">music.cloud package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.db.html">music.db package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.model.html">music.model package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.tasks.html">music.tasks package</a></li>
</ul>
</div>
<div class="section" id="id1">
<h2>Music Tools<a class="headerlink" href="#id1" title="Permalink to this headline"></a></h2>
<img alt="https://github.com/sarsoo/music-tools/workflows/test%20and%20deploy/badge.svg" src="https://github.com/sarsoo/music-tools/workflows/test%20and%20deploy/badge.svg" /><p>Music Tools is a web app for creating smart Spotify playlists.</p>
</div>
</div>
<div class="section" id="indices-and-tables">
<h1>Indices and tables<a class="headerlink" href="#indices-and-tables" title="Permalink to this headline"></a></h1>
<ul class="simple">
<li><p><a class="reference internal" href="genindex.html"><span class="std std-ref">Index</span></a></p></li>
<li><p><a class="reference internal" href="py-modindex.html"><span class="std std-ref">Module Index</span></a></p></li>
<li><p><a class="reference internal" href="search.html"><span class="std std-ref">Search Page</span></a></p></li>
</ul>
</div>
</div>
</div>
</div>
<div class="sphinxsidebar" role="navigation" aria-label="main navigation">
<div class="sphinxsidebarwrapper">
<h1 class="logo"><a href="#">Music Tools</a></h1>
<h3>Navigation</h3>
<p class="caption"><span class="caption-text">Contents:</span></p>
<ul>
<li class="toctree-l1"><a class="reference internal" href="src/modules.html">Modules</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.html">music package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.api.html">music.api package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.auth.html">music.auth package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.cloud.html">music.cloud package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.db.html">music.db package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.model.html">music.model package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.tasks.html">music.tasks package</a></li>
</ul>
<div class="relations">
<h3>Related Topics</h3>
<ul>
<li><a href="#">Documentation overview</a><ul>
<li>Next: <a href="src/modules.html" title="next chapter">music</a></li>
</ul></li>
</ul>
</div>
<div id="searchbox" style="display: none" role="search">
<h3 id="searchlabel">Quick search</h3>
<div class="searchformwrapper">
<form class="search" action="search.html" method="get">
<input type="text" name="q" aria-labelledby="searchlabel" />
<input type="submit" value="Go" />
</form>
</div>
</div>
<script>$('#searchbox').show(0);</script>
</div>
</div>
<div class="clearer"></div>
</div>
<div class="footer">
&copy;2021, Sarsoo.
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.5.3</a>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
<a href="_sources/index.rst.txt"
rel="nofollow">Page source</a>
</div>
</body>
</html>

View File

@ -1,6 +0,0 @@
from music.music import create_app
app = create_app()
if __name__ == '__main__':
app.run(host='127.0.0.1', port=8080, debug=True)

View File

@ -1,13 +0,0 @@
from music.cloud.tasks import update_all_user_playlists, refresh_all_user_playlist_stats, update_all_user_tags
def run_all_playlists(event, context):
update_all_user_playlists()
def run_all_playlist_stats(event, context):
refresh_all_user_playlist_stats()
def run_all_tags(event, context):
update_all_user_tags()

View File

@ -1,15 +0,0 @@
def run_user_playlist(event, context):
import logging
logger = logging.getLogger('music')
if event.get('attributes'):
if 'username' in event['attributes'] and 'name' in event['attributes']:
from music.tasks.run_user_playlist import run_user_playlist as do_run_user_playlist
do_run_user_playlist(user=event['attributes']['username'], playlist=event['attributes']["name"])
else:
logger.error('no parameters in event attributes')
else:
logger.error('no attributes in event')

View File

@ -1,15 +0,0 @@
def update_tag(event, context):
import logging
logger = logging.getLogger('music')
if event.get('attributes'):
if 'username' in event['attributes'] and 'tag_id' in event['attributes']:
from music.tasks.update_tag import update_tag as do_update_tag
do_update_tag(user=event['attributes']['username'], tag=event['attributes']["tag_id"])
else:
logger.error('no parameters in event attributes')
else:
logger.error('no attributes in event')

View File

@ -1,42 +0,0 @@
import logging
import os
logger = logging.getLogger(__name__)
logger.setLevel('DEBUG')
spotframework_logger = logging.getLogger('spotframework')
fmframework_logger = logging.getLogger('fmframework')
spotfm_logger = logging.getLogger('spotfm')
def init_log(cloud=False, console=False):
if cloud:
import google.cloud.logging
from google.cloud.logging.handlers import CloudLoggingHandler
log_format = '%(funcName)s - %(message)s'
formatter = logging.Formatter(log_format)
client = google.cloud.logging.Client()
handler = CloudLoggingHandler(client, name="music-tools")
handler.setFormatter(formatter)
logger.addHandler(handler)
spotframework_logger.addHandler(handler)
fmframework_logger.addHandler(handler)
spotfm_logger.addHandler(handler)
if console:
log_format = '%(levelname)s %(name)s:%(funcName)s - %(message)s'
formatter = logging.Formatter(log_format)
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)
spotframework_logger.addHandler(stream_handler)
fmframework_logger.addHandler(stream_handler)
spotfm_logger.addHandler(stream_handler)
if os.environ.get('DEPLOY_DESTINATION', None) == 'PROD':
init_log(cloud=True)

View File

@ -1,7 +0,0 @@
from .api import blueprint as api_blueprint
from .player import blueprint as player_blueprint
from .fm import blueprint as fm_blueprint
from .spotfm import blueprint as spotfm_blueprint
from .spotify import blueprint as spotify_blueprint
from .admin import blueprint as admin_blueprint
from .tag import blueprint as tag_blueprint

View File

@ -1,41 +0,0 @@
from flask import Blueprint, jsonify
import logging
from datetime import datetime
from google.cloud import tasks_v2
from music.api.decorators import login_or_basic_auth, admin_required
blueprint = Blueprint('admin-api', __name__)
tasker = tasks_v2.CloudTasksClient()
task_path = tasker.queue_path('sarsooxyz', 'europe-west2', 'spotify-executions')
logger = logging.getLogger(__name__)
@blueprint.route('/tasks', methods=['GET'])
@login_or_basic_auth
@admin_required
def get_tasks(user=None):
tasks = list(tasker.list_tasks(task_path))
urls = {}
for task in tasks:
if urls.get(task.app_engine_http_request.relative_uri):
urls[task.app_engine_http_request.relative_uri] += 1
else:
urls[task.app_engine_http_request.relative_uri] = 1
response = {
'tasks': [{
'url': i,
'count': j,
'scheduled_times': [datetime.fromtimestamp(k.schedule_time.seconds) for k in tasks
if k.app_engine_http_request.relative_uri == i]
} for i, j in urls.items()],
'total_tasks': len(tasks),
}
return jsonify(response), 200

View File

@ -1,308 +0,0 @@
from flask import Blueprint, request, jsonify
from google.cloud import firestore
from werkzeug.security import generate_password_hash
import os
import json
import logging
from datetime import datetime
from music.api.decorators import login_required, login_or_basic_auth, \
admin_required, cloud_task, validate_json, validate_args, spotify_link_required
from music.cloud import queue_run_user_playlist, offload_or_run_user_playlist
from music.cloud.tasks import update_all_user_playlists, update_playlists
from music.tasks.create_playlist import create_playlist
from music.tasks.run_user_playlist import run_user_playlist
from music.model.user import User
from music.model.playlist import Playlist
import music.db.database as database
from spotframework.net.network import SpotifyNetworkException
blueprint = Blueprint('api', __name__)
db = firestore.Client()
logger = logging.getLogger(__name__)
@blueprint.route('/playlists', methods=['GET'])
@login_or_basic_auth
def all_playlists_route(user=None):
assert user is not None
return jsonify({
'playlists': [i.to_dict() for i in Playlist.collection.parent(user.key).fetch()]
}), 200
@blueprint.route('/playlist', methods=['GET', 'DELETE'])
@login_or_basic_auth
@validate_args(('name', str))
def playlist_get_delete_route(user=None):
playlist = Playlist.collection.parent(user.key).filter('name', '==', request.args['name']).get()
if playlist is None:
return jsonify({'error': f'playlist {request.args["name"]} not found'}), 404
if request.method == "GET":
return jsonify(playlist.to_dict()), 200
elif request.method == 'DELETE':
Playlist.collection.parent(user.key).delete(key=playlist.key)
return jsonify({"message": 'playlist deleted', "status": "success"}), 200
@blueprint.route('/playlist', methods=['POST', 'PUT'])
@login_or_basic_auth
@validate_json(('name', str))
def playlist_post_put_route(user=None):
request_json = request.get_json()
playlist_name = request_json['name']
playlist_references = []
if request_json.get('playlist_references', None):
if request_json['playlist_references'] != -1:
for i in request_json['playlist_references']:
playlist = Playlist.collection.parent(user.key).filter('name', '==', i).get()
if playlist is not None:
playlist_references.append(db.document(playlist.key))
else:
return jsonify({"message": f'managed playlist {i} not found', "status": "error"}), 400
if len(playlist_references) == 0 and request_json.get('playlist_references', None) != -1:
playlist_references = None
searched_playlist = Playlist.collection.parent(user.key).filter('name', '==', playlist_name).get()
# CREATE
if request.method == 'PUT':
if searched_playlist is not None:
return jsonify({'error': 'playlist already exists'}), 400
playlist = Playlist(parent=user.key)
playlist.name = request_json['name']
for key in [i for i in Playlist.mutable_keys if i not in ['playlist_references', 'type']]:
setattr(playlist, key, request_json.get(key, None))
playlist.playlist_references = playlist_references
playlist.last_updated = datetime.utcnow()
playlist.lastfm_stat_last_refresh = datetime.utcnow()
if request_json.get('type'):
playlist_type = request_json['type'].strip().lower()
if playlist_type in ['default', 'recents', 'fmchart']:
playlist.type = playlist_type
else:
playlist.type = 'default'
logger.warning(f'invalid type ({playlist_type}), {user.username} / {playlist_name}')
if user.spotify_linked:
new_playlist = create_playlist(user, playlist_name)
playlist.uri = str(new_playlist.uri)
playlist.save()
logger.info(f'added {user.username} / {playlist_name}')
return jsonify({"message": 'playlist added', "status": "success"}), 201
# UPDATE
elif request.method == 'POST':
if searched_playlist is None:
return jsonify({'error': "playlist doesn't exist"}), 400
playlist = Playlist.collection.parent(user.key).filter('name', '==', playlist_name).get()
# ATTRIBUTES
for rec_key, rec_item in request_json.items():
# type and parts require extra validation
if rec_key in [k for k in Playlist.mutable_keys if k not in ['type', 'parts', 'playlist_references']]:
setattr(playlist, rec_key, request_json[rec_key])
# COMPONENTS
if request_json.get('parts'):
if request_json['parts'] == -1:
playlist.parts = []
else:
playlist.parts = request_json['parts']
if playlist_references is not None:
if playlist_references == -1:
playlist.playlist_references = []
else:
playlist.playlist_references = playlist_references
# ATTRIBUTE WITH CHECKS
if request_json.get('type'):
playlist_type = request_json['type'].strip().lower()
if playlist_type in ['default', 'recents', 'fmchart']:
playlist.type = playlist_type
playlist.update()
logger.info(f'updated {user.username} / {playlist_name}')
return jsonify({"message": 'playlist updated', "status": "success"}), 200
@blueprint.route('/user', methods=['GET', 'POST'])
@login_or_basic_auth
def user_route(user=None):
assert user is not None
if request.method == 'GET':
return jsonify(user.to_dict()), 200
else: # POST
request_json = request.get_json()
if 'username' in request_json:
if request_json['username'].strip().lower() != user.username:
if user.type != "admin":
return jsonify({'status': 'error', 'message': 'unauthorized'}), 401
user = User.collection.filter('username', '==', request_json['username'].strip().lower()).get()
if 'locked' in request_json:
if user.type == "admin":
logger.info(f'updating lock {user.username} / {request_json["locked"]}')
user.locked = request_json['locked']
if 'spotify_linked' in request_json:
logger.info(f'deauthing {user.username}')
if request_json['spotify_linked'] is False:
user.access_token = None
user.refresh_token = None
user.spotify_linked = False
if 'lastfm_username' in request_json:
logger.info(f'updating lastfm username {user.username} -> {request_json["lastfm_username"]}')
user.lastfm_username = request_json['lastfm_username']
user.update()
logger.info(f'updated {user.username}')
return jsonify({'message': 'account updated', 'status': 'succeeded'}), 200
@blueprint.route('/users', methods=['GET'])
@login_or_basic_auth
@admin_required
def all_users_route(user=None):
return jsonify({
'accounts': [i.to_dict() for i in User.collection.fetch()]
}), 200
@blueprint.route('/user/password', methods=['POST'])
@login_required
@validate_json(('new_password', str), ('current_password', str))
def change_password(user=None):
request_json = request.get_json()
if len(request_json['new_password']) == 0:
return jsonify({"error": 'zero length password'}), 400
if len(request_json['new_password']) > 30:
return jsonify({"error": 'password too long'}), 400
if user.check_password(request_json['current_password']):
user.password = generate_password_hash(request_json['new_password'])
user.update()
logger.info(f'password udpated {user.username}')
return jsonify({"message": 'password changed', "status": "success"}), 200
else:
logger.warning(f"incorrect password {user.username}")
return jsonify({'error': 'wrong password provided'}), 401
@blueprint.route('/playlist/run', methods=['GET'])
@login_or_basic_auth
@validate_args(('name', str))
def run_playlist(user=None):
if os.environ.get('DEPLOY_DESTINATION', None) == 'PROD':
queue_run_user_playlist(user.username, request.args['name']) # pass to either cloud tasks or functions
else:
run_user_playlist(user, request.args['name']) # update synchronously
return jsonify({'message': 'execution requested', 'status': 'success'}), 200
@blueprint.route('/playlist/run/task', methods=['POST'])
@cloud_task
def run_playlist_task(): # receives cloud tasks request for update
payload = request.get_data(as_text=True)
if payload:
payload = json.loads(payload)
logger.info(f'running {payload["username"]} / {payload["name"]}')
offload_or_run_user_playlist(payload['username'], payload['name']) # check whether offloading to cloud function
return jsonify({'message': 'executed playlist', 'status': 'success'}), 200
logger.critical('no payload provided')
@blueprint.route('/playlist/run/user', methods=['GET'])
@login_or_basic_auth
def run_user(user=None):
if user.type == 'admin':
user_name = request.args.get('username', user.username)
else:
user_name = user.username
update_playlists(user_name)
return jsonify({'message': 'executed user', 'status': 'success'}), 200
@blueprint.route('/playlist/run/user/task', methods=['POST'])
@cloud_task
def run_user_task():
payload = request.get_data(as_text=True)
if payload:
update_playlists(payload)
return jsonify({'message': 'executed user', 'status': 'success'}), 200
@blueprint.route('/playlist/run/users', methods=['GET'])
@login_or_basic_auth
@admin_required
def run_users(user=None):
update_all_user_playlists()
return jsonify({'message': 'executed all users', 'status': 'success'}), 200
@blueprint.route('/playlist/image', methods=['GET'])
@login_or_basic_auth
@spotify_link_required
@validate_args(('name', str))
def image(user=None):
_playlist = Playlist.collection.parent(user.key).filter('name', '==', request.args['name']).get()
if _playlist is None:
return jsonify({'error': "playlist not found"}), 404
net = database.get_authed_spotify_network(user)
try:
return jsonify({'images': net.playlist(uri=_playlist.uri).images, 'status': 'success'}), 200
except SpotifyNetworkException as e:
logger.exception(f'error occured during {_playlist.name} / {user.username} playlist retrieval')
return jsonify({'error': f"spotify error occured: {e.http_code}"}), 404

View File

@ -1,180 +0,0 @@
import functools
import logging
from flask import session, request, jsonify
from music.model.user import User
logger = logging.getLogger(__name__)
def is_logged_in():
if 'username' in session:
return True
else:
return False
def is_basic_authed():
if request.authorization:
if request.authorization.get('username', None) and request.authorization.get('password', None):
user = User.collection.filter('username', '==', request.authorization.username.strip().lower()).get()
if user is None:
return False, None
if user.check_password(request.authorization.password):
return True, user
else:
return False, user
return False, None
def login_required(func):
@functools.wraps(func)
def login_required_wrapper(*args, **kwargs):
if is_logged_in():
user = User.collection.filter('username', '==', session['username'].strip().lower()).get()
return func(*args, user=user, **kwargs)
else:
logger.warning('user not logged in')
return jsonify({'error': 'not logged in'}), 401
return login_required_wrapper
def login_or_basic_auth(func):
@functools.wraps(func)
def login_or_basic_auth_wrapper(*args, **kwargs):
if is_logged_in():
user = User.collection.filter('username', '==', session['username'].strip().lower()).get()
return func(*args, user=user, **kwargs)
else:
check, user = is_basic_authed()
if check:
return func(*args, user=user, **kwargs)
else:
logger.warning('user not logged in')
return jsonify({'error': 'not logged in'}), 401
return login_or_basic_auth_wrapper
def admin_required(func):
@functools.wraps(func)
def admin_required_wrapper(*args, **kwargs):
db_user = kwargs.get('user')
if db_user is not None:
if db_user.type == 'admin':
return func(*args, **kwargs)
else:
logger.warning(f'{db_user.username} not authorized')
return jsonify({'status': 'error', 'message': 'unauthorized'}), 401
else:
logger.warning('user not logged in')
return jsonify({'error': 'not logged in'}), 401
return admin_required_wrapper
def spotify_link_required(func):
@functools.wraps(func)
def spotify_link_required_wrapper(*args, **kwargs):
db_user = kwargs.get('user')
if db_user is not None:
if db_user.spotify_linked:
return func(*args, **kwargs)
else:
logger.warning(f'{db_user.username} spotify not linked')
return jsonify({'status': 'error', 'message': 'spotify not linked'}), 401
else:
logger.warning('user not logged in')
return jsonify({'error': 'not logged in'}), 401
return spotify_link_required_wrapper
def lastfm_username_required(func):
@functools.wraps(func)
def lastfm_username_required_wrapper(*args, **kwargs):
db_user = kwargs.get('user')
if db_user is not None:
if db_user.lastfm_username and len(db_user.lastfm_username) > 0:
return func(*args, **kwargs)
else:
logger.warning(f'no last.fm username for {db_user.username}')
return jsonify({'status': 'error', 'message': 'no last.fm username'}), 401
else:
logger.warning('user not logged in')
return jsonify({'error': 'not logged in'}), 401
return lastfm_username_required_wrapper
def gae_cron(func):
@functools.wraps(func)
def gae_cron_wrapper(*args, **kwargs):
if request.headers.get('X-Appengine-Cron', None):
return func(*args, **kwargs)
else:
logger.warning('user not logged in')
return jsonify({'status': 'error', 'message': 'unauthorised'}), 401
return gae_cron_wrapper
def cloud_task(func):
@functools.wraps(func)
def cloud_task_wrapper(*args, **kwargs):
if request.headers.get('X-AppEngine-QueueName', None):
return func(*args, **kwargs)
else:
logger.warning('non tasks request')
return jsonify({'status': 'error', 'message': 'unauthorised'}), 401
return cloud_task_wrapper
def validate_json(*expected_args):
def decorator_validate_json(func):
@functools.wraps(func)
def wrapper_validate_json(*args, **kwargs):
return check_dict(request_params=request.get_json(),
expected_args=expected_args,
func=func,
args=args, kwargs=kwargs)
return wrapper_validate_json
return decorator_validate_json
def validate_args(*expected_args):
def decorator_validate_args(func):
@functools.wraps(func)
def wrapper_validate_args(*args, **kwargs):
return check_dict(request_params=request.args,
expected_args=expected_args,
func=func,
args=args, kwargs=kwargs)
return wrapper_validate_args
return decorator_validate_args
def check_dict(request_params, expected_args, func, args, kwargs):
for expected_arg in expected_args:
if isinstance(expected_arg, tuple):
arg_key = expected_arg[0]
else:
arg_key = expected_arg
if arg_key not in request_params:
return jsonify({'status': 'error', 'message': f'{arg_key} not provided'}), 400
if isinstance(expected_arg, tuple):
if not isinstance(request_params[arg_key], expected_arg[1]):
return jsonify({'status': 'error', 'message': f'{arg_key} not of type {expected_arg[1]}'}), 400
return func(*args, **kwargs)

View File

@ -1,25 +0,0 @@
from flask import Blueprint, jsonify
from datetime import date
import logging
from music.api.decorators import login_or_basic_auth, lastfm_username_required
import music.db.database as database
blueprint = Blueprint('fm-api', __name__)
logger = logging.getLogger(__name__)
@blueprint.route('/today', methods=['GET'])
@login_or_basic_auth
@lastfm_username_required
def daily_scrobbles(user=None):
net = database.get_authed_lastfm_network(user)
total = net.count_scrobbles_from_date(input_date=date.today())
return jsonify({
'username': net.username,
'scrobbles_today': total
}), 200

View File

@ -1,119 +0,0 @@
from flask import Blueprint, request, jsonify
import logging
from music.api.decorators import login_or_basic_auth, spotify_link_required, validate_json
import music.db.database as database
from spotframework.net.network import SpotifyNetworkException
from spotframework.model.track import Context
from spotframework.model.uri import Uri
from spotframework.player.player import Player
blueprint = Blueprint('player_api', __name__)
logger = logging.getLogger(__name__)
@blueprint.route('/play', methods=['POST'])
@login_or_basic_auth
@spotify_link_required
def play(user=None):
request_json = request.get_json()
if 'uri' in request_json:
try:
uri = Uri(request_json['uri'])
if uri.object_type in [Uri.ObjectType.album, Uri.ObjectType.artist, Uri.ObjectType.playlist]:
context = Context(uri)
net = database.get_authed_spotify_network(user)
player = Player(net)
player.play(context=context, device_name=request_json.get('device_name', None))
logger.info(f'played {uri}')
return jsonify({'message': 'played', 'status': 'success'}), 200
else:
return jsonify({'error': "uri not context compatible"}), 400
except ValueError:
return jsonify({'error': "malformed uri provided"}), 400
elif 'playlist_name' in request_json:
net = database.get_authed_spotify_network(user)
try:
playlists = net.playlists()
playlist_to_play = next((i for i in playlists if i.name == request_json['playlist_name']), None)
if playlist_to_play is not None:
player = Player(net)
player.play(context=Context(playlist_to_play.uri), device_name=request_json.get('device_name', None))
logger.info(f'played {request_json["playlist_name"]}')
return jsonify({'message': 'played', 'status': 'success'}), 200
else:
return jsonify({'error': f"playlist {request_json['playlist_name']} not found"}), 404
except SpotifyNetworkException:
logger.exception(f'error occured during {user.username} playlists retrieval')
return jsonify({'error': "playlists not returned"}), 400
elif 'tracks' in request_json:
try:
uris = [Uri(i) for i in request_json['tracks']]
# TODO check uri object type
if len(uris) > 0:
net = database.get_authed_spotify_network(user)
player = Player(net)
player.play(uris=uris, device_name=request_json.get('device_name', None))
logger.info(f'played tracks')
return jsonify({'message': 'played', 'status': 'success'}), 200
else:
return jsonify({'error': "no track uris provided"}), 400
except ValueError:
return jsonify({'error': "uris failed to parse"}), 400
else:
return jsonify({'error': "no uris provided"}), 400
@blueprint.route('/next', methods=['POST'])
@login_or_basic_auth
@spotify_link_required
def next_track(user=None):
net = database.get_authed_spotify_network(user)
player = Player(net)
player.next()
return jsonify({'message': 'skipped', 'status': 'success'}), 200
@blueprint.route('/shuffle', methods=['POST'])
@login_or_basic_auth
@spotify_link_required
@validate_json(('state', bool))
def shuffle(user=None):
request_json = request.get_json()
net = database.get_authed_spotify_network(user)
player = Player(net)
player.shuffle(state=request_json['state'])
return jsonify({'message': f'shuffle set to {request_json["state"]}', 'status': 'success'}), 200
@blueprint.route('/volume', methods=['POST'])
@login_or_basic_auth
@spotify_link_required
@validate_json(('volume', int))
def volume(user=None):
request_json = request.get_json()
if 0 <= request_json['volume'] <= 100:
net = database.get_authed_spotify_network(user)
player = Player(net)
player.volume(value=request_json['volume'])
return jsonify({'message': f'volume set to {request_json["volume"]}', 'status': 'success'}), 200
else:
return jsonify({'error': "volume must be between 0 and 100"}), 400

View File

@ -1,164 +0,0 @@
from flask import Blueprint, jsonify, request
import logging
import json
import os
from music.api.decorators import admin_required, login_or_basic_auth, lastfm_username_required, \
spotify_link_required, cloud_task, validate_args
import music.db.database as database
from music.cloud.tasks import refresh_all_user_playlist_stats, refresh_user_playlist_stats, refresh_playlist_task
from music.tasks.refresh_lastfm_stats import refresh_lastfm_track_stats, \
refresh_lastfm_album_stats, \
refresh_lastfm_artist_stats
from spotfm.maths.counter import Counter
from spotframework.model.uri import Uri
from spotframework.net.network import SpotifyNetworkException
blueprint = Blueprint('spotfm-api', __name__)
logger = logging.getLogger(__name__)
@blueprint.route('/count', methods=['GET'])
@login_or_basic_auth
@spotify_link_required
@lastfm_username_required
def count(user=None):
uri = request.args.get('uri', None)
playlist_name = request.args.get('playlist_name', None)
if uri is None and playlist_name is None:
return jsonify({'error': 'no input provided'}), 401
if uri:
try:
uri = Uri(uri)
except ValueError:
return jsonify({'error': 'malformed uri provided'}), 401
spotnet = database.get_authed_spotify_network(user)
fmnet = database.get_authed_lastfm_network(user)
counter = Counter(fmnet=fmnet, spotnet=spotnet)
if uri:
uri_count = counter.count(uri=uri)
return jsonify({
"uri": str(uri),
"count": uri_count,
'uri_type': str(uri.object_type),
'last.fm_username': fmnet.username
}), 200
elif playlist_name:
try:
playlists = spotnet.playlists()
playlist = next((i for i in playlists if i.name == playlist_name), None)
if playlist is not None:
playlist_count = counter.count_playlist(playlist=playlist)
return jsonify({
"count": playlist_count,
'playlist_name': playlist_name,
'last.fm_username': fmnet.username
}), 200
else:
return jsonify({'error': f'playlist {playlist_name} not found'}), 404
except SpotifyNetworkException:
logger.exception(f'error occured during {user.username} playlists retrieval')
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
@validate_args(('name', str))
def playlist_refresh(user=None):
playlist_name = request.args['name']
if os.environ.get('DEPLOY_DESTINATION', None) == 'PROD':
refresh_playlist_task(user.username, playlist_name)
else:
refresh_lastfm_track_stats(user.username, playlist_name)
refresh_lastfm_album_stats(user.username, playlist_name)
refresh_lastfm_artist_stats(user.username, playlist_name)
return jsonify({'message': 'execution requested', 'status': 'success'}), 200
@blueprint.route('/playlist/refresh/task/track', methods=['POST'])
@cloud_task
def run_playlist_track_task():
payload = request.get_data(as_text=True)
if payload:
payload = json.loads(payload)
logger.info(f'refreshing tracks {payload["username"]} / {payload["name"]}')
refresh_lastfm_track_stats(payload['username'], payload['name'])
return jsonify({'message': 'executed playlist', 'status': 'success'}), 200
@blueprint.route('/playlist/refresh/task/album', methods=['POST'])
@cloud_task
def run_playlist_album_task():
payload = request.get_data(as_text=True)
if payload:
payload = json.loads(payload)
logger.info(f'refreshing albums {payload["username"]} / {payload["name"]}')
refresh_lastfm_album_stats(payload['username'], payload['name'])
return jsonify({'message': 'executed playlist', 'status': 'success'}), 200
@blueprint.route('/playlist/refresh/task/artist', methods=['POST'])
@cloud_task
def run_playlist_artist_task():
payload = request.get_data(as_text=True)
if payload:
payload = json.loads(payload)
logger.info(f'refreshing artists {payload["username"]} / {payload["name"]}')
refresh_lastfm_artist_stats(payload['username'], payload['name'])
return jsonify({'message': 'executed playlist', 'status': 'success'}), 200
@blueprint.route('/playlist/refresh/users', methods=['GET'])
@login_or_basic_auth
@admin_required
def run_users(user=None):
refresh_all_user_playlist_stats()
return jsonify({'message': 'executed all users', 'status': 'success'}), 200
@blueprint.route('/playlist/refresh/user', methods=['GET'])
@login_or_basic_auth
def run_user(user=None):
if user.type == 'admin':
user_name = request.args.get('username', user.username)
else:
user_name = user.username
refresh_user_playlist_stats(user_name)
return jsonify({'message': 'executed user', 'status': 'success'}), 200
@blueprint.route('/playlist/refresh/user/task', methods=['POST'])
@cloud_task
def run_user_task():
payload = request.get_data(as_text=True)
if payload:
refresh_user_playlist_stats(payload)
return jsonify({'message': 'executed user', 'status': 'success'}), 200

View File

@ -1,36 +0,0 @@
from flask import Blueprint, request, jsonify
import logging
from music.api.decorators import login_or_basic_auth, spotify_link_required
import music.db.database as database
from spotframework.engine.playlistengine import PlaylistEngine
from spotframework.model.uri import Uri
blueprint = Blueprint('spotify_api', __name__)
logger = logging.getLogger(__name__)
@blueprint.route('/sort', methods=['POST'])
@login_or_basic_auth
@spotify_link_required
def sort(user=None):
request_json = request.get_json()
net = database.get_authed_spotify_network(user)
engine = PlaylistEngine(net)
reverse = request_json.get('reverse', False)
if 'uri' in request_json:
try:
uri = Uri(request_json['uri'])
engine.reorder_playlist_by_added_date(uri=uri, reverse=reverse)
except ValueError:
return jsonify({'error': "malformed uri provided"}), 400
elif 'playlist_name' in request_json:
engine.reorder_playlist_by_added_date(name=request_json.get('playlist_name'), reverse=reverse)
else:
return jsonify({'error': "no uris provided"}), 400
return jsonify({'message': 'sorted', 'status': 'success'}), 200

View File

@ -1,153 +0,0 @@
from flask import Blueprint, jsonify, request
import logging
import os
import json
from music.api.decorators import login_or_basic_auth, cloud_task
from music.cloud.function import update_tag as serverless_update_tag
from music.tasks.update_tag import update_tag
from music.model.tag import Tag
blueprint = Blueprint('task', __name__)
logger = logging.getLogger(__name__)
@blueprint.route('/tag', methods=['GET'])
@login_or_basic_auth
def tags(user=None):
logger.info(f'retrieving tags for {user.username}')
return jsonify({
'tags': [i.to_dict() for i in Tag.collection.parent(user.key).fetch()]
}), 200
@blueprint.route('/tag/<tag_id>', methods=['GET', 'PUT', 'POST', "DELETE"])
@login_or_basic_auth
def tag_route(tag_id, user=None):
if request.method == 'GET':
return get_tag(tag_id, user)
elif request.method == 'PUT':
return put_tag(tag_id, user)
elif request.method == 'POST':
return post_tag(tag_id, user)
elif request.method == 'DELETE':
return delete_tag(tag_id, user)
def get_tag(tag_id, user):
logger.info(f'retrieving {tag_id} for {user.username}')
db_tag = Tag.collection.parent(user.key).filter('tag_id', '==', tag_id).get()
if db_tag is not None:
return jsonify({
'tag': db_tag.to_dict()
}), 200
else:
return jsonify({"error": 'tag not found'}), 404
def put_tag(tag_id, user):
logger.info(f'updating {tag_id} for {user.username}')
db_tag = Tag.collection.parent(user.key).filter('tag_id', '==', tag_id).get()
if db_tag is None:
return jsonify({"error": 'tag not found'}), 404
request_json = request.get_json()
if request_json.get('name'):
db_tag.name = request_json['name'].strip()
if request_json.get('time_objects') is not None:
db_tag.time_objects = request_json['time_objects']
if request_json.get('tracks') is not None:
db_tag.tracks = [
{
'name': track['name'].strip(),
'artist': track['artist'].strip()
}
for track in request_json['tracks']
if track.get('name') and track.get('artist')
]
if request_json.get('albums') is not None:
db_tag.albums = [
{
'name': album['name'].strip(),
'artist': album['artist'].strip()
}
for album in request_json['albums']
if album.get('name') and album.get('artist')
]
if request_json.get('artists') is not None:
db_tag.artists = [
{
'name': artist['name'].strip()
}
for artist in request_json['artists']
if artist.get('name')
]
db_tag.update()
return jsonify({"message": 'tag updated', "status": "success"}), 200
def post_tag(tag_id, user):
logger.info(f'creating {tag_id} for {user.username}')
tag_id = tag_id.replace(' ', '_').strip()
existing_ids = [i.tag_id for i in Tag.collection.parent(user.key).fetch()]
while tag_id in existing_ids:
tag_id += '_'
tag = Tag(parent=user.key)
tag.tag_id = tag_id
tag.name = tag_id
tag.username = user.username
tag.save()
return jsonify({"message": 'tag added', "status": "success"}), 201
def delete_tag(tag_id, user):
logger.info(f'deleting {tag_id} for {user.username}')
db_tag = Tag.collection.parent(user.key).filter('tag_id', '==', tag_id).get()
Tag.collection.parent(user.key).delete(key=db_tag.key)
return jsonify({"message": 'tag deleted', "status": "success"}), 201
@blueprint.route('/tag/<tag_id>/update', methods=['GET'])
@login_or_basic_auth
def tag_refresh(tag_id, user=None):
logger.info(f'updating {tag_id} tag for {user.username}')
if os.environ.get('DEPLOY_DESTINATION', None) == 'PROD':
serverless_update_tag(username=user.username, tag_id=tag_id)
else:
update_tag(user=user, tag=tag_id)
return jsonify({"message": 'tag updated', "status": "success"}), 200
@blueprint.route('/tag/update/task', methods=['POST'])
@cloud_task
def run_tag_task():
payload = request.get_data(as_text=True)
if payload:
payload = json.loads(payload)
logger.info(f'running {payload["username"]} / {payload["tag_id"]}')
serverless_update_tag(username=payload['username'], tag_id=payload['tag_id'])
return jsonify({'message': 'executed playlist', 'status': 'success'}), 200

View File

@ -1 +0,0 @@
from .auth import blueprint as auth_blueprint

View File

@ -1,194 +0,0 @@
from flask import Blueprint, session, flash, request, redirect, url_for, render_template
from werkzeug.security import generate_password_hash
from music.model.user import User
from music.model.config import Config
from urllib.parse import urlencode, urlunparse
import datetime
import logging
from base64 import b64encode
import requests
blueprint = Blueprint('authapi', __name__)
logger = logging.getLogger(__name__)
@blueprint.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
session.pop('username', None)
username = request.form.get('username', None)
password = request.form.get('password', None)
if username is None or password is None:
flash('malformed request')
return redirect(url_for('index'))
user = User.collection.filter('username', '==', username.strip().lower()).get()
if user is None:
flash('user not found')
return redirect(url_for('index'))
if user.check_password(password):
if user.locked:
logger.warning(f'locked account attempt {username}')
flash('account locked')
return redirect(url_for('index'))
user.last_login = datetime.datetime.utcnow()
user.update()
logger.info(f'success {username}')
session['username'] = username
return redirect(url_for('app_route'))
else:
logger.warning(f'failed attempt {username}')
flash('incorrect password')
return redirect(url_for('index'))
else:
return redirect(url_for('index'))
@blueprint.route('/logout', methods=['GET', 'POST'])
def logout():
if 'username' in session:
logger.info(f'logged out {session["username"]}')
session.pop('username', None)
flash('logged out')
return redirect(url_for('index'))
@blueprint.route('/register', methods=['GET', 'POST'])
def register():
if 'username' in session:
return redirect(url_for('index'))
if request.method == 'GET':
return render_template('register.html')
else:
username = request.form.get('username', None)
password = request.form.get('password', None)
password_again = request.form.get('password_again', None)
if username is None or password is None or password_again is None:
flash('malformed request')
return redirect('authapi.register')
username = username.lower()
if password != password_again:
flash('password mismatch')
return redirect('authapi.register')
if username in [i.username for i in
User.collection.fetch()]:
flash('username already registered')
return redirect('authapi.register')
user = User()
user.username = username
user.password = generate_password_hash(password)
user.last_login = datetime.datetime.utcnow()
user.save()
logger.info(f'new user {username}')
session['username'] = username
return redirect(url_for('authapi.auth'))
@blueprint.route('/spotify')
def auth():
if 'username' in session:
config = Config.collection.get("config/music-tools")
params = urlencode(
{
'client_id': config.spotify_client_id,
'response_type': 'code',
'scope': 'playlist-modify-public playlist-modify-private playlist-read-private '
'user-read-playback-state user-modify-playback-state user-library-read',
'redirect_uri': 'https://music.sarsoo.xyz/auth/spotify/token'
}
)
return redirect(urlunparse(['https', 'accounts.spotify.com', 'authorize', '', params, '']))
return redirect(url_for('index'))
@blueprint.route('/spotify/token')
def token():
if 'username' in session:
code = request.args.get('code', None)
if code is None:
flash('authorization failed')
return redirect('app_route')
else:
config = Config.collection.get("config/music-tools")
idsecret = b64encode(
bytes(config.spotify_client_id + ':' + config.spotify_client_secret, "utf-8")
).decode("ascii")
headers = {'Authorization': 'Basic %s' % idsecret}
data = {
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': 'https://music.sarsoo.xyz/auth/spotify/token'
}
req = requests.post('https://accounts.spotify.com/api/token', data=data, headers=headers)
if 200 <= req.status_code < 300:
resp = req.json()
user = User.collection.filter('username', '==', session['username'].strip().lower()).get()
user.access_token = resp['access_token']
user.refresh_token = resp['refresh_token']
user.last_refreshed = datetime.datetime.now(datetime.timezone.utc)
user.token_expiry = resp['expires_in']
user.spotify_linked = True
user.update()
else:
flash('http error on token request')
return redirect('app_route')
return redirect('/app/settings/spotify')
return redirect(url_for('index'))
@blueprint.route('/spotify/deauth')
def deauth():
if 'username' in session:
user = User.collection.filter('username', '==', session['username'].strip().lower()).get()
user.access_token = None
user.refresh_token = None
user.last_refreshed = datetime.datetime.now(datetime.timezone.utc)
user.token_expiry = None
user.spotify_linked = False
user.update()
return redirect('/app/settings/spotify')
return redirect(url_for('index'))

View File

@ -1,49 +0,0 @@
import logging
from music.model.config import Config
from music.tasks.run_user_playlist import run_user_playlist as run_now
from .function import run_user_playlist_function
from .tasks import run_user_playlist_task
logger = logging.getLogger(__name__)
def queue_run_user_playlist(username: str, playlist_name: str):
config = Config.collection.get("config/music-tools")
if config is None:
logger.error(f'no config object returned, passing to cloud function {username} / {playlist_name}')
run_user_playlist_function(username=username, playlist_name=playlist_name)
if config.playlist_cloud_operating_mode == 'task':
logger.debug(f'passing {username} / {playlist_name} to cloud tasks')
run_user_playlist_task(username=username, playlist_name=playlist_name)
elif config.playlist_cloud_operating_mode == 'function':
logger.debug(f'passing {username} / {playlist_name} to cloud function')
run_user_playlist_function(username=username, playlist_name=playlist_name)
else:
logger.critical(f'invalid operating mode {username} / {playlist_name}, '
f'{config.playlist_cloud_operating_mode}, passing to cloud function')
run_user_playlist_function(username=username, playlist_name=playlist_name)
def offload_or_run_user_playlist(username: str, playlist_name: str):
config = Config.collection.get("config/music-tools")
if config is None:
logger.error(f'no config object returned, passing to cloud function {username} / {playlist_name}')
run_user_playlist_function(username=username, playlist_name=playlist_name)
if config.playlist_cloud_operating_mode == 'task':
run_now(user=username, playlist=playlist_name)
elif config.playlist_cloud_operating_mode == 'function':
logger.debug(f'offloading {username} / {playlist_name} to cloud function')
run_user_playlist_function(username=username, playlist_name=playlist_name)
else:
logger.critical(f'invalid operating mode {username} / {playlist_name}, '
f'{config.playlist_cloud_operating_mode}, passing to cloud function')
run_user_playlist_function(username=username, playlist_name=playlist_name)

View File

@ -1,35 +0,0 @@
import logging
from google.cloud import pubsub_v1
publisher = pubsub_v1.PublisherClient()
logger = logging.getLogger(__name__)
def update_tag(username, tag_id):
"""Queue serverless tag update for user"""
logger.info(f'queuing {tag_id} update for {username}')
if username is None or tag_id is None:
logger.error(f'less than two args provided, {username} / {tag_id}')
return
if not isinstance(username, str) or not isinstance(tag_id, str):
logger.error(f'less than two strings provided, {type(username)} / {type(tag_id)}')
return
publisher.publish('projects/sarsooxyz/topics/update_tag', b'', tag_id=tag_id, username=username)
def run_user_playlist_function(username, playlist_name):
"""Queue serverless playlist update for user"""
logger.info(f'queuing {playlist_name} update for {username}')
if username is None or playlist_name is None:
logger.error(f'less than two args provided, {username} / {playlist_name}')
return
if not isinstance(username, str) or not isinstance(playlist_name, str):
logger.error(f'less than two strings provided, {type(username)} / {type(playlist_name)}')
return
publisher.publish('projects/sarsooxyz/topics/run_user_playlist', b'', name=playlist_name, username=username)

View File

@ -1,263 +0,0 @@
import datetime
import json
import os
import logging
from google.cloud import tasks_v2
from google.protobuf import timestamp_pb2
from music.tasks.run_user_playlist import run_user_playlist
from music.tasks.refresh_lastfm_stats import refresh_lastfm_track_stats
from music.model.user import User
from music.model.playlist import Playlist
from music.model.tag import Tag
tasker = tasks_v2.CloudTasksClient()
task_path = tasker.queue_path('sarsooxyz', 'europe-west2', 'spotify-executions')
logger = logging.getLogger(__name__)
def update_all_user_playlists():
"""Create user playlist refresh task for all users"""
seconds_delay = 0
logger.info('running')
for iter_user in User.collection.fetch():
if iter_user.spotify_linked and not iter_user.locked:
task = {
'app_engine_http_request': { # Specify the type of request.
'http_method': 'POST',
'relative_uri': '/api/playlist/run/user/task',
'body': iter_user.username.encode()
}
}
d = datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds_delay)
timestamp = timestamp_pb2.Timestamp()
timestamp.FromDatetime(d)
task['schedule_time'] = timestamp
tasker.create_task(task_path, task)
seconds_delay += 30
def update_playlists(username):
"""Refresh all playlists for given user, environment dependent"""
user = User.collection.filter('username', '==', username.strip().lower()).get()
if user is None:
logger.error(f'user {username} not found')
return
playlists = Playlist.collection.parent(user.key).fetch()
seconds_delay = 0
logger.info(f'running {username}')
for iterate_playlist in playlists:
if iterate_playlist.uri is not None:
if os.environ.get('DEPLOY_DESTINATION', None) == 'PROD':
run_user_playlist_task(username, iterate_playlist.name, seconds_delay)
else:
run_user_playlist(user, iterate_playlist)
seconds_delay += 6
def run_user_playlist_task(username, playlist_name, delay=0):
"""Create tasks for a users given playlist"""
task = {
'app_engine_http_request': { # Specify the type of request.
'http_method': 'POST',
'relative_uri': '/api/playlist/run/task',
'body': json.dumps({
'username': username,
'name': playlist_name
}).encode()
}
}
if delay > 0:
d = datetime.datetime.utcnow() + datetime.timedelta(seconds=delay)
# Create Timestamp protobuf.
timestamp = timestamp_pb2.Timestamp()
timestamp.FromDatetime(d)
# Add the timestamp to the tasks.
task['schedule_time'] = timestamp
tasker.create_task(task_path, task)
def refresh_all_user_playlist_stats():
""""Create user playlist stats refresh task for all users"""
seconds_delay = 0
logger.info('running')
for iter_user in User.collection.fetch():
if iter_user.spotify_linked and iter_user.lastfm_username and \
len(iter_user.lastfm_username) > 0 and not iter_user.locked:
if os.environ.get('DEPLOY_DESTINATION', None) == 'PROD':
refresh_user_stats_task(username=iter_user.username, delay=seconds_delay)
else:
refresh_user_playlist_stats(username=iter_user.username)
seconds_delay += 2400
else:
logger.debug(f'skipping {iter_user.username}')
def refresh_user_playlist_stats(username):
"""Refresh all playlist stats for given user, environment dependent"""
user = User.collection.filter('username', '==', username.strip().lower()).get()
if user is None:
logger.error(f'user {username} not found')
return
playlists = Playlist.collection.parent(user.key).fetch()
seconds_delay = 0
logger.info(f'running stats for {username}')
if user.lastfm_username and len(user.lastfm_username) > 0:
for playlist in playlists:
if playlist.uri is not None:
if os.environ.get('DEPLOY_DESTINATION', None) == 'PROD':
refresh_playlist_task(username, playlist.name, seconds_delay)
else:
refresh_lastfm_track_stats(username, playlist.name)
seconds_delay += 1200
else:
logger.error('no last.fm username')
def refresh_user_stats_task(username, delay=0):
"""Create user playlist stats refresh task"""
task = {
'app_engine_http_request': { # Specify the type of request.
'http_method': 'POST',
'relative_uri': '/api/spotfm/playlist/refresh/user/task',
'body': username.encode()
}
}
if delay > 0:
d = datetime.datetime.utcnow() + datetime.timedelta(seconds=delay)
timestamp = timestamp_pb2.Timestamp()
timestamp.FromDatetime(d)
task['schedule_time'] = timestamp
tasker.create_task(task_path, task)
def refresh_playlist_task(username, playlist_name, delay=0):
"""Create user playlist stats refresh tasks"""
track_task = {
'app_engine_http_request': { # Specify the type of request.
'http_method': 'POST',
'relative_uri': '/api/spotfm/playlist/refresh/task/track',
'body': json.dumps({
'username': username,
'name': playlist_name
}).encode()
}
}
if delay > 0:
d = datetime.datetime.utcnow() + datetime.timedelta(seconds=delay)
timestamp = timestamp_pb2.Timestamp()
timestamp.FromDatetime(d)
track_task['schedule_time'] = timestamp
album_task = {
'app_engine_http_request': { # Specify the type of request.
'http_method': 'POST',
'relative_uri': '/api/spotfm/playlist/refresh/task/album',
'body': json.dumps({
'username': username,
'name': playlist_name
}).encode()
}
}
d = datetime.datetime.utcnow() + datetime.timedelta(seconds=delay + 180)
timestamp = timestamp_pb2.Timestamp()
timestamp.FromDatetime(d)
album_task['schedule_time'] = timestamp
artist_task = {
'app_engine_http_request': { # Specify the type of request.
'http_method': 'POST',
'relative_uri': '/api/spotfm/playlist/refresh/task/artist',
'body': json.dumps({
'username': username,
'name': playlist_name
}).encode()
}
}
d = datetime.datetime.utcnow() + datetime.timedelta(seconds=delay + 360)
timestamp = timestamp_pb2.Timestamp()
timestamp.FromDatetime(d)
artist_task['schedule_time'] = timestamp
tasker.create_task(task_path, track_task)
tasker.create_task(task_path, album_task)
tasker.create_task(task_path, artist_task)
def update_all_user_tags():
"""Create user tag refresh task sfor all users"""
seconds_delay = 0
logger.info('running')
for iter_user in User.collection.fetch():
if iter_user.lastfm_username and len(iter_user.lastfm_username) > 0 and not iter_user.locked:
for tag in Tag.collection.parent(iter_user.key).fetch():
task = {
'app_engine_http_request': { # Specify the type of request.
'http_method': 'POST',
'relative_uri': '/api/tag/update/task',
'body': json.dumps({
'username': iter_user.username,
'tag_id': tag.tag_id
}).encode()
}
}
d = datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds_delay)
timestamp = timestamp_pb2.Timestamp()
timestamp.FromDatetime(d)
task['schedule_time'] = timestamp
tasker.create_task(task_path, task)
seconds_delay += 10

View File

@ -1,79 +0,0 @@
from dataclasses import dataclass
import logging
from datetime import timedelta, datetime, timezone
from spotframework.net.network import Network as SpotifyNetwork, SpotifyNetworkException
from spotframework.net.user import NetworkUser
from fmframework.net.network import Network as FmNetwork
from music.model.user import User
from music.model.config import Config
logger = logging.getLogger(__name__)
def refresh_token_database_callback(user):
if isinstance(user, DatabaseUser):
user_obj = User.collection.filter('username', '==', user.user_id.strip().lower()).get()
if user_obj is None:
logger.error(f'user {user} not found')
user_obj.access_token = user.access_token
user_obj.refresh_token = user.refresh_token
user_obj.last_refreshed = user.last_refreshed
user_obj.token_expiry = user.token_expiry
user_obj.update()
logger.debug(f'{user.user_id} database entry updated')
else:
logger.error('user has no attached id')
def get_authed_spotify_network(user):
if user is not None:
if user.spotify_linked:
config = Config.collection.get("config/music-tools")
user_obj = DatabaseUser(client_id=config.spotify_client_id,
client_secret=config.spotify_client_secret,
refresh_token=user.refresh_token,
user_id=user.username,
access_token=user.access_token)
user_obj.on_refresh.append(refresh_token_database_callback)
net = SpotifyNetwork(user_obj)
if user.last_refreshed is not None and user.token_expiry is not None:
if user.last_refreshed + timedelta(seconds=user.token_expiry - 1) \
< datetime.now(timezone.utc):
net.refresh_access_token()
else:
net.refresh_access_token()
try:
net.refresh_user_info()
except SpotifyNetworkException:
logger.exception(f'error refreshing user info for {user.username}')
return net
else:
logger.error('user spotify not linked')
else:
logger.error(f'no user provided')
def get_authed_lastfm_network(user):
if user is not None:
if user.lastfm_username:
config = Config.collection.get("config/music-tools")
return FmNetwork(username=user.lastfm_username, api_key=config.last_fm_client_id)
else:
logger.error(f'{user.username} has no last.fm username')
else:
logger.error(f'no user provided')
@dataclass
class DatabaseUser(NetworkUser):
"""adding music tools username to spotframework network user"""
user_id: str = None

View File

@ -1,69 +0,0 @@
from music.model.user import User
from music.model.playlist import Playlist
import logging
logger = logging.getLogger(__name__)
class PartGenerator:
def __init__(self, user: User = None, username: str = None):
self.queried_playlists = []
self.parts = []
if user:
self.user = user
elif username:
pulled_user = User.collection.filter('username', '==', username.strip().lower()).get()
if pulled_user:
self.user = pulled_user
else:
raise LookupError(f'{username} not found')
else:
raise NameError('no user info provided')
def reset(self):
self.queried_playlists = []
self.parts = []
def get_recursive_parts(self, name):
logger.info(f'getting part from {name} for {self.user.username}')
self.reset()
self.process_reference_by_name(name)
return [i for i in {i for i in self.parts}]
def process_reference_by_name(self, name):
playlist = Playlist.collection.parent(self.user.key).filter('name', '==', name).get()
if playlist is not None:
if playlist.id not in self.queried_playlists:
self.parts += playlist.parts
self.queried_playlists.append(playlist.id)
for i in playlist.playlist_references:
if i.id not in self.queried_playlists:
self.process_reference_by_reference(i)
else:
logger.warning(f'playlist reference {name} already queried')
else:
logger.warning(f'playlist reference {name} not found')
def process_reference_by_reference(self, ref):
if ref.id not in self.queried_playlists:
playlist_reference_object = ref.get().to_dict()
self.parts += playlist_reference_object['parts']
self.queried_playlists.append(ref.id)
for i in playlist_reference_object['playlist_references']:
self.process_reference_by_reference(i)
else:
logger.warning(f'playlist reference {ref.get().to_dict()["name"]} already queried')

View File

@ -1,14 +0,0 @@
from fireo.models import Model
from fireo.fields import TextField, BooleanField, DateTime, NumberField, ListField
class Config(Model):
class Meta:
collection_name = 'config'
spotify_client_id = TextField()
spotify_client_secret = TextField()
last_fm_client_id = TextField()
playlist_cloud_operating_mode = TextField() # task, function
secret_key = TextField()

View File

@ -1,84 +0,0 @@
from enum import Enum
from fireo.models import Model
from fireo.fields import TextField, BooleanField, DateTime, NumberField, ListField
class Sort(Enum):
default = 1
shuffle = 2
release_date = 3
class Playlist(Model):
class Meta:
collection_name = 'playlists'
uri = TextField()
name = TextField(required=True)
type = TextField(required=True)
include_recommendations = BooleanField(default=False)
recommendation_sample = NumberField(default=10)
include_library_tracks = BooleanField(default=False)
parts = ListField(default=[])
playlist_references = ListField(default=[])
shuffle = BooleanField(default=False)
sort = TextField(default='release_date')
description_overwrite = TextField()
description_suffix = TextField()
last_updated = DateTime()
lastfm_stat_count = NumberField(default=0)
lastfm_stat_album_count = NumberField(default=0)
lastfm_stat_artist_count = NumberField(default=0)
lastfm_stat_percent = NumberField(default=0)
lastfm_stat_album_percent = NumberField(default=0)
lastfm_stat_artist_percent = NumberField(default=0)
lastfm_stat_last_refresh = DateTime()
add_last_month = BooleanField(default=False)
add_this_month = BooleanField(default=False)
day_boundary = NumberField(default=21)
chart_range = TextField(default='MONTH')
chart_limit = NumberField(default=50)
mutable_keys = [
'type',
'include_recommendations',
'recommendation_sample',
'include_library_tracks',
'parts',
'playlist_references',
'shuffle',
'sort',
'description_overwrite',
'description_suffix',
'add_last_month',
'add_this_month',
'day_boundary',
'chart_range',
'chart_limit'
]
def to_dict(self):
to_return = super().to_dict()
to_return["playlist_references"] = [i.get().to_dict().get('name') for i in to_return['playlist_references']]
# remove unnecessary and sensitive fields
to_return.pop('id', None)
to_return.pop('key', None)
return to_return

View File

@ -1,34 +0,0 @@
from fireo.models import Model
from fireo.fields import TextField, DateTime, NumberField, ListField, BooleanField
class Tag(Model):
class Meta:
collection_name = 'tags'
tag_id = TextField(required=True)
name = TextField(required=True)
username = TextField(required=True)
tracks = ListField(default=[])
albums = ListField(default=[])
artists = ListField(default=[])
count = NumberField(default=0)
proportion = NumberField(default=0)
total_user_scrobbles = NumberField(default=0)
last_updated = DateTime()
time_objects = BooleanField(default=False)
total_time = TextField(default='00:00:00')
total_time_ms = NumberField(default=0)
def to_dict(self):
to_return = super().to_dict()
# remove unnecessary and sensitive fields
to_return.pop('id', None)
to_return.pop('key', None)
return to_return

View File

@ -1,42 +0,0 @@
from fireo.models import Model
from fireo.fields import TextField, BooleanField, DateTime, NumberField
from werkzeug.security import check_password_hash
class User(Model):
class Meta:
collection_name = 'spotify_users'
username = TextField(required=True)
password = TextField(required=True)
email = TextField()
type = TextField(default="user")
last_login = DateTime()
last_refreshed = DateTime()
locked = BooleanField(default=False, required=True)
validated = BooleanField(default=True, required=True)
spotify_linked = BooleanField(default=False, required=True)
access_token = TextField()
refresh_token = TextField()
token_expiry = NumberField()
lastfm_username = TextField()
def check_password(self, password):
return check_password_hash(self.password, password)
def to_dict(self):
to_return = super().to_dict()
# remove unnecessary and sensitive fields
to_return.pop('password', None)
to_return.pop('access_token', None)
to_return.pop('refresh_token', None)
to_return.pop('token_expiry', None)
to_return.pop('id', None)
to_return.pop('key', None)
return to_return

View File

@ -1,55 +0,0 @@
from flask import Flask, render_template, redirect, session, flash, url_for
import logging
import os
from music.auth import auth_blueprint
from music.api import api_blueprint, player_blueprint, fm_blueprint, \
spotfm_blueprint, spotify_blueprint, admin_blueprint, tag_blueprint
from music.model.config import Config
logger = logging.getLogger(__name__)
def create_app():
app = Flask(__name__, static_folder=os.path.join(os.path.dirname(__file__), '..', 'build'), template_folder="templates")
config = Config.collection.get("config/music-tools")
if config is not None:
app.secret_key = config.secret_key
else:
logger.error('no config returned, skipping secret key')
app.register_blueprint(auth_blueprint, url_prefix='/auth')
app.register_blueprint(api_blueprint, url_prefix='/api')
app.register_blueprint(player_blueprint, url_prefix='/api/player')
app.register_blueprint(fm_blueprint, url_prefix='/api/fm')
app.register_blueprint(spotfm_blueprint, url_prefix='/api/spotfm')
app.register_blueprint(spotify_blueprint, url_prefix='/api/spotify')
app.register_blueprint(admin_blueprint, url_prefix='/api/admin')
app.register_blueprint(tag_blueprint, url_prefix='/api')
@app.route('/')
def index():
if 'username' in session:
logged_in = True
return redirect(url_for('app_route'))
else:
logged_in = False
return render_template('login.html', logged_in=logged_in)
@app.route('/app', defaults={'path': ''})
@app.route('/app/<path:path>')
def app_route(path):
if 'username' not in session:
flash('please log in')
return redirect(url_for('index'))
return render_template('app.html')
return app
# [END gae_python37_app]

View File

@ -1,22 +0,0 @@
import logging
import music.db.database as database
from spotframework.net.network import SpotifyNetworkException
logger = logging.getLogger(__name__)
def create_playlist(user, name):
if user is None:
logger.error(f'username not provided')
return
logger.info(f'creating spotify playlist for {user.username} / {name}')
net = database.get_authed_spotify_network(user)
try:
return net.create_playlist(net.user.user.display_name, name)
except SpotifyNetworkException:
logger.exception(f'error ocurred {user.username} / {name}')
return

View File

@ -1,151 +0,0 @@
import logging
from datetime import datetime
import music.db.database as database
from music.model.user import User
from music.model.playlist import Playlist
from spotfm.maths.counter import Counter
from spotframework.net.network import SpotifyNetworkException
from fmframework.net.network import LastFMNetworkException
logger = logging.getLogger(__name__)
def refresh_lastfm_track_stats(username, playlist_name):
logger.info(f'refreshing {playlist_name} stats for {username}')
user = User.collection.filter('username', '==', username.strip().lower()).get()
if user is None:
logger.error(f'user {username} not found')
fmnet = database.get_authed_lastfm_network(user)
spotnet = database.get_authed_spotify_network(user)
counter = Counter(fmnet=fmnet, spotnet=spotnet)
playlist = Playlist.collection.parent(user.key).filter('name', '==', playlist_name).get()
if playlist is None:
logger.critical(f'playlist {playlist_name} for {username} not found')
return
if playlist.uri is None:
logger.critical(f'playlist {playlist_name} for {username} has no spotify uri')
return
try:
spotify_playlist = spotnet.playlist(uri=playlist.uri)
except SpotifyNetworkException:
logger.exception(f'error retrieving spotify playlist {username} / {playlist_name}')
return
track_count = counter.count_playlist(playlist=spotify_playlist)
try:
user_count = fmnet.user_scrobble_count()
if user_count > 0:
percent = round((track_count * 100) / user_count, 2)
else:
percent = 0
except LastFMNetworkException:
logger.exception(f'error while retrieving user scrobble count {username} / {playlist_name}')
percent = 0
playlist.lastfm_stat_count = track_count
playlist.lastfm_stat_percent = percent
playlist.lastfm_stat_last_refresh = datetime.utcnow()
playlist.update()
def refresh_lastfm_album_stats(username, playlist_name):
logger.info(f'refreshing {playlist_name} stats for {username}')
user = User.collection.filter('username', '==', username.strip().lower()).get()
if user is None:
logger.error(f'user {username} not found')
fmnet = database.get_authed_lastfm_network(user)
spotnet = database.get_authed_spotify_network(user)
counter = Counter(fmnet=fmnet, spotnet=spotnet)
playlist = Playlist.collection.parent(user.key).filter('name', '==', playlist_name).get()
if playlist is None:
logger.critical(f'playlist {playlist_name} for {username} not found')
return
if playlist.uri is None:
logger.critical(f'playlist {playlist_name} for {username} has no spotify uri')
return
try:
spotify_playlist = spotnet.playlist(uri=playlist.uri)
except SpotifyNetworkException:
logger.exception(f'error retrieving spotify playlist {username} / {playlist_name}')
return
album_count = counter.count_playlist(playlist=spotify_playlist, query_album=True)
try:
user_count = fmnet.user_scrobble_count()
if user_count > 0:
album_percent = round((album_count * 100) / user_count, 2)
else:
album_percent = 0
except LastFMNetworkException:
logger.exception(f'error while retrieving user scrobble count {username} / {playlist_name}')
album_percent = 0
playlist.lastfm_stat_album_count = album_count
playlist.lastfm_stat_album_percent = album_percent
playlist.lastfm_stat_last_refresh = datetime.utcnow()
playlist.update()
def refresh_lastfm_artist_stats(username, playlist_name):
logger.info(f'refreshing {playlist_name} stats for {username}')
user = User.collection.filter('username', '==', username.strip().lower()).get()
if user is None:
logger.error(f'user {username} not found')
fmnet = database.get_authed_lastfm_network(user)
spotnet = database.get_authed_spotify_network(user)
counter = Counter(fmnet=fmnet, spotnet=spotnet)
playlist = Playlist.collection.parent(user.key).filter('name', '==', playlist_name).get()
if playlist is None:
logger.critical(f'playlist {playlist_name} for {username} not found')
return
if playlist.uri is None:
logger.critical(f'playlist {playlist_name} for {username} has no spotify uri')
return
try:
spotify_playlist = spotnet.playlist(uri=playlist.uri)
except SpotifyNetworkException:
logger.exception(f'error retrieving spotify playlist {username} / {playlist_name}')
return
artist_count = counter.count_playlist(playlist=spotify_playlist, query_artist=True)
try:
user_count = fmnet.user_scrobble_count()
if user_count > 0:
artist_percent = round((artist_count * 100) / user_count, 2)
else:
artist_percent = 0
except LastFMNetworkException:
logger.exception(f'error while retrieving user scrobble count {username} / {playlist_name}')
artist_percent = 0
playlist.lastfm_stat_artist_count = artist_count
playlist.lastfm_stat_artist_percent = artist_percent
playlist.lastfm_stat_last_refresh = datetime.utcnow()
playlist.update()

View File

@ -1,201 +0,0 @@
import datetime
import logging
import random
import spotframework.util.monthstrings as monthstrings
from spotframework.model.uri import Uri
from spotframework.filter import remove_local, get_track_objects
from spotframework.filter.added import added_after
from spotframework.filter.sort import sort_by_release_date
from spotframework.filter.deduplicate import deduplicate_by_name
from spotframework.net.network import SpotifyNetworkException
from fmframework.net.network import Network
from spotfm.chart import map_lastfm_track_chart_to_spotify
import music.db.database as database
from music.db.part_generator import PartGenerator
from music.model.user import User
from music.model.playlist import Playlist
logger = logging.getLogger(__name__)
def run_user_playlist(user, playlist, spotnet=None, fmnet=None):
"""Generate and upadate a user's playlist"""
# PRE-RUN CHECKS
if isinstance(user, str):
username = user
user = User.collection.filter('username', '==', username.strip().lower()).get()
else:
username = user.username
if user is None:
logger.error(f'user {username} not found')
raise NameError(f'User {username} not found')
if isinstance(playlist, str):
playlist_name = playlist
playlist = Playlist.collection.parent(user.key).filter('name', '==', playlist_name).get()
else:
playlist_name = playlist.name
if playlist is None:
logger.critical(f'playlist not found {username} / {playlist_name}')
raise NameError(f'Playlist {playlist_name} not found for {username}')
if playlist.uri is None:
logger.critical(f'no playlist id to populate {username} / {playlist_name}')
raise AttributeError(f'No URI for {playlist_name} ({username})')
# END CHECKS
logger.info(f'running {username} / {playlist_name}')
if spotnet is None:
spotnet = database.get_authed_spotify_network(user)
if spotnet is None:
logger.error(f'no spotify network returned for {username} / {playlist_name}')
raise NameError(f'No Spotify network returned ({username} / {playlist_name})')
try:
user_playlists = spotnet.playlists()
except SpotifyNetworkException as e:
logger.exception(f'error occured while retrieving playlists {username} / {playlist_name}')
raise e
part_generator = PartGenerator(user=user)
part_names = part_generator.get_recursive_parts(playlist.name)
playlist_tracks = []
if playlist.add_last_month:
part_names.append(monthstrings.get_last_month())
if playlist.add_this_month:
part_names.append(monthstrings.get_this_month())
# LOAD PLAYLIST TRACKS
for part_name in part_names:
try: # attempt to cast to uri
uri = Uri(part_name)
log_name = uri
except ValueError: # is a playlist name
part_playlist = next((i for i in user_playlists if i.name == part_name), None)
if part_playlist is None:
logger.warning(f'playlist {part_name} not found {username} / {playlist_name}')
continue
uri = part_playlist.uri
log_name = part_playlist.name
try:
_tracks = spotnet.playlist_tracks(uri=uri)
if _tracks and len(_tracks) > 0:
playlist_tracks += _tracks
else:
logger.warning(f'no tracks returned for {log_name} {username} / {playlist_name}')
except SpotifyNetworkException:
logger.exception(f'error occured while retrieving {log_name} {username} / {playlist_name}')
playlist_tracks = list(remove_local(playlist_tracks))
# LIBRARY
if playlist.include_library_tracks:
try:
library_tracks = spotnet.saved_tracks()
if library_tracks and len(library_tracks) > 0:
playlist_tracks += library_tracks
else:
logger.error(f'error getting library tracks {username} / {playlist_name}')
except SpotifyNetworkException:
logger.exception(f'error occured while retrieving library tracks {username} / {playlist_name}')
# PLAYLIST TYPE SPECIFIC
if playlist.type == 'recents':
boundary_date = datetime.datetime.now(datetime.timezone.utc) - \
datetime.timedelta(days=int(playlist.day_boundary))
playlist_tracks = list(added_after(playlist_tracks, boundary_date))
elif playlist.type == 'fmchart':
if user.lastfm_username is None:
logger.error(f'no associated last.fm username, chart source skipped {username} / {playlist_name}')
else:
chart_range = Network.Range.MONTH
try:
chart_range = Network.Range[playlist.chart_range]
except KeyError:
logger.error(f'invalid last.fm chart range found {playlist.chart_range}, '
f'defaulting to 1 month {username} / {playlist_name}')
if fmnet is None:
fmnet = database.get_authed_lastfm_network(user)
if fmnet is not None:
chart_tracks = map_lastfm_track_chart_to_spotify(spotnet=spotnet,
fmnet=fmnet,
period=chart_range,
limit=playlist.chart_limit)
if chart_tracks is not None and len(chart_tracks) > 0:
playlist_tracks += chart_tracks
else:
logger.error(f'no tracks returned {username} / {playlist_name}')
else:
logger.error(f'no last.fm network returned {username} / {playlist_name}')
# SORT METHOD
if playlist.shuffle:
random.shuffle(playlist_tracks)
elif playlist.type != 'fmchart':
playlist_tracks = sort_by_release_date(tracks=playlist_tracks, reverse=True)
# RECOMMENDATIONS
if playlist.include_recommendations:
try:
recommendations = spotnet.recommendations(tracks=[i.uri.object_id for i, j
in get_track_objects(
random.sample(playlist_tracks,
k=min(5, len(playlist_tracks))
)
)
if i.uri.object_type == Uri.ObjectType.track],
response_limit=playlist.recommendation_sample)
if recommendations and len(recommendations.tracks) > 0:
playlist_tracks += recommendations.tracks
else:
logger.error(f'error getting recommendations {username} / {playlist_name}')
except SpotifyNetworkException:
logger.exception(f'error occured while generating recommendations {username} / {playlist_name}')
# DEDUPLICATE
playlist_tracks = deduplicate_by_name(playlist_tracks)
# EXECUTE
try:
spotnet.replace_playlist_tracks(uri=playlist.uri, uris=[i.uri for i, j in get_track_objects(playlist_tracks)])
if playlist.description_overwrite:
string = playlist.description_overwrite
else:
string = ' / '.join(sorted(part_names))
if playlist.description_suffix:
string += f' - {str(playlist.description_suffix)}'
if string is None or len(string) == 0:
logger.error(f'no string generated {username} / {playlist_name}')
return None
try:
spotnet.change_playlist_details(uri=playlist.uri, description=string)
except SpotifyNetworkException:
logger.exception(f'error changing description for {username} / {playlist_name}')
except SpotifyNetworkException:
logger.exception(f'error executing {username} / {playlist_name}')
playlist.last_updated = datetime.datetime.utcnow()
playlist.update()

View File

@ -1,167 +0,0 @@
import logging
from datetime import datetime
import music.db.database as database
from music.model.user import User
from music.model.tag import Tag
from fmframework.net.network import LastFMNetworkException
from spotfm.timer import time, seconds_to_time_str
logger = logging.getLogger(__name__)
def update_tag(user, tag, spotnet=None, fmnet=None):
# PRE-RUN CHECKS
if isinstance(user, str):
username = user
user = User.collection.filter('username', '==', username.strip().lower()).get()
else:
username = user.username
if user is None:
logger.error(f'user {username} not found')
raise NameError(f'User {username} not found')
if isinstance(tag, str):
tag_id = tag
tag = Tag.collection.parent(user.key).filter('tag_id', '==', tag_id).get()
else:
tag_id = tag.tag_id
if tag is None:
logger.error(f'{tag_id} for {username} not found')
raise NameError(f'Tag {tag_id} not found for {username}')
if user.lastfm_username is None or len(user.lastfm_username) == 0:
logger.error(f'{username} has no last.fm username')
raise AttributeError(f'{username} has no Last.fm username ({tag_id})')
# END CHECKS
logger.info(f'updating {username} / {tag_id}')
if fmnet is None:
fmnet = database.get_authed_lastfm_network(user)
if fmnet is None:
logger.error(f'no last.fm network returned for {username} / {tag_id}')
raise NameError(f'No Last.fm network returned ({username} / {tag_id})')
if tag.time_objects:
if user.spotify_linked:
if spotnet is None:
spotnet = database.get_authed_spotify_network(user)
else:
logger.warning(f'timing objects requested but no spotify linked {username} / {tag_id}')
tag.count = 0
tag.total_time_ms = 0
try:
user_scrobbles = fmnet.user_scrobble_count()
except LastFMNetworkException:
logger.exception(f'error retrieving scrobble count {username} / {tag_id}')
user_scrobbles = 1
artists = []
for artist in tag.artists:
try:
if tag.time_objects and user.spotify_linked:
total_ms, timed_tracks = time(spotnet=spotnet, fmnet=fmnet,
artist=artist['name'], username=user.lastfm_username,
return_tracks=True)
scrobbles = sum(i[0].user_scrobbles for i in timed_tracks)
artist['time_ms'] = total_ms
artist['time'] = seconds_to_time_str(milliseconds=total_ms)
tag.total_time_ms += total_ms
else:
net_artist = fmnet.artist(name=artist['name'])
if net_artist is not None:
scrobbles = net_artist.user_scrobbles
else:
scrobbles = 0
artist['count'] = scrobbles
tag.count += scrobbles
except LastFMNetworkException:
logger.exception(f'error during artist retrieval {username} / {tag_id}')
artists.append(artist)
albums = []
for album in tag.albums:
try:
if tag.time_objects and user.spotify_linked:
total_ms, timed_tracks = time(spotnet=spotnet, fmnet=fmnet,
album=album['name'], artist=album['artist'],
username=user.lastfm_username, return_tracks=True)
scrobbles = sum(i[0].user_scrobbles for i in timed_tracks)
album['time_ms'] = total_ms
album['time'] = seconds_to_time_str(milliseconds=total_ms)
tag.total_time_ms += total_ms
else:
net_album = fmnet.album(name=album['name'], artist=album['artist'])
if net_album is not None:
scrobbles = net_album.user_scrobbles
else:
scrobbles = 0
album['count'] = scrobbles
if album['artist'].lower() not in [i.lower() for i in [j['name'] for j in artists]]:
tag.count += scrobbles
except LastFMNetworkException:
logger.exception(f'error during album retrieval {username} / {tag_id}')
albums.append(album)
tracks = []
for track in tag.tracks:
try:
if tag.time_objects and user.spotify_linked:
total_ms, timed_tracks = time(spotnet=spotnet, fmnet=fmnet,
track=track['name'], artist=track['artist'],
username=user.lastfm_username, return_tracks=True)
scrobbles = sum(i[0].user_scrobbles for i in timed_tracks)
track['time_ms'] = total_ms
track['time'] = seconds_to_time_str(milliseconds=total_ms)
tag.total_time_ms += total_ms
else:
net_track = fmnet.track(name=track['name'], artist=track['artist'])
if net_track is not None:
scrobbles = net_track.user_scrobbles
else:
scrobbles = 0
track['count'] = scrobbles
if track['artist'].lower() not in [i.lower() for i in [j['name'] for j in artists]]:
tag.count += scrobbles
except LastFMNetworkException:
logger.exception(f'error during track retrieval {username} / {tag_id}')
tracks.append(track)
tag.tracks = tracks
tag.albums = albums
tag.artists = artists
tag.total_time = seconds_to_time_str(milliseconds=tag.total_time_ms)
tag.total_user_scrobbles = user_scrobbles
tag.proportion = (tag.count / user_scrobbles) * 100
tag.last_updated = datetime.utcnow()
tag.update()

View File

@ -1,45 +0,0 @@
<!doctype html>
<html>
<head>
<title>sarsoo/music</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link type="text/css" rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link href="https://fonts.googleapis.com/css?family=Pirata+One|Lato" rel="stylesheet">
<link rel="apple-touch-icon" sizes="180x180" href="https://storage.googleapis.com/sarsooxyzstatic/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="https://storage.googleapis.com/sarsooxyzstatic/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="https://storage.googleapis.com/sarsooxyzstatic/favicon-16x16.png">
<link rel="manifest" href="https://storage.googleapis.com/sarsooxyzstatic/site.webmanifest">
<link rel="mask-icon" href="https://storage.googleapis.com/sarsooxyzstatic/safari-pinned-tab.svg" color="#5bbad5">
<link rel="shortcut icon" href="https://storage.googleapis.com/sarsooxyzstatic/favicon.ico">
</head>
<body>
<div>
<h1 class="title">sarsoo</h1>
</div>
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div class="row card pad-12">
<p class="center-text" style="color: red">{{ message }}</p>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<br><br>
<div id="react"></div>
<script src="{{ url_for('static', filename='js/app.bundle.js') }}"></script>
<div id="snackbar">toast</div>
<img src="https://storage.googleapis.com/sarsooxyzstatic/andy.png"
alt="AP"
width="120px"
style="display: block;
margin-left: auto;
margin-right: auto;
padding: 8px">
</body>
</html>

View File

@ -1,39 +0,0 @@
<!doctype html>
<html>
<head>
<title>sarsoo/{% block title %}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link type="text/css" rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link href="https://fonts.googleapis.com/css?family=Pirata+One|Roboto" rel="stylesheet">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
<link rel="apple-touch-icon" sizes="180x180" href="https://storage.googleapis.com/sarsooxyzstatic/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="https://storage.googleapis.com/sarsooxyzstatic/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="https://storage.googleapis.com/sarsooxyzstatic/favicon-16x16.png">
<link rel="manifest" href="https://storage.googleapis.com/sarsooxyzstatic/site.webmanifest">
<link rel="mask-icon" href="https://storage.googleapis.com/sarsooxyzstatic/safari-pinned-tab.svg" color="#5bbad5">
<link rel="shortcut icon" href="https://storage.googleapis.com/sarsooxyzstatic/favicon.ico">
{% block scripts %}{% endblock %}
</head>
<body>
<div>
<h1 class="title">sarsoo</h1>
</div>
<br><br>
<ul class="navbar">
<li><a href="/">Home</a></li>
<li><a href="https://sarsoo.xyz">Sarsoo.xyz</a></li>
</ul>
{% block content %}{% endblock %}
<img src="https://storage.googleapis.com/sarsooxyzstatic/andy.png"
alt="AP"
width="120px"
style="display: block;
margin-left: auto;
margin-right: auto;
padding: 8px">
</body>
</html>

View File

@ -1,86 +0,0 @@
{% extends 'base.html' %}
{% block title %}music{% endblock %}
{% block content %}
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div class="row card pad-12">
<p class="center-text" style="color: red">{{ message }}</p>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="row">
<div class="{% if logged_in %}pad-12{% else %}pad-9{% endif %} card">
<h1 class="center-text">Music Tools</h1>
<p class="center-text">A set of tools using Spotify and Last.fm to create smart playlists and calculate listening statistics</p>
<p class="center-text">Playlists are updated multiple times a day to keep tracks up to date</p>
<p class="center-text">Written in Python and Javascript, based on the <a href="https://github.com/Sarsoo/spotframework">spotframework</a> and <a href="https://github.com/Sarsoo/pyfmframework">fmframework</a> libraries</p>
<div style="padding:10px">
<a class="button" style="padding:15px;width:49%" href="https://github.com/Sarsoo/Music-Tools">View Source</a>
<a class="button" style="padding:15px;width:49%" href="https://github.com/Sarsoo/Music-Tools-iOS">iOS Client Source</a>
</div>
{% if logged_in %}
<a class="button full-width" href="/app">Launch</a>
{% endif %}
</div>
{% if not logged_in %}
{% block form %}{% endblock %}
{% endif %}
</div>
<div class="row">
<h1>Smart Playlists</h1>
</div>
<div class="row center-text">
<div class="pad-6 card">
<h1 class="center-text">Sources</h1>
<p>Create smart Spotify playlists pulling tracks from</p>
<ul style="text-align:left" >
<li style="padding:10px">Spotify Playlists</li>
<li style="padding:10px">Spotify Library Tracks</li>
<li style="padding:10px">Music Tools Playlists</li>
</ul>
</div>
<div class="pad-6 card">
<h1 class="center-text">Currents</h1>
<p>Currents are mix playlists acting as a snapshot of what's being listened to at the moment</p>
<p>Includes date filtering on playlist sources for recently added tracks</p>
<p>Optionally search for and include "monthly" playlists for the last two months when named e.g february 20</p>
</div>
</div>
<div class="row center-text">
<div class="pad-6 card">
<h1 class="center-text">Last.fm Charts</h1>
<p>Create playlists including Last.fm track charts of varying length and time range</p>
</div>
<div class="pad-6 card">
<h1 class="center-text">Recommendations</h1>
<p>Include Spotify recommendations based on the other tracks of the playlist</p>
</div>
</div>
<div class="row">
<h1>Tags</h1>
</div>
<div class="row center-text">
<div class="pad-6 card">
<h1 class="center-text">Last.fm Integration</h1>
<p>Create groups of entries for scrobble summing and listening stats</p>
</div>
<div class="pad-6 card">
<h1 class="center-text">Visualise Data</h1>
<p>Present listening trends with pie charts</p>
</div>
</div>
{% endblock %}

View File

@ -1,19 +0,0 @@
{% extends 'index.html' %}
{% block form %}
<div class="pad-3 card">
<form name="login" action="/auth/login" method="POST" onsubmit="return handleLogin()">
<p class="center-text">Username<br><input type="text" name="username" class="full-width"></p>
<p class="center-text">Password<br><input type="password" name="password" class="full-width"></p>
<p id="status" style="display: none; color: red" class="center-text"></p>
<button class="button full-width" onclick="handleLogin()" type="submit">Login</button>
<br><br>
<a href="/auth/register" class="button full-width">Register</a>
</form>
<script src="{{ url_for('static', filename='js/login.bundle.js') }}"></script>
</div>
{% endblock %}

View File

@ -1,18 +0,0 @@
{% extends 'index.html' %}
{% block form %}
<div class="pad-3 card">
<form name="register" action="/auth/register" method="POST" onsubmit="return handleRegister()">
<p class="center-text">Username<br><input type="text" name="username" class="full-width"></p>
<p class="center-text">Password<br><input type="password" name="password" class="full-width"></p>
<p class="center-text">Password again<br><input type="password" name="password_again" class="full-width"></p>
<p id="status" style="display: none; color: red" class="center-text"></p>
<button class="button full-width" onclick="handleRegister()" type="submit">Register</button>
</form>
<script src="{{ url_for('static', filename='js/register.bundle.js') }}"></script>
</div>
{% endblock %}

BIN
objects.inv Normal file

Binary file not shown.

13990
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,43 +0,0 @@
{
"name": "music-tools",
"version": "1.0.0",
"description": "web app for spotify playlist management with last.fm integration",
"private": true,
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "webpack --config webpack.prod.js --env production",
"devbuild": "webpack --config webpack.dev.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Sarsoo/Music-Tools.git"
},
"author": "sarsoo",
"license": "ISC",
"bugs": {
"url": "https://github.com/Sarsoo/Music-Tools/issues"
},
"homepage": "https://github.com/Sarsoo/Music-Tools#readme",
"dependencies": {
"@material-ui/core": "^4.11.3",
"@material-ui/icons": "^4.11.2",
"axios": "^0.21.1",
"chart.js": "^2.9.4",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"react-router-dom": "^5.2.0"
},
"devDependencies": {
"@babel/cli": "^7.12.16",
"@babel/core": "^7.12.16",
"@babel/preset-env": "^7.12.16",
"@babel/preset-react": "^7.12.13",
"babel-loader": "^8.2.2",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^3.6.0",
"style-loader": "^0.23.1",
"webpack": "^4.46.0",
"webpack-cli": "^3.3.12",
"webpack-merge": "^4.2.2"
}
}

866
poetry.lock generated
View File

@ -1,866 +0,0 @@
[[package]]
name = "astroid"
version = "2.5"
description = "An abstract syntax tree for Python with inference support."
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
lazy-object-proxy = ">=1.4.0"
wrapt = ">=1.11,<1.13"
[[package]]
name = "backports-datetime-fromisoformat"
version = "1.0.0"
description = "Backport of Python 3.7's datetime.fromisoformat"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "beautifulsoup4"
version = "4.9.3"
description = "Screen-scraping library"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
soupsieve = {version = ">1.2", markers = "python_version >= \"3.0\""}
[package.extras]
html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]]
name = "cachetools"
version = "4.2.1"
description = "Extensible memoizing collections and decorators"
category = "main"
optional = false
python-versions = "~=3.5"
[[package]]
name = "certifi"
version = "2020.12.5"
description = "Python package for providing Mozilla's CA Bundle."
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "chardet"
version = "4.0.0"
description = "Universal encoding detector for Python 2 and 3"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "click"
version = "7.1.2"
description = "Composable command line interface toolkit"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "colorama"
version = "0.4.4"
description = "Cross-platform colored terminal text."
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "fireo"
version = "1.4.1"
description = "FireO ORM is specifically designed for the Google's Firestore."
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
backports-datetime-fromisoformat = "1.0.0"
google-cloud-firestore = "1.9.0"
[[package]]
name = "flask"
version = "1.1.2"
description = "A simple framework for building complex web applications."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.dependencies]
click = ">=5.1"
itsdangerous = ">=0.24"
Jinja2 = ">=2.10.1"
Werkzeug = ">=0.15"
[package.extras]
dev = ["pytest", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinxcontrib-log-cabinet", "sphinx-issues"]
docs = ["sphinx", "pallets-sphinx-themes", "sphinxcontrib-log-cabinet", "sphinx-issues"]
dotenv = ["python-dotenv"]
[[package]]
name = "fmframework"
version = "1.0.0"
description = "Last.fm HTTP wrapper library"
category = "main"
optional = false
python-versions = "^3.8"
develop = false
[package.dependencies]
beautifulsoup4 = "^4.9.3"
requests = "^2.24.0"
[package.extras]
image = []
[package.source]
type = "git"
url = "https://github.com/Sarsoo/pyfmframework.git"
reference = "master"
resolved_reference = "bf111d06cb4ffc9dbe0b57fbf83a3c236365c2ad"
[[package]]
name = "google-api-core"
version = "1.26.1"
description = "Google API client core library"
category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*"
[package.dependencies]
google-auth = ">=1.21.1,<2.0dev"
googleapis-common-protos = ">=1.6.0,<2.0dev"
grpcio = {version = ">=1.29.0,<2.0dev", optional = true, markers = "extra == \"grpc\""}
packaging = ">=14.3"
protobuf = ">=3.12.0"
pytz = "*"
requests = ">=2.18.0,<3.0.0dev"
six = ">=1.13.0"
[package.extras]
grpc = ["grpcio (>=1.29.0,<2.0dev)"]
grpcgcp = ["grpcio-gcp (>=0.2.2)"]
grpcio-gcp = ["grpcio-gcp (>=0.2.2)"]
[[package]]
name = "google-auth"
version = "1.28.0"
description = "Google Authentication Library"
category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*"
[package.dependencies]
cachetools = ">=2.0.0,<5.0"
pyasn1-modules = ">=0.2.1"
rsa = {version = ">=3.1.4,<5", markers = "python_version >= \"3.6\""}
six = ">=1.9.0"
[package.extras]
aiohttp = ["aiohttp (>=3.6.2,<4.0.0dev)"]
pyopenssl = ["pyopenssl (>=20.0.0)"]
[[package]]
name = "google-cloud-core"
version = "1.6.0"
description = "Google Cloud API client core library"
category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*"
[package.dependencies]
google-api-core = ">=1.21.0,<2.0.0dev"
google-auth = ">=1.24.0,<2.0dev"
six = ">=1.12.0"
[package.extras]
grpc = ["grpcio (>=1.8.2,<2.0dev)"]
[[package]]
name = "google-cloud-firestore"
version = "1.9.0"
description = "Google Cloud Firestore API client library"
category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
[package.dependencies]
google-api-core = {version = ">=1.14.0,<2.0.0dev", extras = ["grpc"]}
google-cloud-core = ">=1.4.1,<2.0dev"
pytz = "*"
[[package]]
name = "google-cloud-logging"
version = "1.15.1"
description = "Stackdriver Logging API client library"
category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
[package.dependencies]
google-api-core = {version = ">=1.15.0,<2.0.0dev", extras = ["grpc"]}
google-cloud-core = ">=1.1.0,<2.0dev"
[[package]]
name = "google-cloud-pubsub"
version = "1.7.0"
description = "Google Cloud Pub/Sub API client library"
category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*"
[package.dependencies]
google-api-core = {version = ">=1.14.0,<1.17.0 || >=1.20.0", extras = ["grpc"]}
grpc-google-iam-v1 = ">=0.12.3,<0.13dev"
[[package]]
name = "google-cloud-tasks"
version = "1.5.0"
description = "Cloud Tasks API API client library"
category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
[package.dependencies]
google-api-core = {version = ">=1.14.0,<2.0.0dev", extras = ["grpc"]}
grpc-google-iam-v1 = ">=0.12.3,<0.13dev"
[[package]]
name = "googleapis-common-protos"
version = "1.53.0"
description = "Common protobufs used in Google APIs"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
grpcio = {version = ">=1.0.0", optional = true, markers = "extra == \"grpc\""}
protobuf = ">=3.12.0"
[package.extras]
grpc = ["grpcio (>=1.0.0)"]
[[package]]
name = "grpc-google-iam-v1"
version = "0.12.3"
description = "GRPC library for the google-iam-v1 service"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
googleapis-common-protos = {version = ">=1.5.2,<2.0.0dev", extras = ["grpc"]}
grpcio = ">=1.0.0,<2.0.0dev"
[[package]]
name = "grpcio"
version = "1.36.1"
description = "HTTP/2-based RPC framework"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
six = ">=1.5.2"
[package.extras]
protobuf = ["grpcio-tools (>=1.36.1)"]
[[package]]
name = "idna"
version = "2.10"
description = "Internationalized Domain Names in Applications (IDNA)"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "isort"
version = "4.3.21"
description = "A Python utility / library to sort Python imports."
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.extras]
pipfile = ["pipreqs", "requirementslib"]
pyproject = ["toml"]
requirements = ["pipreqs", "pip-api"]
xdg_home = ["appdirs (>=1.4.0)"]
[[package]]
name = "itsdangerous"
version = "1.1.0"
description = "Various helpers to pass data to untrusted environments and back."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "jinja2"
version = "2.11.3"
description = "A very fast and expressive template engine."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.dependencies]
MarkupSafe = ">=0.23"
[package.extras]
i18n = ["Babel (>=0.8)"]
[[package]]
name = "lazy-object-proxy"
version = "1.5.2"
description = "A fast and thorough lazy object proxy."
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "markupsafe"
version = "1.1.1"
description = "Safely add untrusted strings to HTML/XML markup."
category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
[[package]]
name = "mccabe"
version = "0.6.1"
description = "McCabe checker, plugin for flake8"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "packaging"
version = "20.9"
description = "Core utilities for Python packages"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.dependencies]
pyparsing = ">=2.0.2"
[[package]]
name = "protobuf"
version = "3.15.6"
description = "Protocol Buffers"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
six = ">=1.9"
[[package]]
name = "pyasn1"
version = "0.4.8"
description = "ASN.1 types and codecs"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "pyasn1-modules"
version = "0.2.8"
description = "A collection of ASN.1-based protocols modules."
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
pyasn1 = ">=0.4.6,<0.5.0"
[[package]]
name = "pylint"
version = "2.5.3"
description = "python code static checker"
category = "dev"
optional = false
python-versions = ">=3.5.*"
[package.dependencies]
astroid = ">=2.4.0,<=2.5"
colorama = {version = "*", markers = "sys_platform == \"win32\""}
isort = ">=4.2.5,<5"
mccabe = ">=0.6,<0.7"
toml = ">=0.7.1"
[[package]]
name = "pyparsing"
version = "2.4.7"
description = "Python parsing module"
category = "main"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "pytz"
version = "2021.1"
description = "World timezone definitions, modern and historical"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "requests"
version = "2.25.1"
description = "Python HTTP for Humans."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.dependencies]
certifi = ">=2017.4.17"
chardet = ">=3.0.2,<5"
idna = ">=2.5,<3"
urllib3 = ">=1.21.1,<1.27"
[package.extras]
security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
[[package]]
name = "rsa"
version = "4.7.2"
description = "Pure-Python RSA implementation"
category = "main"
optional = false
python-versions = ">=3.5, <4"
[package.dependencies]
pyasn1 = ">=0.1.3"
[[package]]
name = "six"
version = "1.15.0"
description = "Python 2 and 3 compatibility utilities"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "soupsieve"
version = "2.2.1"
description = "A modern CSS selector implementation for Beautiful Soup."
category = "main"
optional = false
python-versions = ">=3.6"
[[package]]
name = "spotfm"
version = "1.0.0"
description = "Interface functions for spotframework and fmframework. Maths functions among others"
category = "main"
optional = false
python-versions = "^3.8"
develop = false
[package.dependencies]
fmframework = "branch master"
spotframework = "branch master"
[package.source]
type = "git"
url = "https://github.com/Sarsoo/spotfm.git"
reference = "master"
resolved_reference = "062347347b81f41c9f473686d5770b68c582fb86"
[[package]]
name = "spotframework"
version = "1.0.1"
description = "Spotify HTTP wrapper library"
category = "main"
optional = false
python-versions = "^3.8"
develop = false
[package.dependencies]
click = "^7.1.2"
requests = "^2.24.0"
tabulate = "^0.8.7"
[package.source]
type = "git"
url = "https://github.com/Sarsoo/spotframework.git"
reference = "master"
resolved_reference = "50230c3ab8067f1ed45d355c85f6eba7dc9c2c71"
[[package]]
name = "tabulate"
version = "0.8.9"
description = "Pretty-print tabular data"
category = "main"
optional = false
python-versions = "*"
[package.extras]
widechars = ["wcwidth"]
[[package]]
name = "toml"
version = "0.10.2"
description = "Python Library for Tom's Obvious, Minimal Language"
category = "dev"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "urllib3"
version = "1.26.4"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
[package.extras]
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
brotli = ["brotlipy (>=0.6.0)"]
[[package]]
name = "werkzeug"
version = "1.0.1"
description = "The comprehensive WSGI web application library."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.extras]
dev = ["pytest", "pytest-timeout", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinx-issues"]
watchdog = ["watchdog"]
[[package]]
name = "wrapt"
version = "1.12.1"
description = "Module for decorators, wrappers and monkey patching."
category = "dev"
optional = false
python-versions = "*"
[metadata]
lock-version = "1.1"
python-versions = "^3.8"
content-hash = "943b58ae3ae110c4c990bab3fa72b73b0f8a172905246839c2fe481c73919067"
[metadata.files]
astroid = [
{file = "astroid-2.5-py3-none-any.whl", hash = "sha256:87ae7f2398b8a0ae5638ddecf9987f081b756e0e9fc071aeebdca525671fc4dc"},
{file = "astroid-2.5.tar.gz", hash = "sha256:b31c92f545517dcc452f284bc9c044050862fbe6d93d2b3de4a215a6b384bf0d"},
]
backports-datetime-fromisoformat = [
{file = "backports-datetime-fromisoformat-1.0.0.tar.gz", hash = "sha256:9577a2a9486cd7383a5f58b23bb8e81cf0821dbbc0eb7c87d3fa198c1df40f5c"},
]
beautifulsoup4 = [
{file = "beautifulsoup4-4.9.3-py2-none-any.whl", hash = "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35"},
{file = "beautifulsoup4-4.9.3-py3-none-any.whl", hash = "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666"},
{file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"},
]
cachetools = [
{file = "cachetools-4.2.1-py3-none-any.whl", hash = "sha256:1d9d5f567be80f7c07d765e21b814326d78c61eb0c3a637dffc0e5d1796cb2e2"},
{file = "cachetools-4.2.1.tar.gz", hash = "sha256:f469e29e7aa4cff64d8de4aad95ce76de8ea1125a16c68e0d93f65c3c3dc92e9"},
]
certifi = [
{file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"},
{file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"},
]
chardet = [
{file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
{file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
]
click = [
{file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
{file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
]
colorama = [
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
]
fireo = [
{file = "fireo-1.4.1.tar.gz", hash = "sha256:f210cc1e73e7f8c06cb4f2dda256c3df47fb0413326c049d5b558d8437a8d4c0"},
]
flask = [
{file = "Flask-1.1.2-py2.py3-none-any.whl", hash = "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"},
{file = "Flask-1.1.2.tar.gz", hash = "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060"},
]
fmframework = []
google-api-core = [
{file = "google-api-core-1.26.1.tar.gz", hash = "sha256:23b0df512c4cc8729793f8992edb350e3211f5fd0ec007afb1599864b421beef"},
{file = "google_api_core-1.26.1-py2.py3-none-any.whl", hash = "sha256:c383206f0f87545d3e658c4f8dc3b18a8457610fdbd791a15757c5b42d1e0e7f"},
]
google-auth = [
{file = "google-auth-1.28.0.tar.gz", hash = "sha256:9bd436d19ab047001a1340720d2b629eb96dd503258c524921ec2af3ee88a80e"},
{file = "google_auth-1.28.0-py2.py3-none-any.whl", hash = "sha256:dcaba3aa9d4e0e96fd945bf25a86b6f878fcb05770b67adbeb50a63ca4d28a5e"},
]
google-cloud-core = [
{file = "google-cloud-core-1.6.0.tar.gz", hash = "sha256:c6abb18527545379fc82efc4de75ce9a3772ccad2fc645adace593ba097cbb02"},
{file = "google_cloud_core-1.6.0-py2.py3-none-any.whl", hash = "sha256:40d9c2da2d03549b5ac3dcccf289d4f15e6d1210044c6381ce45c92913e62904"},
]
google-cloud-firestore = [
{file = "google-cloud-firestore-1.9.0.tar.gz", hash = "sha256:d8a56919a3a32c7271d1253542ec24cb13f384a726fed354fdeb2a2269f25d1c"},
{file = "google_cloud_firestore-1.9.0-py2.py3-none-any.whl", hash = "sha256:2b2985180591433f9343b5b4cafb9b0dbe077ced95b3ac5c57ef850a0339a4ce"},
]
google-cloud-logging = [
{file = "google-cloud-logging-1.15.1.tar.gz", hash = "sha256:cb0d4af9d684eb8a416f14c39d9fa6314be3adf41db2dd8ee8e30db9e8853d90"},
{file = "google_cloud_logging-1.15.1-py2.py3-none-any.whl", hash = "sha256:20c7557fd170891eab1a5e428338ad646203ddc519bc2fc57fd59bef14cd3602"},
]
google-cloud-pubsub = [
{file = "google-cloud-pubsub-1.7.0.tar.gz", hash = "sha256:c8d098ebd208d00c8f3bb55eefecd8553e7391d59700426a97d35125f0dcb248"},
{file = "google_cloud_pubsub-1.7.0-py2.py3-none-any.whl", hash = "sha256:b7f577621f991b513034c50f3314ef66838701b3b0dd1fca0d5e9a0e82f9f801"},
]
google-cloud-tasks = [
{file = "google-cloud-tasks-1.5.0.tar.gz", hash = "sha256:d751b97c1e84980a1646702d3fc1b45bab3284bc3388181f1dc9ba3d204b5a39"},
{file = "google_cloud_tasks-1.5.0-py2.py3-none-any.whl", hash = "sha256:36aa16f0c52aa9a292b1f919d2582725731e9760393c9ca98ce599c68cbf9996"},
]
googleapis-common-protos = [
{file = "googleapis-common-protos-1.53.0.tar.gz", hash = "sha256:a88ee8903aa0a81f6c3cec2d5cf62d3c8aa67c06439b0496b49048fb1854ebf4"},
{file = "googleapis_common_protos-1.53.0-py2.py3-none-any.whl", hash = "sha256:f6d561ab8fb16b30020b940e2dd01cd80082f4762fa9f3ee670f4419b4b8dbd0"},
]
grpc-google-iam-v1 = [
{file = "grpc-google-iam-v1-0.12.3.tar.gz", hash = "sha256:0bfb5b56f648f457021a91c0df0db4934b6e0c300bd0f2de2333383fe958aa72"},
]
grpcio = [
{file = "grpcio-1.36.1-cp27-cp27m-macosx_10_10_x86_64.whl", hash = "sha256:e3a83c5db16f95daac1d96cf3c9018d765579b5a29bb336758d793028e729921"},
{file = "grpcio-1.36.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:c18739fecb90760b183bfcb4da1cf2c6bf57e38f7baa2c131d5f67d9a4c8365d"},
{file = "grpcio-1.36.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:f6efa62ca1fe02cd34ec35f53446f04a15fe2c886a4e825f5679936a573d2cbf"},
{file = "grpcio-1.36.1-cp27-cp27m-win32.whl", hash = "sha256:9a18299827a70be0507f98a65393b1c7f6c004fe2ca995fe23ffac534dd187a7"},
{file = "grpcio-1.36.1-cp27-cp27m-win_amd64.whl", hash = "sha256:8a89190de1985a54ef311650cf9687ffb81de038973fd32e452636ddae36b29f"},
{file = "grpcio-1.36.1-cp27-cp27mu-linux_armv7l.whl", hash = "sha256:3e75643d21db7d68acd541d3fec66faaa8061d12b511e101b529ff12a276bb9b"},
{file = "grpcio-1.36.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:3c5204e05e18268dd6a1099ca6c106fd9d00bcae1e37d5a5186094c55044c941"},
{file = "grpcio-1.36.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:24d4c2c5e540e666c52225953d6813afc8ccf9bf46db6a72edd4e8d606656248"},
{file = "grpcio-1.36.1-cp35-cp35m-linux_armv7l.whl", hash = "sha256:4dc7295dc9673f7af22c1e38c2a2c24ecbd6773a4c5ed5a46ed38ad4dcf2bf6c"},
{file = "grpcio-1.36.1-cp35-cp35m-macosx_10_10_intel.whl", hash = "sha256:f241116d4bf1a8037ff87f16914b606390824e50902bdbfa2262e855fbf07fe5"},
{file = "grpcio-1.36.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:1056b558acfd575d774644826df449e1402a03e456a3192fafb6b06d1069bf80"},
{file = "grpcio-1.36.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:52ec563da45d06319224ebbda53501d25594de64ee1b2786e119ba4a2f1ce40c"},
{file = "grpcio-1.36.1-cp35-cp35m-manylinux2014_i686.whl", hash = "sha256:7cbeac9bbe6a4a7fce4a89c892c249135dd9f5f5219ede157174c34a456188f0"},
{file = "grpcio-1.36.1-cp35-cp35m-manylinux2014_x86_64.whl", hash = "sha256:2abaa9f0d83bd0b26f6d0d1fc4b97d73bde3ceac36ab857f70d3cabcf31c5c79"},
{file = "grpcio-1.36.1-cp35-cp35m-win32.whl", hash = "sha256:02030e1afd3247f2b159df9dff959ec79dd4047b1c4dd4eec9e3d1642efbd504"},
{file = "grpcio-1.36.1-cp35-cp35m-win_amd64.whl", hash = "sha256:eafafc7e040e36aa926edc731ab52c23465981888779ae64bfc8ad85888ed4f3"},
{file = "grpcio-1.36.1-cp36-cp36m-linux_armv7l.whl", hash = "sha256:1030e74ddd0fa6e3bad7944f0c68cf1251b15bcd70641f0ad3858fdf2b8602a0"},
{file = "grpcio-1.36.1-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:b003e24339030ed356f59505d1065b89e1f443ef41ce71ca9069be944c0d2e6b"},
{file = "grpcio-1.36.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:76daa3c4d58fcf40f7969bdb4270335e96ee0382a050cadcd97d7332cd0251a3"},
{file = "grpcio-1.36.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f591597bb25eae0094ead5a965555e911453e5f35fdbdaa83be11ef107865697"},
{file = "grpcio-1.36.1-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:cbd82c479338fc1c0e5c3db09752b61fe47d40c6e38e4be8657153712fa76674"},
{file = "grpcio-1.36.1-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:7e32bc01dfaa7a51c547379644ea619a2161d6969affdac3bbd173478d26673d"},
{file = "grpcio-1.36.1-cp36-cp36m-win32.whl", hash = "sha256:5378189fb897567f4929f75ab67a3e0da4f8967806246cb9cfa1fa06bfbdb0d5"},
{file = "grpcio-1.36.1-cp36-cp36m-win_amd64.whl", hash = "sha256:3a6295aa692806218e97bb687a71cd768450ed99e2acddc488f18d738edef463"},
{file = "grpcio-1.36.1-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:6f6f8a8b57e40347d0bf32c2135037dae31d63d3b19007b4c426a11b76deaf65"},
{file = "grpcio-1.36.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:4c05ed54b2a00df01e633bebec819b512bf0c60f8f5b3b36dd344dc673b02fea"},
{file = "grpcio-1.36.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:e1b9e906aa6f7577016e86ed7f3a69cae7dab4e41356584dc7980f76ea65035f"},
{file = "grpcio-1.36.1-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:a602d6b30760bbbb2fe776caaa914a0d404636cafc3f2322718bf8002d7b1e55"},
{file = "grpcio-1.36.1-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:dee9971aef20fc09ed897420446c4d0926cd1d7630f343333288523ca5b44bb2"},
{file = "grpcio-1.36.1-cp37-cp37m-win32.whl", hash = "sha256:ed16bfeda02268e75e038c58599d52afc7097d749916c079b26bc27a66900f7d"},
{file = "grpcio-1.36.1-cp37-cp37m-win_amd64.whl", hash = "sha256:85a6035ae75ce964f78f19cf913938596ccf068b149fcd79f4371268bcb9aa7c"},
{file = "grpcio-1.36.1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:6b30682180053eebc87802c2f249d2f59b430e1a18e8808575dde0d22a968b2c"},
{file = "grpcio-1.36.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:5e4920a8fb5d17b2c5ba980db0ac1c925bbee3e5d70e96da3ec4fb1c8600d68f"},
{file = "grpcio-1.36.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:f7740d9d9451f3663df11b241ac05cafc0efaa052d2fdca6640c4d3748eaf6e2"},
{file = "grpcio-1.36.1-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:20b7c4c5513e1135a2261e56830c0e710f205fee92019b92fe132d7f16a5cfd8"},
{file = "grpcio-1.36.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:216fbd2a488e74c3b96e240e4054c85c4c99102a439bc9f556936991643f43bc"},
{file = "grpcio-1.36.1-cp38-cp38-win32.whl", hash = "sha256:7863c2a140e829b1f4c6d67bf0bf15e5321ac4766d0a295e2682970d9dd4b091"},
{file = "grpcio-1.36.1-cp38-cp38-win_amd64.whl", hash = "sha256:f214076eb13da9e65c1aa9877b51fca03f51a82bd8691358e1a1edd9ff341330"},
{file = "grpcio-1.36.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:ec753c022b39656f88409fbf9f2d3b28497e3f17aa678f884d78776b41ebe6bd"},
{file = "grpcio-1.36.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:0648a6d5d7ddcd9c8462d7d961660ee024dad6b88152ee3a521819e611830edf"},
{file = "grpcio-1.36.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:45ea10dd133a43b10c0b4326834107ebccfee25dab59b312b78e018c2d72a1f0"},
{file = "grpcio-1.36.1-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:bab743cdac1d6d8326c65d1d091d0740b39966dfab06519f74a03b3d128b8454"},
{file = "grpcio-1.36.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:09af8ceb91860086216edc6e5ea15f9beb2cf81687faa43b7c03216f5b73e244"},
{file = "grpcio-1.36.1-cp39-cp39-win32.whl", hash = "sha256:f3f70505207ee1cee65f60a799fd8e06e07861409aa0d55d834825a79b40c297"},
{file = "grpcio-1.36.1-cp39-cp39-win_amd64.whl", hash = "sha256:f22c11772eff25ba1ca536e760b8c34ba56f2a9d66b6842cb11770a8f61f879d"},
{file = "grpcio-1.36.1.tar.gz", hash = "sha256:a66ea59b20f3669df0f0c6a3bd57b985e5b2d1dcf3e4c29819bb8dc232d0fd38"},
]
idna = [
{file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
]
isort = [
{file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"},
{file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"},
]
itsdangerous = [
{file = "itsdangerous-1.1.0-py2.py3-none-any.whl", hash = "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"},
{file = "itsdangerous-1.1.0.tar.gz", hash = "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19"},
]
jinja2 = [
{file = "Jinja2-2.11.3-py2.py3-none-any.whl", hash = "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419"},
{file = "Jinja2-2.11.3.tar.gz", hash = "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"},
]
lazy-object-proxy = [
{file = "lazy-object-proxy-1.5.2.tar.gz", hash = "sha256:5944a9b95e97de1980c65f03b79b356f30a43de48682b8bdd90aa5089f0ec1f4"},
{file = "lazy_object_proxy-1.5.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:e960e8be509e8d6d618300a6c189555c24efde63e85acaf0b14b2cd1ac743315"},
{file = "lazy_object_proxy-1.5.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:522b7c94b524389f4a4094c4bf04c2b02228454ddd17c1a9b2801fac1d754871"},
{file = "lazy_object_proxy-1.5.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:3782931963dc89e0e9a0ae4348b44762e868ea280e4f8c233b537852a8996ab9"},
{file = "lazy_object_proxy-1.5.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:429c4d1862f3fc37cd56304d880f2eae5bd0da83bdef889f3bd66458aac49128"},
{file = "lazy_object_proxy-1.5.2-cp35-cp35m-win32.whl", hash = "sha256:cd1bdace1a8762534e9a36c073cd54e97d517a17d69a17985961265be6d22847"},
{file = "lazy_object_proxy-1.5.2-cp35-cp35m-win_amd64.whl", hash = "sha256:ddbdcd10eb999d7ab292677f588b658372aadb9a52790f82484a37127a390108"},
{file = "lazy_object_proxy-1.5.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ecb5dd5990cec6e7f5c9c1124a37cb2c710c6d69b0c1a5c4aa4b35eba0ada068"},
{file = "lazy_object_proxy-1.5.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:b6577f15d5516d7d209c1a8cde23062c0f10625f19e8dc9fb59268859778d7d7"},
{file = "lazy_object_proxy-1.5.2-cp36-cp36m-win32.whl", hash = "sha256:c8fe2d6ff0ff583784039d0255ea7da076efd08507f2be6f68583b0da32e3afb"},
{file = "lazy_object_proxy-1.5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:fa5b2dee0e231fa4ad117be114251bdfe6afe39213bd629d43deb117b6a6c40a"},
{file = "lazy_object_proxy-1.5.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1d33d6f789697f401b75ce08e73b1de567b947740f768376631079290118ad39"},
{file = "lazy_object_proxy-1.5.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:57fb5c5504ddd45ed420b5b6461a78f58cbb0c1b0cbd9cd5a43ad30a4a3ee4d0"},
{file = "lazy_object_proxy-1.5.2-cp37-cp37m-win32.whl", hash = "sha256:e7273c64bccfd9310e9601b8f4511d84730239516bada26a0c9846c9697617ef"},
{file = "lazy_object_proxy-1.5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6f4e5e68b7af950ed7fdb594b3f19a0014a3ace0fedb86acb896e140ffb24302"},
{file = "lazy_object_proxy-1.5.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:cadfa2c2cf54d35d13dc8d231253b7985b97d629ab9ca6e7d672c35539d38163"},
{file = "lazy_object_proxy-1.5.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:e7428977763150b4cf83255625a80a23dfdc94d43be7791ce90799d446b4e26f"},
{file = "lazy_object_proxy-1.5.2-cp38-cp38-win32.whl", hash = "sha256:2f2de8f8ac0be3e40d17730e0600619d35c78c13a099ea91ef7fb4ad944ce694"},
{file = "lazy_object_proxy-1.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:38c3865bd220bd983fcaa9aa11462619e84a71233bafd9c880f7b1cb753ca7fa"},
{file = "lazy_object_proxy-1.5.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:8a44e9901c0555f95ac401377032f6e6af66d8fc1fbfad77a7a8b1a826e0b93c"},
{file = "lazy_object_proxy-1.5.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:fa7fb7973c622b9e725bee1db569d2c2ee64d2f9a089201c5e8185d482c7352d"},
{file = "lazy_object_proxy-1.5.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:71a1ef23f22fa8437974b2d60fedb947c99a957ad625f83f43fd3de70f77f458"},
{file = "lazy_object_proxy-1.5.2-cp39-cp39-win32.whl", hash = "sha256:ef3f5e288aa57b73b034ce9c1f1ac753d968f9069cd0742d1d69c698a0167166"},
{file = "lazy_object_proxy-1.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:37d9c34b96cca6787fe014aeb651217944a967a5b165e2cacb6b858d2997ab84"},
]
markupsafe = [
{file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"},
{file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"},
{file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"},
{file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"},
{file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"},
{file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"},
{file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"},
{file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"},
{file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"},
{file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"},
{file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"},
{file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"},
{file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"},
{file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"},
{file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"},
{file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"},
{file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"},
{file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"},
{file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"},
{file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"},
{file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"},
{file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"},
{file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"},
{file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"},
{file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"},
{file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"},
{file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"},
{file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"},
{file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"},
{file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"},
{file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"},
{file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"},
{file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"},
]
mccabe = [
{file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
{file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
]
packaging = [
{file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"},
{file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"},
]
protobuf = [
{file = "protobuf-3.15.6-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1771ef20e88759c4d81db213e89b7a1fc53937968e12af6603c658ee4bcbfa38"},
{file = "protobuf-3.15.6-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:1a66261a402d05c8ad8c1fde8631837307bf8d7e7740a4f3941fc3277c2e1528"},
{file = "protobuf-3.15.6-cp35-cp35m-macosx_10_9_intel.whl", hash = "sha256:eac23a3e56175b710f3da9a9e8e2aa571891fbec60e0c5a06db1c7b1613b5cfd"},
{file = "protobuf-3.15.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9ec220d90eda8bb7a7a1434a8aed4fe26d7e648c1a051c2885f3f5725b6aa71a"},
{file = "protobuf-3.15.6-cp35-cp35m-win32.whl", hash = "sha256:88d8f21d1ac205eedb6dea943f8204ed08201b081dba2a966ab5612788b9bb1e"},
{file = "protobuf-3.15.6-cp35-cp35m-win_amd64.whl", hash = "sha256:eaada29bbf087dea7d8bce4d1d604fc768749e8809e9c295922accd7c8fce4d5"},
{file = "protobuf-3.15.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:256c0b2e338c1f3228d3280707606fe5531fde85ab9d704cde6fdeb55112531f"},
{file = "protobuf-3.15.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:b9069e45b6e78412fba4a314ea38b4a478686060acf470d2b131b3a2c50484ec"},
{file = "protobuf-3.15.6-cp36-cp36m-win32.whl", hash = "sha256:24f4697f57b8520c897a401b7f9a5ae45c369e22c572e305dfaf8053ecb49687"},
{file = "protobuf-3.15.6-cp36-cp36m-win_amd64.whl", hash = "sha256:d9ed0955b794f1e5f367e27f8a8ff25501eabe34573f003f06639c366ca75f73"},
{file = "protobuf-3.15.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:822ac7f87fc2fb9b24edd2db390538b60ef50256e421ca30d65250fad5a3d477"},
{file = "protobuf-3.15.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:74ac159989e2b02d761188a2b6f4601ff5e494d9b9d863f5ad6e98e5e0c54328"},
{file = "protobuf-3.15.6-cp37-cp37m-win32.whl", hash = "sha256:30fe4249a364576f9594180589c3f9c4771952014b5f77f0372923fc7bafbbe2"},
{file = "protobuf-3.15.6-cp37-cp37m-win_amd64.whl", hash = "sha256:45a91fc6f9aa86d3effdeda6751882b02de628519ba06d7160daffde0c889ff8"},
{file = "protobuf-3.15.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:83c7c7534f050cb25383bb817159416601d1cc46c40bc5e851ec8bbddfc34a2f"},
{file = "protobuf-3.15.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9ec20a6ded7d0888e767ad029dbb126e604e18db744ac0a428cf746e040ccecd"},
{file = "protobuf-3.15.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0f2da2fcc4102b6c3b57f03c9d8d5e37c63f8bc74deaa6cb54e0cc4524a77247"},
{file = "protobuf-3.15.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:70054ae1ce5dea7dec7357db931fcf487f40ea45b02cb719ee6af07eb1e906fb"},
{file = "protobuf-3.15.6-py2.py3-none-any.whl", hash = "sha256:1655fc0ba7402560d749de13edbfca1ac45d1753d8f4e5292989f18f5a00c215"},
{file = "protobuf-3.15.6.tar.gz", hash = "sha256:2b974519a2ae83aa1e31cff9018c70bbe0e303a46a598f982943c49ae1d4fcd3"},
]
pyasn1 = [
{file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"},
{file = "pyasn1-0.4.8-py2.5.egg", hash = "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf"},
{file = "pyasn1-0.4.8-py2.6.egg", hash = "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00"},
{file = "pyasn1-0.4.8-py2.7.egg", hash = "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8"},
{file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"},
{file = "pyasn1-0.4.8-py3.1.egg", hash = "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86"},
{file = "pyasn1-0.4.8-py3.2.egg", hash = "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7"},
{file = "pyasn1-0.4.8-py3.3.egg", hash = "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576"},
{file = "pyasn1-0.4.8-py3.4.egg", hash = "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12"},
{file = "pyasn1-0.4.8-py3.5.egg", hash = "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2"},
{file = "pyasn1-0.4.8-py3.6.egg", hash = "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359"},
{file = "pyasn1-0.4.8-py3.7.egg", hash = "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776"},
{file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"},
]
pyasn1-modules = [
{file = "pyasn1-modules-0.2.8.tar.gz", hash = "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e"},
{file = "pyasn1_modules-0.2.8-py2.4.egg", hash = "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199"},
{file = "pyasn1_modules-0.2.8-py2.5.egg", hash = "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405"},
{file = "pyasn1_modules-0.2.8-py2.6.egg", hash = "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb"},
{file = "pyasn1_modules-0.2.8-py2.7.egg", hash = "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8"},
{file = "pyasn1_modules-0.2.8-py2.py3-none-any.whl", hash = "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74"},
{file = "pyasn1_modules-0.2.8-py3.1.egg", hash = "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d"},
{file = "pyasn1_modules-0.2.8-py3.2.egg", hash = "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45"},
{file = "pyasn1_modules-0.2.8-py3.3.egg", hash = "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4"},
{file = "pyasn1_modules-0.2.8-py3.4.egg", hash = "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811"},
{file = "pyasn1_modules-0.2.8-py3.5.egg", hash = "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed"},
{file = "pyasn1_modules-0.2.8-py3.6.egg", hash = "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0"},
{file = "pyasn1_modules-0.2.8-py3.7.egg", hash = "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd"},
]
pylint = [
{file = "pylint-2.5.3-py3-none-any.whl", hash = "sha256:d0ece7d223fe422088b0e8f13fa0a1e8eb745ebffcb8ed53d3e95394b6101a1c"},
{file = "pylint-2.5.3.tar.gz", hash = "sha256:7dd78437f2d8d019717dbf287772d0b2dbdfd13fc016aa7faa08d67bccc46adc"},
]
pyparsing = [
{file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
{file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
]
pytz = [
{file = "pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"},
{file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"},
]
requests = [
{file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"},
{file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"},
]
rsa = [
{file = "rsa-4.7.2-py3-none-any.whl", hash = "sha256:78f9a9bf4e7be0c5ded4583326e7461e3a3c5aae24073648b4bdfa797d78c9d2"},
{file = "rsa-4.7.2.tar.gz", hash = "sha256:9d689e6ca1b3038bc82bf8d23e944b6b6037bc02301a574935b2dd946e0353b9"},
]
six = [
{file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
{file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
]
soupsieve = [
{file = "soupsieve-2.2.1-py3-none-any.whl", hash = "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"},
{file = "soupsieve-2.2.1.tar.gz", hash = "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc"},
]
spotfm = []
spotframework = []
tabulate = [
{file = "tabulate-0.8.9-py3-none-any.whl", hash = "sha256:d7c013fe7abbc5e491394e10fa845f8f32fe54f8dc60c6622c6cf482d25d47e4"},
{file = "tabulate-0.8.9.tar.gz", hash = "sha256:eb1d13f25760052e8931f2ef80aaf6045a6cceb47514db8beab24cded16f13a7"},
]
toml = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
]
urllib3 = [
{file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"},
{file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"},
]
werkzeug = [
{file = "Werkzeug-1.0.1-py2.py3-none-any.whl", hash = "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43"},
{file = "Werkzeug-1.0.1.tar.gz", hash = "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c"},
]
wrapt = [
{file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"},
]

266
py-modindex.html Normal file
View File

@ -0,0 +1,266 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Python Module Index &#8212; Music Tools documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
<script src="_static/jquery.js"></script>
<script src="_static/underscore.js"></script>
<script src="_static/doctools.js"></script>
<link rel="index" title="Index" href="genindex.html" />
<link rel="search" title="Search" href="search.html" />
<link rel="stylesheet" href="_static/custom.css" type="text/css" />
<meta name="viewport" content="width=device-width, initial-scale=0.9, maximum-scale=0.9" />
</head><body>
<div class="document">
<div class="documentwrapper">
<div class="bodywrapper">
<div class="body" role="main">
<h1>Python Module Index</h1>
<div class="modindex-jumpbox">
<a href="#cap-m"><strong>m</strong></a>
</div>
<table class="indextable modindextable">
<tr class="pcap"><td></td><td>&#160;</td><td></td></tr>
<tr class="cap" id="cap-m"><td></td><td>
<strong>m</strong></td><td></td></tr>
<tr>
<td><img src="_static/minus.png" class="toggler"
id="toggle-1" style="display: none" alt="-" /></td>
<td>
<a href="src/music.html#module-music"><code class="xref">music</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.api.html#module-music.api"><code class="xref">music.api</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.api.html#module-music.api.admin"><code class="xref">music.api.admin</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.api.html#module-music.api.api"><code class="xref">music.api.api</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.api.html#module-music.api.decorators"><code class="xref">music.api.decorators</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.api.html#module-music.api.fm"><code class="xref">music.api.fm</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.api.html#module-music.api.player"><code class="xref">music.api.player</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.api.html#module-music.api.spotfm"><code class="xref">music.api.spotfm</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.api.html#module-music.api.spotify"><code class="xref">music.api.spotify</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.api.html#module-music.api.tag"><code class="xref">music.api.tag</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.auth.html#module-music.auth"><code class="xref">music.auth</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.auth.html#module-music.auth.auth"><code class="xref">music.auth.auth</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.cloud.html#module-music.cloud"><code class="xref">music.cloud</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.cloud.html#module-music.cloud.function"><code class="xref">music.cloud.function</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.cloud.html#module-music.cloud.tasks"><code class="xref">music.cloud.tasks</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.db.html#module-music.db"><code class="xref">music.db</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.db.html#module-music.db.database"><code class="xref">music.db.database</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.db.html#module-music.db.part_generator"><code class="xref">music.db.part_generator</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.model.html#module-music.model"><code class="xref">music.model</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.model.html#module-music.model.config"><code class="xref">music.model.config</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.model.html#module-music.model.playlist"><code class="xref">music.model.playlist</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.model.html#module-music.model.tag"><code class="xref">music.model.tag</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.model.html#module-music.model.user"><code class="xref">music.model.user</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.html#module-music.music"><code class="xref">music.music</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.tasks.html#module-music.tasks"><code class="xref">music.tasks</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.tasks.html#module-music.tasks.create_playlist"><code class="xref">music.tasks.create_playlist</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.tasks.html#module-music.tasks.refresh_lastfm_stats"><code class="xref">music.tasks.refresh_lastfm_stats</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.tasks.html#module-music.tasks.run_user_playlist"><code class="xref">music.tasks.run_user_playlist</code></a></td><td>
<em></em></td></tr>
<tr class="cg-1">
<td></td>
<td>&#160;&#160;&#160;
<a href="src/music.tasks.html#module-music.tasks.update_tag"><code class="xref">music.tasks.update_tag</code></a></td><td>
<em></em></td></tr>
</table>
</div>
</div>
</div>
<div class="sphinxsidebar" role="navigation" aria-label="main navigation">
<div class="sphinxsidebarwrapper">
<h1 class="logo"><a href="index.html">Music Tools</a></h1>
<h3>Navigation</h3>
<p class="caption"><span class="caption-text">Contents:</span></p>
<ul>
<li class="toctree-l1"><a class="reference internal" href="src/modules.html">Modules</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.html">music package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.api.html">music.api package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.auth.html">music.auth package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.cloud.html">music.cloud package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.db.html">music.db package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.model.html">music.model package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.tasks.html">music.tasks package</a></li>
</ul>
<div class="relations">
<h3>Related Topics</h3>
<ul>
<li><a href="index.html">Documentation overview</a><ul>
</ul></li>
</ul>
</div>
<div id="searchbox" style="display: none" role="search">
<h3 id="searchlabel">Quick search</h3>
<div class="searchformwrapper">
<form class="search" action="search.html" method="get">
<input type="text" name="q" aria-labelledby="searchlabel" />
<input type="submit" value="Go" />
</form>
</div>
</div>
<script>$('#searchbox').show(0);</script>
</div>
</div>
<div class="clearer"></div>
</div>
<div class="footer">
&copy;2021, Sarsoo.
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.5.3</a>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
</div>
</body>
</html>

View File

@ -1,33 +0,0 @@
[tool.poetry]
name = "music"
version = "1.0.0"
description = "Spotify smart playlist generator. Last.fm integration for listening statistics"
authors = ["andy <andy@sarsoo.xyz>"]
readme = "README.md"
homepage = "https://music.sarsoo.xyz/"
repository = "https://github.com/Sarsoo/Music-Tools"
[tool.poetry.scripts]
test = 'admin:test'
start = 'admin:run'
[tool.poetry.dependencies]
python = "^3.8"
fireo = "^1.3.3"
Flask = "^1.1.2"
google-cloud-firestore = "^1.7.0"
google-cloud-logging = "^1.15.0"
google-cloud-pubsub = "^1.6.0"
google-cloud-tasks = "^1.5.0"
requests = "^2.24.0"
spotframework = { git = "https://github.com/Sarsoo/spotframework.git" }
fmframework = { git = "https://github.com/Sarsoo/pyfmframework.git" }
spotfm = { git = "https://github.com/Sarsoo/spotfm.git" }
[tool.poetry.dev-dependencies]
pylint = "2.5.3"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

121
search.html Normal file
View File

@ -0,0 +1,121 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Search &#8212; Music Tools documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
<script src="_static/jquery.js"></script>
<script src="_static/underscore.js"></script>
<script src="_static/doctools.js"></script>
<script src="_static/searchtools.js"></script>
<script src="_static/language_data.js"></script>
<link rel="index" title="Index" href="genindex.html" />
<link rel="search" title="Search" href="#" />
<script src="searchindex.js" defer></script>
<link rel="stylesheet" href="_static/custom.css" type="text/css" />
<meta name="viewport" content="width=device-width, initial-scale=0.9, maximum-scale=0.9" />
</head><body>
<div class="document">
<div class="documentwrapper">
<div class="bodywrapper">
<div class="body" role="main">
<h1 id="search-documentation">Search</h1>
<div id="fallback" class="admonition warning">
<script>$('#fallback').hide();</script>
<p>
Please activate JavaScript to enable the search
functionality.
</p>
</div>
<p>
Searching for multiple words only shows matches that contain
all words.
</p>
<form action="" method="get">
<input type="text" name="q" aria-labelledby="search-documentation" value="" />
<input type="submit" value="search" />
<span id="search-progress" style="padding-left: 10px"></span>
</form>
<div id="search-results">
</div>
</div>
</div>
</div>
<div class="sphinxsidebar" role="navigation" aria-label="main navigation">
<div class="sphinxsidebarwrapper">
<h1 class="logo"><a href="index.html">Music Tools</a></h1>
<h3>Navigation</h3>
<p class="caption"><span class="caption-text">Contents:</span></p>
<ul>
<li class="toctree-l1"><a class="reference internal" href="src/modules.html">Modules</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.html">music package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.api.html">music.api package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.auth.html">music.auth package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.cloud.html">music.cloud package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.db.html">music.db package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.model.html">music.model package</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/music.tasks.html">music.tasks package</a></li>
</ul>
<div class="relations">
<h3>Related Topics</h3>
<ul>
<li><a href="index.html">Documentation overview</a><ul>
</ul></li>
</ul>
</div>
</div>
</div>
<div class="clearer"></div>
</div>
<div class="footer">
&copy;2021, Sarsoo.
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.5.3</a>
&amp; <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
</div>
</body>
</html>

1
searchindex.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,52 +0,0 @@
import React, { Component } from "react";
import { Route, Link, Switch } from "react-router-dom";
import { Paper, Tabs, Tab} from '@material-ui/core';
import Lock from "./Lock.js";
import Functions from "./Functions.js";
import Tasks from "./Tasks.js";
class Admin extends Component {
constructor(props){
super(props);
this.state = {
tab: 0
}
this.handleChange = this.handleChange.bind(this);
}
handleChange(e, newValue){
this.setState({
tab: newValue
});
}
render(){
return (
<div>
<Paper>
<Tabs
value={this.state.tab}
onChange={this.handleChange}
indicatorColor="primary"
centered
width="50%"
>
<Tab label="Lock Accounts" component={Link} to={`${this.props.match.url}/lock`} />
<Tab label="Functions" component={Link} to={`${this.props.match.url}/functions`} />
<Tab label="Tasks" component={Link} to={`${this.props.match.url}/tasks`} />
</Tabs>
</Paper>
<Switch>
<Route path={`${this.props.match.url}/lock`} component={Lock} />
<Route path={`${this.props.match.url}/functions`} component={Functions} />
<Route path={`${this.props.match.url}/tasks`} component={Tasks} />
</Switch>
</div>
);
}
}
export default Admin

View File

@ -1,55 +0,0 @@
import React, { Component } from "react";
const axios = require('axios');
import showMessage from "../Toast.js"
import { Card, Button, ButtonGroup, CardContent, CardActions, Typography } from "@material-ui/core";
class Functions extends Component {
constructor(props){
super(props);
this.runAllUsers = this.runAllUsers.bind(this);
this.runStats = this.runStats.bind(this);
}
runAllUsers(event){
axios.get('/api/playlist/run/users')
.then((response) => {
showMessage('Users Run');
})
.catch((error) => {
showMessage(`Error Running All Users (${error.response.status})`);
});
}
runStats(event){
axios.get('/api/spotfm/playlist/refresh/users')
.then((response) => {
showMessage('Stats Run');
})
.catch((error) => {
showMessage(`Error Running All Users (${error.response.status})`);
});
}
render () {
return (
<div style={{maxWidth: '1000px', margin: 'auto', marginTop: '20px'}}>
<Card align="center">
<CardContent>
<Typography variant="h4" color="textPrimary">Admin Functions</Typography>
</CardContent>
<CardActions>
<ButtonGroup variant="contained" color="primary" className="full-width">
<Button className="full-width button" onClick={this.runAllUsers}>Run All Users</Button>
<Button className="full-width button" onClick={this.runStats}>Run Stats</Button>
</ButtonGroup>
</CardActions>
</Card>
</div>
);
}
}
export default Functions;

Some files were not shown because too many files have changed in this diff Show More