Compare commits

..

37 Commits

Author SHA1 Message Date
Sarsoo
78292a8ef8 deploy: 54b9adbc76 2023-01-06 21:55:21 +00:00
Sarsoo
f4e3b259d1 deploy: 64b8faaf1f 2022-12-20 21:37:28 +00:00
Sarsoo
7930b3082c deploy: 5d95dccb7a 2022-12-10 10:29:08 +00:00
Sarsoo
4d5c94175b deploy: 7a907e918c 2022-12-09 08:51:42 +00:00
Sarsoo
7990c39883 deploy: 1ec929ce98 2022-11-29 22:52:55 +00:00
Sarsoo
952da95867 deploy: de23eb0065 2022-11-28 22:33:50 +00:00
Sarsoo
2e7ecff013 deploy: 2ba341ae7f 2022-11-13 22:00:43 +00:00
Sarsoo
c1dd70bfb1 deploy: 384eceae05 2022-11-11 07:14:19 +00:00
Sarsoo
f5655b3a99 deploy: 237d07eab2 2022-11-10 22:17:04 +00:00
Sarsoo
4241a751fd deploy: 569c4c76dc 2022-11-10 21:32:41 +00:00
Sarsoo
e3ec0431e0 deploy: 569c4c76dc 2022-11-09 22:26:00 +00:00
Sarsoo
f2aa0594eb deploy: ccc90761f7 2022-08-26 18:34:19 +00:00
andy
bd33691acc
Create CNAME 2022-08-26 18:31:00 +00:00
Sarsoo
e1b43a0972 deploy: b5868fd69c 2022-07-27 17:04:12 +00:00
Sarsoo
977ba9dff5 deploy: e767241c21 2022-04-10 21:26:28 +00:00
Sarsoo
7cc32d0221 deploy: de1f09456f 2022-03-28 18:54:32 +00:00
Sarsoo
30695d516f deploy: 9c58e99f51 2022-02-20 12:23:01 +00:00
Sarsoo
40d3c48283 deploy: 95e1d7279d 2022-02-13 22:28:31 +00:00
Sarsoo
355445299f deploy: cbe563551f 2022-02-13 21:24:53 +00:00
Sarsoo
916b14421c deploy: a92b10b9d6 2021-09-18 14:54:04 +00:00
Sarsoo
ca5bf69677 deploy: 90d1ce04d9 2021-07-10 15:22:55 +00:00
Sarsoo
791910a47f deploy: 79c841d9d8 2021-07-05 22:38:09 +00:00
Sarsoo
05d4b8999d deploy: e51976dc1f 2021-07-04 21:40:32 +00:00
Sarsoo
f5d263fe0e deploy: b5730375d9 2021-06-19 12:01:25 +00:00
Sarsoo
07183d8b00 deploy: 489b436c58 2021-06-16 19:46:54 +00:00
Sarsoo
ae03b4a981 deploy: e64d0e2cd8 2021-06-12 13:00:38 +00:00
Sarsoo
a7cd85b11b deploy: 4fc4676041 2021-06-11 15:43:57 +00:00
Sarsoo
15eaa62c69 deploy: a33d91dba4 2021-06-05 08:59:11 +00:00
andy
4099bbdb5d Delete CNAME 2021-05-25 21:25:18 +01:00
andy
fcccac84c7 Create CNAME 2021-05-25 20:54:32 +01:00
andy
1d2a2c0bcd Delete CNAME 2021-05-25 20:52:47 +01:00
andy
ffe19eb1bc Create CNAME 2021-05-25 20:52:06 +01:00
Sarsoo
b3fa78e739 deploy: ebc9a0cfa1 2021-05-12 21:21:48 +00:00
Sarsoo
c3ccc21bf2 deploy: c72762f616 2021-04-04 18:52:28 +00:00
Sarsoo
76544ee65c deploy: f259096b47 2021-03-27 00:10:03 +00:00
Sarsoo
3bf7defaa1 deploy: 7ade5ccaab 2021-03-24 10:15:52 +00:00
Sarsoo
a3b82dd616 deploy: 0a18597165 2021-03-23 22:46:23 +00:00
169 changed files with 20500 additions and 17485 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: d5d0d2dc7e6531bfca30187e22cd5741
tags: 645f666f9bcd5a90fca523b33c5a78b7

View File

@ -1,13 +0,0 @@
.github
.idea
.jenkins
.git
.venv
.vscode
**/__pycache__
service.json
build
docs
node_modules
public
tests

Binary file not shown.

BIN
.doctrees/index.doctree Normal file

Binary file not shown.

BIN
.doctrees/src/admin.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,90 +0,0 @@
name: test and deploy
on: [push]
env:
python-version: '3.11'
poetry-version: 1.8.3
node-version: 22.5.1
jobs:
build:
runs-on: ubuntu-latest
name: Build & Unit Test
steps:
- uses: actions/checkout@v4 # get source
with:
github-server-url: https://gitea.sheep-ghoul.ts.net
# PYTHON
- name: Install Python ${{ env.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ env.python-version }}
# PYTHON dependency management
- name: Install Poetry ${{ env.poetry-version }}
uses: abatilo/actions-poetry@v2.1.6
with:
poetry-version: ${{ env.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/auth@v0.7.3
with:
credentials_json: '${{ secrets.GCP_SA_KEY }}'
export_environment_variables: true
create_credentials_file: 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 ${{ env.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ env.node-version }}
# 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
# JS tests
# - name: Run JavaScript Tests
# run: npm test
package:
runs-on: ubuntu-latest
name: Package & Push Container
needs: [build] # for ignoring bad builds
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v4
with:
github-server-url: https://gitea.sheep-ghoul.ts.net
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
registry: gitea.sheep-ghoul.ts.net
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build & Push Container
uses: docker/build-push-action@v2
with:
push: true
tags: gitea.sheep-ghoul.ts.net/sarsoo/mixonomer:latest
context: .

View File

@ -1,333 +0,0 @@
name: test and deploy
on: [push]
env:
python-version: '3.11'
poetry-version: 1.8.3
node-version: 22.5.1
jobs:
build:
runs-on: ubuntu-latest
name: Build & Unit Test
steps:
- uses: actions/checkout@v4 # get source
# PYTHON
- name: Install Python ${{ env.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ env.python-version }}
# PYTHON dependency management
- name: Install Poetry ${{ env.poetry-version }}
uses: abatilo/actions-poetry@v2.1.6
with:
poetry-version: ${{ env.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/auth@v0.7.3
with:
credentials_json: '${{ secrets.GCP_SA_KEY }}'
export_environment_variables: true
create_credentials_file: 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 ${{ env.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ env.node-version }}
# 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
# JS tests
# - name: Run JavaScript Tests
# run: npm test
deploytest:
runs-on: ubuntu-latest
name: Deploy Test
environment:
name: test
url: https://test.mixonomer.sarsoo.xyz
needs: build # for ignoring bad builds
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4 # get source
# PYTHON (pinned to 3.9 for gcloud attribute mapping error)
- name: Install Python ${{ env.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ env.python-version }}
# PYTHON for dependency export only, not installing
- name: Install Poetry ${{ env.poetry-version }}
uses: abatilo/actions-poetry@v2.1.6
with:
poetry-version: ${{ env.poetry-version }}
# PYTHON Export Poetry dependencies as requirements.txt
- name: Export Poetry Dependencies
run: python admin.py pydepend
# JS setup
- name: Install Node ${{ env.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ env.node-version }}
# 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/auth@v0.7.3
with:
credentials_json: '${{ secrets.GCP_SA_KEY }}'
export_environment_variables: true
create_credentials_file: true
# DEPLOY set project
- name: Set GCP Project
run: python admin.py set_project ${{ vars.GCP_PROJECT }}
# DEPLOY app engine service, -nb for skipping compile
- name: Deploy App Engine Service
run: python admin.py app -nb
- name: Set Test Dispatch Rules
run: |
cp dispatch.test.yaml dispatch.yaml
shell: bash
# DEPLOY domain routes
- name: Deploy dispatch.yaml
run: gcloud app deploy dispatch.yaml --quiet
### MAIN FUNCTIONS
# DEPLOY update_tag function
- name: Deploy update_tag Function
run: python admin.py tag ${{ vars.GCP_PROJECT }}
# DEPLOY run_user_playlist function
- name: Deploy run_user_playlist Function
run: python admin.py playlist ${{ vars.GCP_PROJECT }}
### CRON FUNCTIONS
# DEPLOY run_all_playlists function
- name: Deploy run_all_playlists Function
run: python admin.py playlist_cron ${{ vars.GCP_PROJECT }}
# DEPLOY run_all_playlist_stats function
- name: Deploy run_all_playlist_stats Function
run: python admin.py playlist_stats_cron ${{ vars.GCP_PROJECT }}
# DEPLOY run_all_tags function
- name: Deploy run_all_tags Function
run: python admin.py tags_cron ${{ vars.GCP_PROJECT }}
documentation:
runs-on: ubuntu-latest
name: Build & Deploy Documentation
needs: deployprod # for ignoring bad builds
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v4 # get source
# PYTHON
- name: Install Python ${{ env.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ env.python-version }}
# PYTHON for dependency export only, not installing
- name: Install Poetry ${{ env.poetry-version }}
uses: abatilo/actions-poetry@v2.1.6
with:
poetry-version: ${{ env.poetry-version }}
# PYTHON install dependencies
- name: Install Python Dependencies
run: poetry install
# # JS setup for jsdoc
# - name: Install Node ${{ env.node-version }}
# uses: actions/setup-node@v2
# with:
# node-version: ${{ env.node-version }}
# # JS setup for jsdoc
# - name: Install jsdoc
# run: npm install jsdoc
# # JS setup for jsdoc
# - name: Add node_modules/.bin to PATH
# run: echo "${GITHUB_WORKSPACE}/node_modules/.bin" >> $GITHUB_PATH
# DEPLOY for setting up cloud API
- name: Set up Cloud SDK
uses: google-github-actions/auth@v0.7.3
with:
credentials_json: '${{ secrets.GCP_SA_KEY }}'
export_environment_variables: true
create_credentials_file: true
- name: Generate Documentation
run: poetry run sphinx-build docs public -b html
- name: Write CNAME
run: echo docs.mixonomer.sarsoo.xyz > ./public/CNAME
- name: Deploy To Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./public
package:
runs-on: ubuntu-latest
name: Package & Push Container
needs: [build] # for ignoring bad builds
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build & Push Container
uses: docker/build-push-action@v2
with:
push: true
platforms: linux/amd64,linux/arm64
tags: sarsoo/mixonomer:latest
deployprod:
runs-on: ubuntu-latest
name: Deploy Production
environment:
name: prod
url: https://mixonomer.sarsoo.xyz
needs: deploytest # for ignoring bad builds
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v4 # get source
# PYTHON (pinned to 3.9 for gcloud attribute mapping error)
- name: Install Python ${{ env.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ env.python-version }}
# PYTHON for dependency export only, not installing
- name: Install Poetry ${{ env.poetry-version }}
uses: abatilo/actions-poetry@v2.1.6
with:
poetry-version: ${{ env.poetry-version }}
# PYTHON Export Poetry dependencies as requirements.txt
- name: Export Poetry Dependencies
run: python admin.py pydepend
# JS setup
- name: Install Node ${{ env.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ env.node-version }}
# 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/auth@v0.7.3
with:
credentials_json: '${{ secrets.GCP_SA_KEY }}'
export_environment_variables: true
create_credentials_file: true
# DEPLOY set project
- name: Set GCP Project
run: python admin.py set_project ${{ vars.GCP_PROJECT }}
# DEPLOY app engine service, -nb for skipping compile
- name: Deploy App Engine Service
run: python admin.py app -nb
# DEPLOY domain routes
- name: Deploy dispatch.yaml
run: gcloud app deploy dispatch.yaml --quiet
### MAIN FUNCTIONS
# DEPLOY update_tag function
- name: Deploy update_tag Function
run: python admin.py tag ${{ vars.GCP_PROJECT }}
# DEPLOY run_user_playlist function
- name: Deploy run_user_playlist Function
run: python admin.py playlist ${{ vars.GCP_PROJECT }}
### CRON FUNCTIONS
# DEPLOY run_all_playlists function
- name: Deploy run_all_playlists Function
run: python admin.py playlist_cron ${{ vars.GCP_PROJECT }}
# DEPLOY run_all_playlist_stats function
- name: Deploy run_all_playlist_stats Function
run: python admin.py playlist_stats_cron ${{ vars.GCP_PROJECT }}
# DEPLOY run_all_tags function
- name: Deploy run_all_tags Function
run: python admin.py tags_cron ${{ vars.GCP_PROJECT }}

128
.gitignore vendored
View File

@ -1,128 +0,0 @@
scratch.py
service.json
requirements.txt
main.py
*~*
*#
node_modules/
# Byte-compiled / optimized / DLL files
*/__pycache__/*
*.py[cod]
*$py.class
.idea
# 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/

View File

@ -1,40 +0,0 @@
pipeline {
agent any
stages {
// stage('Build Python Env') {
// steps {
// sh 'poetry install'
// }
// }
// stage('Build Javascript') {
// steps {
// sh 'npm ci'
// sh 'npm run build --if-present'
// }
// }
// stage('Test') {
// steps {
// dotnetTest project: "Selector.Core.sln"
// }
// }
stage('Deploy') {
when { branch 'master' }
steps {
script {
docker.withRegistry('https://registry.sarsoo.xyz', 'git-registry-creds') {
docker.build("sarsoo/mixonomer:latest").push()
}
}
}
}
}
post {
always {
cleanWs()
}
}
}

38
.vscode/launch.json vendored
View File

@ -1,38 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python: Flask",
"type": "python",
"request": "launch",
"module": "flask",
"env": {
"FLASK_APP": "music.music",
"FLASK_ENV": "development",
"GOOGLE_CLOUD_PROJECT": "sarsooxyz"
},
"envFile": "${workspaceFolder}/.env",
"args": [
"run",
"--no-debugger"
],
"jinja": true,
"justMyCode": false
},
{
"command": "npm run devbuild",
"name": "Js: Build Dev",
"request": "launch",
"type": "node-terminal"
},
{
"command": "npm run build",
"name": "Js: Build Release",
"request": "launch",
"type": "node-terminal"
}
]
}

13
.vscode/settings.json vendored
View File

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

1
CNAME Normal file
View File

@ -0,0 +1 @@
docs.mixonomer.sarsoo.xyz

View File

@ -1,38 +0,0 @@
FROM node:22.5.1-alpine AS js-build
RUN npm install -g sass
COPY ./package.json /mixonomer/
COPY ./package-lock.json /mixonomer/
COPY ./webpack.common.js /mixonomer/
COPY ./webpack.prod.js /mixonomer/
COPY ./.babelrc /mixonomer/
COPY ./src /mixonomer/src/
WORKDIR /mixonomer
RUN npm ci
RUN npm run build
RUN sass src/scss/style.scss build/style.css
FROM python:3.11-slim as py
RUN pip install poetry==1.8.3
RUN poetry config virtualenvs.create false
WORKDIR /mixonomer
COPY pyproject.toml .
COPY poetry.lock .
RUN poetry install
RUN poetry add gunicorn@^20
COPY ./music ./music
COPY gunicorn.conf.py gunicorn.conf.py
COPY main.api.py main.py
COPY --from=js-build /mixonomer/build ./build/
EXPOSE 80
#Run the container
ENTRYPOINT [ "poetry", "run", "gunicorn" ]

View File

@ -1,54 +0,0 @@
[Mixonomer](https://mixonomer.sarsoo.xyz)
==================
![Python Tests](https://github.com/sarsoo/Mixonomer/workflows/test%20and%20deploy/badge.svg)
Smart playlists for Spotify with Last.fm insights. Mixonomer is a cloud native platform for combining Spotify playlists and enhancing them with recommendations and listening history data. I started the app in 2019, but Spotify have included similar features since then such as [enhanced playlists](https://www.theverge.com/2021/9/9/22664655/spotify-enhance-feature-recommended-songs-playlists) and [smart shuffle](https://newsroom.spotify.com/2023-03-08/smart-shuffle-new-life-spotify-playlists/).
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/Mixonomer-iOS).
Read the full documentation [here](https://docs.mixonomer.sarsoo.xyz). Read the blog post [here](https://sarsoo.xyz/mixonomer/).
# 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 Mixonomer 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.
## Acknowledgements
Took inspiration from Paul Lamere's [smarter playlists](http://smarterplaylists.playlistmachinery.com/).

BIN
_images/Playlists.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

@ -0,0 +1,134 @@
/*
* _sphinx_javascript_frameworks_compat.js
* ~~~~~~~~~~
*
* Compatability shim for jQuery and underscores.js.
*
* WILL BE REMOVED IN Sphinx 6.0
* xref RemovedInSphinx60Warning
*
*/
/**
* select a different prefix for underscore
*/
$u = _.noConflict();
/**
* 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;
}

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;
}
}

900
_static/basic.css Normal file
View File

@ -0,0 +1,900 @@
/*
* basic.css
* ~~~~~~~~~
*
* Sphinx stylesheet -- basic theme.
*
* :copyright: Copyright 2007-2022 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 p.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: 360px;
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;
}
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, .figure.align-left, object.align-left {
clear: left;
float: left;
margin-right: 1em;
}
img.align-right, figure.align-right, .figure.align-right, object.align-right {
clear: right;
float: right;
margin-left: 1em;
}
img.align-center, figure.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
img.align-default, figure.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,
aside.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;
}
nav.contents,
aside.topic,
div.admonition, div.topic, blockquote {
clear: left;
}
/* -- topics ---------------------------------------------------------------- */
nav.contents,
aside.topic,
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,
aside.sidebar > :last-child,
nav.contents > :last-child,
aside.topic > :last-child,
div.topic > :last-child,
div.admonition > :last-child {
margin-bottom: 0;
}
div.sidebar::after,
aside.sidebar::after,
nav.contents::after,
aside.topic::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;
}
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, figure {
margin: 0.5em;
padding: 0.5em;
}
div.figure p.caption, figcaption {
padding: 0.3em;
}
div.figure p.caption span.caption-number,
figcaption span.caption-number {
font-style: italic;
}
div.figure p.caption span.caption-text,
figcaption 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;
}
/* -- object description styles --------------------------------------------- */
.sig {
font-family: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace;
}
.sig-name, code.descname {
background-color: transparent;
font-weight: bold;
}
.sig-name {
font-size: 1.1em;
}
code.descname {
font-size: 1.2em;
}
.sig-prename, code.descclassname {
background-color: transparent;
}
.optional {
font-size: 1.3em;
}
.sig-paren {
font-size: larger;
}
.sig-param.n {
font-style: italic;
}
/* C++ specific styling */
.sig-inline.c-texpr,
.sig-inline.cpp-texpr {
font-family: unset;
}
.sig.c .k, .sig.c .kt,
.sig.cpp .k, .sig.cpp .kt {
color: #0033B3;
}
.sig.c .m,
.sig.cpp .m {
color: #1750EB;
}
.sig.c .s, .sig.c .sc,
.sig.cpp .s, .sig.cpp .sc {
color: #067D17;
}
/* -- 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;
}
aside.footnote > span,
div.citation > span {
float: left;
}
aside.footnote > span:last-of-type,
div.citation > span:last-of-type {
padding-right: 0.5em;
}
aside.footnote > p {
margin-left: 2em;
}
div.citation > p {
margin-left: 4em;
}
aside.footnote > p:last-of-type,
div.citation > p:last-of-type {
margin-bottom: 0em;
}
aside.footnote > p:last-of-type:after,
div.citation > p:last-of-type: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 > 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;
}
.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 0.5em;
content: ":";
display: inline-block;
}
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;
white-space: nowrap;
}
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.highlight span.gp { /* gp: Generic.Prompt */
user-select: none;
-webkit-user-select: text; /* Safari fallback only */
-webkit-user-select: none; /* Chrome/Safari */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* IE10+ */
}
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.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. */

156
_static/doctools.js Normal file
View File

@ -0,0 +1,156 @@
/*
* doctools.js
* ~~~~~~~~~~~
*
* Base JavaScript utilities for all Sphinx HTML documentation.
*
* :copyright: Copyright 2007-2022 by the Sphinx team, see AUTHORS.
* :license: BSD, see LICENSE for details.
*
*/
"use strict";
const BLACKLISTED_KEY_CONTROL_ELEMENTS = new Set([
"TEXTAREA",
"INPUT",
"SELECT",
"BUTTON",
]);
const _ready = (callback) => {
if (document.readyState !== "loading") {
callback();
} else {
document.addEventListener("DOMContentLoaded", callback);
}
};
/**
* Small JavaScript module for the documentation.
*/
const Documentation = {
init: () => {
Documentation.initDomainIndexTable();
Documentation.initOnKeyListeners();
},
/**
* i18n support
*/
TRANSLATIONS: {},
PLURAL_EXPR: (n) => (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: (string) => {
const translated = Documentation.TRANSLATIONS[string];
switch (typeof translated) {
case "undefined":
return string; // no translation
case "string":
return translated; // translation exists
default:
return translated[0]; // (singular, plural) translation tuple exists
}
},
ngettext: (singular, plural, n) => {
const translated = Documentation.TRANSLATIONS[singular];
if (typeof translated !== "undefined")
return translated[Documentation.PLURAL_EXPR(n)];
return n === 1 ? singular : plural;
},
addTranslations: (catalog) => {
Object.assign(Documentation.TRANSLATIONS, catalog.messages);
Documentation.PLURAL_EXPR = new Function(
"n",
`return (${catalog.plural_expr})`
);
Documentation.LOCALE = catalog.locale;
},
/**
* helper function to focus on search bar
*/
focusSearchBar: () => {
document.querySelectorAll("input[name=q]")[0]?.focus();
},
/**
* Initialise the domain index toggle buttons
*/
initDomainIndexTable: () => {
const toggler = (el) => {
const idNumber = el.id.substr(7);
const toggledRows = document.querySelectorAll(`tr.cg-${idNumber}`);
if (el.src.substr(-9) === "minus.png") {
el.src = `${el.src.substr(0, el.src.length - 9)}plus.png`;
toggledRows.forEach((el) => (el.style.display = "none"));
} else {
el.src = `${el.src.substr(0, el.src.length - 8)}minus.png`;
toggledRows.forEach((el) => (el.style.display = ""));
}
};
const togglerElements = document.querySelectorAll("img.toggler");
togglerElements.forEach((el) =>
el.addEventListener("click", (event) => toggler(event.currentTarget))
);
togglerElements.forEach((el) => (el.style.display = ""));
if (DOCUMENTATION_OPTIONS.COLLAPSE_INDEX) togglerElements.forEach(toggler);
},
initOnKeyListeners: () => {
// only install a listener if it is really needed
if (
!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS &&
!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS
)
return;
document.addEventListener("keydown", (event) => {
// bail for input elements
if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) return;
// bail with special keys
if (event.altKey || event.ctrlKey || event.metaKey) return;
if (!event.shiftKey) {
switch (event.key) {
case "ArrowLeft":
if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break;
const prevLink = document.querySelector('link[rel="prev"]');
if (prevLink && prevLink.href) {
window.location.href = prevLink.href;
event.preventDefault();
}
break;
case "ArrowRight":
if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break;
const nextLink = document.querySelector('link[rel="next"]');
if (nextLink && nextLink.href) {
window.location.href = nextLink.href;
event.preventDefault();
}
break;
}
}
// some keyboard layouts may need Shift to get /
switch (event.key) {
case "/":
if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) break;
Documentation.focusSearchBar();
event.preventDefault();
}
});
},
};
// quick alias for translations
const _ = Documentation.gettext;
_ready(Documentation.init);

View File

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

BIN
_static/file.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

10881
_static/jquery-3.6.0.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

199
_static/language_data.js Normal file
View File

@ -0,0 +1,199 @@
/*
* 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-2022 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;
}
}

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

83
_static/pygments.css Normal file
View File

@ -0,0 +1,83 @@
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 .pm { color: #000000; font-weight: bold } /* Punctuation.Marker */
.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 */

566
_static/searchtools.js Normal file
View File

@ -0,0 +1,566 @@
/*
* searchtools.js
* ~~~~~~~~~~~~~~~~
*
* Sphinx JavaScript utilities for the full-text search.
*
* :copyright: Copyright 2007-2022 by the Sphinx team, see AUTHORS.
* :license: BSD, see LICENSE for details.
*
*/
"use strict";
/**
* Simple result scoring code.
*/
if (typeof Scorer === "undefined") {
var Scorer = {
// Implement the following function to further tweak the score for each result
// The function takes a result array [docname, title, anchor, descr, score, filename]
// and returns the new score.
/*
score: result => {
const [docname, title, anchor, descr, score, filename] = result
return score
},
*/
// 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,
};
}
const _removeChildren = (element) => {
while (element && element.lastChild) element.removeChild(element.lastChild);
};
/**
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
*/
const _escapeRegExp = (string) =>
string.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
const _displayItem = (item, searchTerms) => {
const docBuilder = DOCUMENTATION_OPTIONS.BUILDER;
const docUrlRoot = DOCUMENTATION_OPTIONS.URL_ROOT;
const docFileSuffix = DOCUMENTATION_OPTIONS.FILE_SUFFIX;
const docLinkSuffix = DOCUMENTATION_OPTIONS.LINK_SUFFIX;
const showSearchSummary = DOCUMENTATION_OPTIONS.SHOW_SEARCH_SUMMARY;
const [docName, title, anchor, descr, score, _filename] = item;
let listItem = document.createElement("li");
let requestUrl;
let linkUrl;
if (docBuilder === "dirhtml") {
// dirhtml builder
let dirname = docName + "/";
if (dirname.match(/\/index\/$/))
dirname = dirname.substring(0, dirname.length - 6);
else if (dirname === "index/") dirname = "";
requestUrl = docUrlRoot + dirname;
linkUrl = requestUrl;
} else {
// normal html builders
requestUrl = docUrlRoot + docName + docFileSuffix;
linkUrl = docName + docLinkSuffix;
}
let linkEl = listItem.appendChild(document.createElement("a"));
linkEl.href = linkUrl + anchor;
linkEl.dataset.score = score;
linkEl.innerHTML = title;
if (descr)
listItem.appendChild(document.createElement("span")).innerHTML =
" (" + descr + ")";
else if (showSearchSummary)
fetch(requestUrl)
.then((responseData) => responseData.text())
.then((data) => {
if (data)
listItem.appendChild(
Search.makeSearchSummary(data, searchTerms)
);
});
Search.output.appendChild(listItem);
};
const _finishSearch = (resultCount) => {
Search.stopPulse();
Search.title.innerText = _("Search Results");
if (!resultCount)
Search.status.innerText = Documentation.gettext(
"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.innerText = _(
`Search finished, found ${resultCount} page(s) matching the search query.`
);
};
const _displayNextItem = (
results,
resultCount,
searchTerms
) => {
// results left, load the summary and display it
// this is intended to be dynamic (don't sub resultsCount)
if (results.length) {
_displayItem(results.pop(), searchTerms);
setTimeout(
() => _displayNextItem(results, resultCount, searchTerms),
5
);
}
// search finished, update title and status message
else _finishSearch(resultCount);
};
/**
* Default splitQuery function. Can be overridden in ``sphinx.search`` with a
* custom function per language.
*
* The regular expression works by splitting the string on consecutive characters
* that are not Unicode letters, numbers, underscores, or emoji characters.
* This is the same as ``\W+`` in Python, preserving the surrogate pair area.
*/
if (typeof splitQuery === "undefined") {
var splitQuery = (query) => query
.split(/[^\p{Letter}\p{Number}_\p{Emoji_Presentation}]+/gu)
.filter(term => term) // remove remaining empty strings
}
/**
* Search Module
*/
const Search = {
_index: null,
_queued_query: null,
_pulse_status: -1,
htmlToText: (htmlString) => {
const htmlElement = new DOMParser().parseFromString(htmlString, 'text/html');
htmlElement.querySelectorAll(".headerlink").forEach((el) => { el.remove() });
const docContent = htmlElement.querySelector('[role="main"]');
if (docContent !== undefined) return docContent.textContent;
console.warn(
"Content block not found. Sphinx search tries to obtain it via '[role=main]'. Could you check your theme or template."
);
return "";
},
init: () => {
const query = new URLSearchParams(window.location.search).get("q");
document
.querySelectorAll('input[name="q"]')
.forEach((el) => (el.value = query));
if (query) Search.performSearch(query);
},
loadIndex: (url) =>
(document.body.appendChild(document.createElement("script")).src = url),
setIndex: (index) => {
Search._index = index;
if (Search._queued_query !== null) {
const query = Search._queued_query;
Search._queued_query = null;
Search.query(query);
}
},
hasIndex: () => Search._index !== null,
deferQuery: (query) => (Search._queued_query = query),
stopPulse: () => (Search._pulse_status = -1),
startPulse: () => {
if (Search._pulse_status >= 0) return;
const pulse = () => {
Search._pulse_status = (Search._pulse_status + 1) % 4;
Search.dots.innerText = ".".repeat(Search._pulse_status);
if (Search._pulse_status >= 0) window.setTimeout(pulse, 500);
};
pulse();
},
/**
* perform a search for something (or wait until index is loaded)
*/
performSearch: (query) => {
// create the required interface elements
const searchText = document.createElement("h2");
searchText.textContent = _("Searching");
const searchSummary = document.createElement("p");
searchSummary.classList.add("search-summary");
searchSummary.innerText = "";
const searchList = document.createElement("ul");
searchList.classList.add("search");
const out = document.getElementById("search-results");
Search.title = out.appendChild(searchText);
Search.dots = Search.title.appendChild(document.createElement("span"));
Search.status = out.appendChild(searchSummary);
Search.output = out.appendChild(searchList);
const searchProgress = document.getElementById("search-progress");
// Some themes don't use the search progress node
if (searchProgress) {
searchProgress.innerText = _("Preparing search...");
}
Search.startPulse();
// index already loaded, the browser was quick!
if (Search.hasIndex()) Search.query(query);
else Search.deferQuery(query);
},
/**
* execute search (requires search index to be loaded)
*/
query: (query) => {
const filenames = Search._index.filenames;
const docNames = Search._index.docnames;
const titles = Search._index.titles;
const allTitles = Search._index.alltitles;
const indexEntries = Search._index.indexentries;
// stem the search terms and add them to the correct list
const stemmer = new Stemmer();
const searchTerms = new Set();
const excludedTerms = new Set();
const highlightTerms = new Set();
const objectTerms = new Set(splitQuery(query.toLowerCase().trim()));
splitQuery(query.trim()).forEach((queryTerm) => {
const queryTermLower = queryTerm.toLowerCase();
// maybe skip this "word"
// stopwords array is from language_data.js
if (
stopwords.indexOf(queryTermLower) !== -1 ||
queryTerm.match(/^\d+$/)
)
return;
// stem the word
let word = stemmer.stemWord(queryTermLower);
// select the correct list
if (word[0] === "-") excludedTerms.add(word.substr(1));
else {
searchTerms.add(word);
highlightTerms.add(queryTermLower);
}
});
if (SPHINX_HIGHLIGHT_ENABLED) { // set in sphinx_highlight.js
localStorage.setItem("sphinx_highlight_terms", [...highlightTerms].join(" "))
}
// console.debug("SEARCH: searching for:");
// console.info("required: ", [...searchTerms]);
// console.info("excluded: ", [...excludedTerms]);
// array of [docname, title, anchor, descr, score, filename]
let results = [];
_removeChildren(document.getElementById("search-progress"));
const queryLower = query.toLowerCase();
for (const [title, foundTitles] of Object.entries(allTitles)) {
if (title.toLowerCase().includes(queryLower) && (queryLower.length >= title.length/2)) {
for (const [file, id] of foundTitles) {
let score = Math.round(100 * queryLower.length / title.length)
results.push([
docNames[file],
titles[file] !== title ? `${titles[file]} > ${title}` : title,
id !== null ? "#" + id : "",
null,
score,
filenames[file],
]);
}
}
}
// search for explicit entries in index directives
for (const [entry, foundEntries] of Object.entries(indexEntries)) {
if (entry.includes(queryLower) && (queryLower.length >= entry.length/2)) {
for (const [file, id] of foundEntries) {
let score = Math.round(100 * queryLower.length / entry.length)
results.push([
docNames[file],
titles[file],
id ? "#" + id : "",
null,
score,
filenames[file],
]);
}
}
}
// lookup as object
objectTerms.forEach((term) =>
results.push(...Search.performObjectSearch(term, objectTerms))
);
// lookup as search terms in fulltext
results.push(...Search.performTermsSearch(searchTerms, excludedTerms));
// let the scorer override scores with a custom scoring function
if (Scorer.score) results.forEach((item) => (item[4] = Scorer.score(item)));
// 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((a, b) => {
const leftScore = a[4];
const rightScore = b[4];
if (leftScore === rightScore) {
// same score: sort alphabetically
const leftTitle = a[1].toLowerCase();
const rightTitle = b[1].toLowerCase();
if (leftTitle === rightTitle) return 0;
return leftTitle > rightTitle ? -1 : 1; // inverted is intentional
}
return leftScore > rightScore ? 1 : -1;
});
// remove duplicate search results
// note the reversing of results, so that in the case of duplicates, the highest-scoring entry is kept
let seen = new Set();
results = results.reverse().reduce((acc, result) => {
let resultStr = result.slice(0, 4).concat([result[5]]).map(v => String(v)).join(',');
if (!seen.has(resultStr)) {
acc.push(result);
seen.add(resultStr);
}
return acc;
}, []);
results = results.reverse();
// for debugging
//Search.lastresults = results.slice(); // a copy
// console.info("search results:", Search.lastresults);
// print the results
_displayNextItem(results, results.length, searchTerms);
},
/**
* search for object names
*/
performObjectSearch: (object, objectTerms) => {
const filenames = Search._index.filenames;
const docNames = Search._index.docnames;
const objects = Search._index.objects;
const objNames = Search._index.objnames;
const titles = Search._index.titles;
const results = [];
const objectSearchCallback = (prefix, match) => {
const name = match[4]
const fullname = (prefix ? prefix + "." : "") + name;
const fullnameLower = fullname.toLowerCase();
if (fullnameLower.indexOf(object) < 0) return;
let score = 0;
const 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.slice(-1)[0] === object)
score += Scorer.objNameMatch;
else if (parts.slice(-1)[0].indexOf(object) > -1)
score += Scorer.objPartialMatch; // matches in last name
const objName = objNames[match[1]][2];
const title = titles[match[0]];
// If more than one term searched for, we require other words to be
// found in the name/title/description
const otherTerms = new Set(objectTerms);
otherTerms.delete(object);
if (otherTerms.size > 0) {
const haystack = `${prefix} ${name} ${objName} ${title}`.toLowerCase();
if (
[...otherTerms].some((otherTerm) => haystack.indexOf(otherTerm) < 0)
)
return;
}
let anchor = match[3];
if (anchor === "") anchor = fullname;
else if (anchor === "-") anchor = objNames[match[1]][1] + "-" + fullname;
const descr = objName + _(", in ") + title;
// 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]],
]);
};
Object.keys(objects).forEach((prefix) =>
objects[prefix].forEach((array) =>
objectSearchCallback(prefix, array)
)
);
return results;
},
/**
* search for full-text terms in the index
*/
performTermsSearch: (searchTerms, excludedTerms) => {
// prepare search
const terms = Search._index.terms;
const titleTerms = Search._index.titleterms;
const filenames = Search._index.filenames;
const docNames = Search._index.docnames;
const titles = Search._index.titles;
const scoreMap = new Map();
const fileMap = new Map();
// perform the search on the required terms
searchTerms.forEach((word) => {
const files = [];
const arr = [
{ files: terms[word], score: Scorer.term },
{ files: titleTerms[word], score: Scorer.title },
];
// add support for partial matches
if (word.length > 2) {
const escapedWord = _escapeRegExp(word);
Object.keys(terms).forEach((term) => {
if (term.match(escapedWord) && !terms[word])
arr.push({ files: terms[term], score: Scorer.partialTerm });
});
Object.keys(titleTerms).forEach((term) => {
if (term.match(escapedWord) && !titleTerms[word])
arr.push({ files: titleTerms[word], score: Scorer.partialTitle });
});
}
// no match but word was a required one
if (arr.every((record) => record.files === undefined)) return;
// found search word in contents
arr.forEach((record) => {
if (record.files === undefined) return;
let recordFiles = record.files;
if (recordFiles.length === undefined) recordFiles = [recordFiles];
files.push(...recordFiles);
// set score for the word in each file
recordFiles.forEach((file) => {
if (!scoreMap.has(file)) scoreMap.set(file, {});
scoreMap.get(file)[word] = record.score;
});
});
// create the mapping
files.forEach((file) => {
if (fileMap.has(file) && fileMap.get(file).indexOf(word) === -1)
fileMap.get(file).push(word);
else fileMap.set(file, [word]);
});
});
// now check if the files don't contain excluded terms
const results = [];
for (const [file, wordList] of fileMap) {
// check if all requirements are matched
// as search terms with length < 3 are discarded
const filteredTermCount = [...searchTerms].filter(
(term) => term.length > 2
).length;
if (
wordList.length !== searchTerms.size &&
wordList.length !== filteredTermCount
)
continue;
// ensure that none of the excluded terms is in the search result
if (
[...excludedTerms].some(
(term) =>
terms[term] === file ||
titleTerms[term] === file ||
(terms[term] || []).includes(file) ||
(titleTerms[term] || []).includes(file)
)
)
break;
// select one (max) score for the file.
const score = Math.max(...wordList.map((w) => scoreMap.get(file)[w]));
// add result to the result list
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.
*/
makeSearchSummary: (htmlText, keywords) => {
const text = Search.htmlToText(htmlText);
if (text === "") return null;
const textLower = text.toLowerCase();
const actualStartPosition = [...keywords]
.map((k) => textLower.indexOf(k.toLowerCase()))
.filter((i) => i > -1)
.slice(-1)[0];
const startWithContext = Math.max(actualStartPosition - 120, 0);
const top = startWithContext === 0 ? "" : "...";
const tail = startWithContext + 240 < text.length ? "..." : "";
let summary = document.createElement("p");
summary.classList.add("context");
summary.textContent = top + text.substr(startWithContext, 240).trim() + tail;
return summary;
},
};
_ready(Search.init);

144
_static/sphinx_highlight.js Normal file
View File

@ -0,0 +1,144 @@
/* Highlighting utilities for Sphinx HTML documentation. */
"use strict";
const SPHINX_HIGHLIGHT_ENABLED = true
/**
* highlight a given string on a node by wrapping it in
* span elements with the given class name.
*/
const _highlight = (node, addItems, text, className) => {
if (node.nodeType === Node.TEXT_NODE) {
const val = node.nodeValue;
const parent = node.parentNode;
const pos = val.toLowerCase().indexOf(text);
if (
pos >= 0 &&
!parent.classList.contains(className) &&
!parent.classList.contains("nohighlight")
) {
let span;
const closestNode = parent.closest("body, svg, foreignObject");
const isInSVG = closestNode && closestNode.matches("svg");
if (isInSVG) {
span = document.createElementNS("http://www.w3.org/2000/svg", "tspan");
} else {
span = document.createElement("span");
span.classList.add(className);
}
span.appendChild(document.createTextNode(val.substr(pos, text.length)));
parent.insertBefore(
span,
parent.insertBefore(
document.createTextNode(val.substr(pos + text.length)),
node.nextSibling
)
);
node.nodeValue = val.substr(0, pos);
if (isInSVG) {
const rect = document.createElementNS(
"http://www.w3.org/2000/svg",
"rect"
);
const bbox = parent.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: parent, target: rect });
}
}
} else if (node.matches && !node.matches("button, select, textarea")) {
node.childNodes.forEach((el) => _highlight(el, addItems, text, className));
}
};
const _highlightText = (thisNode, text, className) => {
let addItems = [];
_highlight(thisNode, addItems, text, className);
addItems.forEach((obj) =>
obj.parent.insertAdjacentElement("beforebegin", obj.target)
);
};
/**
* Small JavaScript module for the documentation.
*/
const SphinxHighlight = {
/**
* highlight the search words provided in localstorage in the text
*/
highlightSearchWords: () => {
if (!SPHINX_HIGHLIGHT_ENABLED) return; // bail if no highlight
// get and clear terms from localstorage
const url = new URL(window.location);
const highlight =
localStorage.getItem("sphinx_highlight_terms")
|| url.searchParams.get("highlight")
|| "";
localStorage.removeItem("sphinx_highlight_terms")
url.searchParams.delete("highlight");
window.history.replaceState({}, "", url);
// get individual terms from highlight string
const terms = highlight.toLowerCase().split(/\s+/).filter(x => x);
if (terms.length === 0) return; // nothing to do
// There should never be more than one element matching "div.body"
const divBody = document.querySelectorAll("div.body");
const body = divBody.length ? divBody[0] : document.querySelector("body");
window.setTimeout(() => {
terms.forEach((term) => _highlightText(body, term, "highlighted"));
}, 10);
const searchBox = document.getElementById("searchbox");
if (searchBox === null) return;
searchBox.appendChild(
document
.createRange()
.createContextualFragment(
'<p class="highlight-link">' +
'<a href="javascript:SphinxHighlight.hideSearchWords()">' +
_("Hide Search Matches") +
"</a></p>"
)
);
},
/**
* helper function to hide the search marks again
*/
hideSearchWords: () => {
document
.querySelectorAll("#searchbox .highlight-link")
.forEach((el) => el.remove());
document
.querySelectorAll("span.highlighted")
.forEach((el) => el.classList.remove("highlighted"));
localStorage.removeItem("sphinx_highlight_terms")
},
initEscapeListener: () => {
// only install a listener if it is really needed
if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) return;
document.addEventListener("keydown", (event) => {
// bail for input elements
if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) return;
// bail with special keys
if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return;
if (DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS && (event.key === "Escape")) {
SphinxHighlight.hideSearchWords();
event.preventDefault();
}
});
},
};
_ready(SphinxHighlight.highlightSearchWords);
_ready(SphinxHighlight.initEscapeListener);

2042
_static/underscore-1.13.1.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

308
admin.py
View File

@ -1,308 +0,0 @@
#!/usr/bin/env python3
import shutil
import os
from pathlib import Path
import sys
import subprocess
from cmd import Cmd
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
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 = 'Mixonomer 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(f'gcloud config set project {args.strip()}', shell=True)
@property
def gcloud_project(self):
return subprocess.run(["gcloud", "config", "get-value", "project"], stdout=subprocess.PIPE).stdout.decode("utf-8").strip()
def do_project(self, args):
print(f"\"{self.gcloud_project}\"")
def deploy_function(self, name, project_id, timeout: int = 60, region='europe-west2'):
"""
Deploy function with required environment variables
"""
subprocess.check_call(
f'gcloud functions deploy {name} '
f'--region {region} '
f'--gen2 '
'--runtime=python311 '
f'--trigger-topic {name} '
f'--set-env-vars DEPLOY_DESTINATION=PROD,GOOGLE_CLOUD_PROJECT={project_id} '
f'--service-account {name.replace("_", "-")}-func@{self.gcloud_project}.iam.gserviceaccount.com '
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 = user.get_playlist(name)
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, project_id):
"""Deploy Cloud Function, copy main file and initiate gcloud command
Args:
main (str): main path
function_id (str): function id to deploy to
"""
if len(project_id) == 0:
print("no project id provided")
exit()
self.copy_main_file(main)
print(f'>> deploying {function_id}')
self.deploy_function(function_id, project_id)
def do_tag(self, args):
"""
Deploy update_tag function
"""
self.function_deploy('update_tag', 'update_tag', args.strip())
def do_playlist(self, args):
"""
Deploy run_user_playlist function
"""
self.function_deploy('run_playlist', 'run_user_playlist', args.strip())
# all playlists cron job
def do_playlist_cron(self, args):
"""
Deploy run_all_playlists function
"""
self.function_deploy('cron', 'run_all_playlists', args.strip())
# 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', args.strip())
# all tags cron job
def do_tags_cron(self, args):
"""
Deploy run_all_tags function
"""
self.function_deploy('cron', 'run_all_tags', args.strip())
# 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]
final_filtered = []
for f in filtered:
if f.count('@') == 2:
final_filtered.append(f[:f.rindex('@') + 1] + "master")
else:
final_filtered.append(f)
with open('requirements.txt', 'w') as f:
f.write("\n".join(final_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_run(self, args):
"""
Run Flask app
"""
subprocess.check_call(f'python main.api.py', shell=True)
def do_test(self, args):
"""
Run Python unit tests
"""
subprocess.check_call(f'python -u -m unittest discover -s tests', shell=True)
def do_docs(self, args):
"""
Compile documentation using sphinx
"""
subprocess.check_call(f'sphinx-build docs docs/build -b html', shell=True)
def do_exit(self, args):
"""
Exit script
"""
exit(0)
def test():
Admin().onecmd('test')
def run():
Admin().onecmd('run')
def docs():
Admin().onecmd('docs')
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: python311
service: default
#instance_class: F1
handlers:
- url: /static
static_dir: build
- url: /.*
script: auto
secure: always
env_variables:
DEPLOY_DESTINATION: 'PROD'

View File

@ -1,4 +0,0 @@
dispatch:
- url: "test.mixonomer.sarsoo.xyz/*"
service: default

View File

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

View File

@ -1,20 +0,0 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = src
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

File diff suppressed because one or more lines are too long

View File

@ -1,55 +0,0 @@
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
# import sys
# sys.path.insert(0, os.path.abspath('..'))
# sys.setrecursionlimit(1500)
# -- Project information -----------------------------------------------------
project = 'Mixonomer'
copyright = '2021, Sarsoo'
author = 'Sarsoo'
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage', 'sphinx.ext.napoleon']
# js_source_path = '../src/js'
jsdoc_config_path = 'jsdoc.json'
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', 'venv']
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'alabaster'
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']

View File

@ -1,5 +0,0 @@
{
"opts": {
"recurse": true
}
}

View File

@ -1,35 +0,0 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

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 Mixonomer: 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 Mixonomer 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}
}

1016
genindex.html Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +0,0 @@
import multiprocessing
bind = "0.0.0.0:80"
workers = multiprocessing.cpu_count() * 2 + 1
wsgi_app = "main:app"

152
index.html Normal file
View File

@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.19: https://docutils.sourceforge.io/" />
<title>Mixonomer &#8212; Mixonomer documentation</title>
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
<script src="_static/jquery.js"></script>
<script src="_static/underscore.js"></script>
<script src="_static/_sphinx_javascript_frameworks_compat.js"></script>
<script src="_static/doctools.js"></script>
<script src="_static/sphinx_highlight.js"></script>
<link rel="index" title="Index" href="genindex.html" />
<link rel="search" title="Search" href="search.html" />
<link rel="next" title="Flask Backend" href="src/music.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">
<section id="mixonomer">
<h1>Mixonomer<a class="headerlink" href="#mixonomer" title="Permalink to this heading"></a></h1>
<div class="toctree-wrapper compound">
<p class="caption" role="heading"><span class="caption-text">Contents:</span></p>
<ul>
<li class="toctree-l1"><a class="reference internal" href="src/music.html">Py</a><ul>
<li class="toctree-l2"><a class="reference internal" href="src/music.api.html">music.api</a></li>
<li class="toctree-l2"><a class="reference internal" href="src/music.auth.html">music.auth</a></li>
<li class="toctree-l2"><a class="reference internal" href="src/music.cloud.html">music.cloud</a></li>
<li class="toctree-l2"><a class="reference internal" href="src/music.db.html">music.db</a></li>
<li class="toctree-l2"><a class="reference internal" href="src/music.model.html">music.model</a></li>
<li class="toctree-l2"><a class="reference internal" href="src/music.tasks.html">music.tasks</a></li>
<li class="toctree-l2"><a class="reference internal" href="src/music.html#module-music">music Root Module</a></li>
</ul>
</li>
<li class="toctree-l1"><a class="reference internal" href="src/admin.html">Admin Script</a><ul>
<li class="toctree-l2"><a class="reference internal" href="src/admin.html#admin.Admin"><code class="docutils literal notranslate"><span class="pre">Admin</span></code></a></li>
<li class="toctree-l2"><a class="reference internal" href="src/admin.html#admin.docs"><code class="docutils literal notranslate"><span class="pre">docs()</span></code></a></li>
<li class="toctree-l2"><a class="reference internal" href="src/admin.html#admin.folders_to_ignore"><code class="docutils literal notranslate"><span class="pre">folders_to_ignore</span></code></a></li>
<li class="toctree-l2"><a class="reference internal" href="src/admin.html#admin.run"><code class="docutils literal notranslate"><span class="pre">run()</span></code></a></li>
<li class="toctree-l2"><a class="reference internal" href="src/admin.html#admin.test"><code class="docutils literal notranslate"><span class="pre">test()</span></code></a></li>
</ul>
</li>
<li class="toctree-l1"><a class="reference internal" href="src/modules.html">All Modules</a><ul>
<li class="toctree-l2"><a class="reference internal" href="src/music.html">Flask Backend</a></li>
</ul>
</li>
</ul>
</div>
<section id="id2">
<h2><a class="reference external" href="https://mixonomer.sarsoo.xyz">Mixonomer</a><a class="headerlink" href="#id2" title="Permalink to this heading"></a></h2>
<img alt="https://github.com/sarsoo/Mixonomer/workflows/test%20and%20deploy/badge.svg" src="https://github.com/sarsoo/Mixonomer/workflows/test%20and%20deploy/badge.svg" /><p>Mixonomer is a web app for creating smart Spotify playlists. The app is based on <a class="reference external" href="https://github.com/Sarsoo/spotframework">spotframework</a> and <a class="reference external" href="https://github.com/Sarsoo/pyfmframework">fmframework</a> for interfacing with Spotify and Last.fm. The app is currently hosted on Googles Cloud Platform.</p>
<p>The backend is composed of a Flask web server with a Fireo ORM layer and longer tasks dispatched to Cloud Tasks or Functions. The frontend is a React app with material UI components and Axios for HTTP requests.</p>
<img alt="_images/Playlists.png" src="_images/Playlists.png" />
</section>
</section>
<section id="indices-and-tables">
<h1>Indices and tables<a class="headerlink" href="#indices-and-tables" title="Permalink to this heading"></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>
</section>
</div>
</div>
</div>
<div class="sphinxsidebar" role="navigation" aria-label="main navigation">
<div class="sphinxsidebarwrapper">
<h1 class="logo"><a href="#">Mixonomer</a></h1>
<h3>Navigation</h3>
<p class="caption" role="heading"><span class="caption-text">Contents:</span></p>
<ul>
<li class="toctree-l1"><a class="reference internal" href="src/music.html">Py</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/admin.html">Admin Script</a></li>
<li class="toctree-l1"><a class="reference internal" href="src/modules.html">All Modules</a></li>
</ul>
<div class="relations">
<h3>Related Topics</h3>
<ul>
<li><a href="#">Documentation overview</a><ul>
<li>Next: <a href="src/music.html" title="next chapter">Flask Backend</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" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"/>
<input type="submit" value="Go" />
</form>
</div>
</div>
<script>document.getElementById('searchbox').style.display = "block"</script>
</div>
</div>
<div class="clearer"></div>
</div>
<div class="footer">
&copy;2021, Sarsoo.
|
Powered by <a href="http://sphinx-doc.org/">Sphinx 5.3.0</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,19 +0,0 @@
from cloudevents.http import CloudEvent
import functions_framework
from music.cloud.tasks import update_all_user_playlists, refresh_all_user_playlist_stats, update_all_user_tags
@functions_framework.cloud_event
def run_all_playlists(event: CloudEvent):
update_all_user_playlists()
@functions_framework.cloud_event
def run_all_playlist_stats(event: CloudEvent):
refresh_all_user_playlist_stats()
@functions_framework.cloud_event
def run_all_tags(event: CloudEvent):
update_all_user_tags()

View File

@ -1,18 +0,0 @@
from cloudevents.http import CloudEvent
import functions_framework
# Register a CloudEvent function with the Functions Framework
@functions_framework.cloud_event
def run_user_playlist(event: CloudEvent):
import logging
logger = logging.getLogger('music')
attr = event.get_data()['message']['attributes']
if 'username' in attr and 'name' in attr:
from music.tasks.run_user_playlist import run_user_playlist as do_run_user_playlist
do_run_user_playlist(user=attr['username'], playlist=attr["name"])
else:
logger.error('no parameters in event attributes')

View File

@ -1,19 +0,0 @@
from cloudevents.http import CloudEvent
import functions_framework
# Register a CloudEvent function with the Functions Framework
@functions_framework.cloud_event
def update_tag(event: CloudEvent):
import logging
logger = logging.getLogger('music')
attr = event.get_data()['message']['attributes']
if 'username' in attr and 'tag_id' in attr:
from music.tasks.update_tag import update_tag as do_update_tag
do_update_tag(user=attr['username'], tag=attr["tag_id"])
else:
logger.error('no parameters in event attributes')

View File

@ -1,50 +0,0 @@
"""Root module containing Mixonomer backend
Top level module with functions for creating app with loaded blueprints and initialising the logging stack
"""
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,10 +0,0 @@
"""Flask blueprints for loading the app endpoints
"""
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,43 +0,0 @@
from flask import Blueprint, jsonify
import os
import logging
from datetime import datetime
from google.cloud import tasks_v2
from music.api.decorators import login_or_jwt, admin_required, no_locked_users
blueprint = Blueprint('admin-api', __name__)
tasker = tasks_v2.CloudTasksClient()
task_path = tasker.queue_path(os.environ['GOOGLE_CLOUD_PROJECT'], 'europe-west2', 'spotify-executions')
logger = logging.getLogger(__name__)
@blueprint.route('/tasks', methods=['GET'])
@login_or_jwt
@admin_required
@no_locked_users
def get_tasks(auth=None, user=None):
tasks = list(tasker.list_tasks(parent=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,397 +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_or_jwt, login_required, \
admin_required, cloud_task, validate_json, validate_args, spotify_link_required, no_locked_users
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_jwt
@no_locked_users
def all_playlists_route(auth: dict = None, user: User = None):
"""Retrieve all playlists for a given user
Args:
user ([type], optional): [description]. Defaults to None.
Returns:
HTTP Response: All playlists for given user
"""
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_jwt
@no_locked_users
@validate_args(('name', str))
def playlist_get_delete_route(auth: dict = None, user: User = None):
playlist = user.get_playlist(name := request.args['name'], raise_error=False)
if playlist is None:
return jsonify({'error': f'playlist {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_jwt
@no_locked_users
@validate_json(('name', str))
def playlist_post_put_route(auth: dict = None, user: User = None):
request_json = request.get_json()
playlist_name = request_json['name']
playlist_references = []
if request_refs := request_json.get('playlist_references', None):
if request_refs != -1:
for i in request_refs:
playlist = user.get_playlist(i, raise_error=False)
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_refs != -1:
playlist_references = None
searched_playlist = user.get_playlist(playlist_name, raise_error=False)
# 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 playlist_type := request_json.get('type'):
playlist_type = playlist_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
# 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(searched_playlist, rec_key, request_json[rec_key])
# COMPONENTS
if request_parts := request_json.get('parts'):
if request_parts == -1:
searched_playlist.parts = []
else:
searched_playlist.parts = request_parts
if request_part_addition := request_json.get('add_part'):
if request_part_addition not in searched_playlist.parts:
searched_playlist.parts = searched_playlist.parts + [request_part_addition]
if request_part_deletion := request_json.get('remove_part'):
if request_part_deletion in searched_playlist.parts:
searched_playlist.parts.remove(request_part_deletion)
if playlist_references is not None:
if playlist_references == -1:
searched_playlist.playlist_references = []
else:
searched_playlist.playlist_references = playlist_references
if request_ref_addition := request_json.get('add_ref'):
playlist = user.get_playlist(request_ref_addition, raise_error=False)
if playlist is not None and playlist.id not in [x.id for x in searched_playlist.playlist_references]:
searched_playlist.playlist_references = searched_playlist.playlist_references + [db.document(playlist.key)]
else:
return jsonify({"message": f'managed playlist {request_ref_addition} not found', "status": "error"}), 400
if request_ref_deletion := request_json.get('remove_ref'):
playlist = user.get_playlist(request_ref_deletion, raise_error=False)
if playlist is not None and playlist.id in [x.id for x in searched_playlist.playlist_references]:
searched_playlist.playlist_references = [i for i in searched_playlist.playlist_references if i.id != playlist.id]
else:
return jsonify({"message": f'managed playlist {request_ref_deletion} not found', "status": "error"}), 400
# ATTRIBUTE WITH CHECKS
if request_type := request_json.get('type'):
playlist_type = request_type.strip().lower()
if playlist_type in ['default', 'recents', 'fmchart']:
searched_playlist.type = playlist_type
searched_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_jwt
@no_locked_users
def user_route(auth: dict = None, user: 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 := request_json.get('username')) and 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 := request_json.get('locked')) and user.type == "admin":
logger.info(f'updating lock {user.username} / {locked}')
user.locked = locked
if (spotify_linked := request_json.get('spotify_linked')) and not spotify_linked:
logger.info(f'deauthing {user.username}')
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']
if user.lastfm_username is None:
user.lastfm_username = ""
if apns_token := request_json.get('apns_token'):
if user.apns_tokens is None:
user.apns_tokens = []
if apns_token not in user.apns_tokens:
logger.info(f'adding apns token {user.username} -> {apns_token}')
user.apns_tokens = user.apns_tokens + [apns_token]
else:
logger.info(f'skipping duplicate apns token {user.username} -> {apns_token}')
if 'notify' in request_json:
notify = request_json['notify']
logger.info(f'updating notification settings for {user.username} -> {notify}')
user.notify = notify
if 'notify_playlist_updates' in request_json:
notify_playlist_updates = request_json['notify_playlist_updates']
logger.info(f'updating playlist update notification settings for {user.username} -> {notify_playlist_updates}')
user.notify_playlist_updates = notify_playlist_updates
if 'notify_tag_updates' in request_json:
notify_tag_updates = request_json['notify_tag_updates']
logger.info(f'updating playlist update notification settings for {user.username} -> {notify_tag_updates}')
user.notify_tag_updates = notify_tag_updates
if 'notify_admins' in request_json:
notify_admins = request_json['notify_admins']
logger.info(f'updating admin notification settings for {user.username} -> {notify_admins}')
user.notify_admins = notify_admins
user.update()
logger.info(f'updated {user.username}')
return jsonify({'message': 'account updated', 'status': 'succeeded'}), 200
@blueprint.route('/user', methods=['DELETE'])
@login_or_jwt
def user_delete_route(auth: dict = None, user: User = None):
assert user is not None
if user.type == 'admin' and (username_override := request.args.get('username')) is not None:
user = User.collection.filter('username', '==', username_override.strip().lower()).get()
User.collection.delete(user.key, child=True)
logger.info(f'user {user.username} deleted')
return jsonify({'message': 'account deleted', 'status': 'succeeded'}), 200
@blueprint.route('/users', methods=['GET'])
@login_or_jwt
@admin_required
@no_locked_users
def all_users_route(auth: dict = None, user: User = None):
return jsonify({
'accounts': [i.to_dict() for i in User.collection.fetch()]
}), 200
@blueprint.route('/user/password', methods=['POST'])
@login_required
@no_locked_users
@validate_json(('new_password', str), ('current_password', str))
def change_password(user: User = None):
request_json = request.get_json()
if len(new_password := request_json['new_password']) == 0:
return jsonify({"error": 'zero length password'}), 400
if len(new_password) > 30:
return jsonify({"error": 'password too long'}), 400
if user.check_password(request_json['current_password']):
user.password = generate_password_hash(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_jwt
@no_locked_users
@validate_args(('name', str))
def run_playlist(auth: dict = None, user: 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_jwt
@no_locked_users
def run_user(auth: dict = None, 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_jwt
@admin_required
@no_locked_users
def run_users(auth: dict = None, user: User = None):
update_all_user_playlists()
return jsonify({'message': 'executed all users', 'status': 'success'}), 200
@blueprint.route('/playlist/image', methods=['GET'])
@login_or_jwt
@spotify_link_required
@no_locked_users
@validate_args(('name', str))
def image(auth: dict = None, user: User = None):
_playlist = user.get_playlist(request.args['name'], raise_error=False)
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,276 +0,0 @@
import functools
import logging
from flask import session, request, jsonify, make_response
from music.model.user import User
from music.auth.jwt_keys import validate_key
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 is_jwt_authed():
if request.headers.get('Authorization', None):
unparsed = request.headers.get('Authorization')
if unparsed.startswith('Bearer '):
token = validate_key(unparsed.removeprefix('Bearer ').strip())
if token is not None:
return token
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_jwt(func):
@functools.wraps(func)
def login_or_jwt_wrapper(*args, **kwargs):
if is_logged_in():
user = User.collection.filter('username', '==', session['username'].strip().lower()).get()
return func(*args, user=user, **kwargs)
else:
token = is_jwt_authed()
if token is not None:
user = User.collection.filter('username', '==', token['sub'].strip().lower()).get()
if user is not None:
return func(*args, auth=token, user=user, **kwargs)
else:
logger.warning(f'user {token["sub"]} not found')
return jsonify({'error': f'user {token["sub"]} not found'}), 404
else:
logger.warning('user not authorised')
return jsonify({'error': 'not authorised'}), 401
return login_or_jwt_wrapper
def jwt_required(func):
@functools.wraps(func)
def jwt_required_wrapper(*args, **kwargs):
token = is_jwt_authed()
if token is not None:
user = User.collection.filter('username', '==', token['sub'].strip().lower()).get()
if user is not None:
return func(*args, auth=token, user=user, **kwargs)
else:
logger.warning(f'user {token["sub"]} not found')
return jsonify({'error': f'user {token["sub"]} not found'}), 404
else:
logger.warning('user not authorised')
return jsonify({'error': 'not authorised'}), 401
return jwt_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 no_locked_users(func):
@functools.wraps(func)
def no_locked_users_wrapper(*args, **kwargs):
db_user = kwargs.get('user')
if db_user is not None:
if not db_user.locked:
return func(*args, **kwargs)
else:
logger.warning('user locked')
return jsonify({'status': 'error', 'message': 'user locked'}), 401
else:
logger.warning('user not logged in')
return jsonify({'error': 'not logged in'}), 401
return no_locked_users_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)
def no_cache(func):
@functools.wraps(func)
def no_cache_wrapper(*args, **kwargs):
resp = func(*args, **kwargs)
if isinstance(resp, str):
resp = make_response(resp)
if isinstance(resp, tuple):
response = resp[0]
else:
response = resp
if response is not None:
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
response.headers['Cache-Control'] = 'public, max-age=0'
return resp
return no_cache_wrapper

View File

@ -1,26 +0,0 @@
from flask import Blueprint, jsonify
from datetime import date
import logging
from music.api.decorators import login_or_jwt, lastfm_username_required, no_locked_users
import music.db.database as database
blueprint = Blueprint('fm-api', __name__)
logger = logging.getLogger(__name__)
@blueprint.route('/today', methods=['GET'])
@login_or_jwt
@no_locked_users
@lastfm_username_required
def daily_scrobbles(auth=None, 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,123 +0,0 @@
from flask import Blueprint, request, jsonify
import logging
from music.api.decorators import login_or_jwt, spotify_link_required, validate_json, no_locked_users
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_jwt
@spotify_link_required
@no_locked_users
def play(auth=None, 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_jwt
@spotify_link_required
@no_locked_users
def next_track(auth=None, 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_jwt
@spotify_link_required
@no_locked_users
@validate_json(('state', bool))
def shuffle(auth=None, 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_jwt
@spotify_link_required
@no_locked_users
@validate_json(('volume', int))
def volume(auth=None, 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,167 +0,0 @@
from flask import Blueprint, jsonify, request
import logging
import json
import os
from music.api.decorators import admin_required, login_or_jwt, lastfm_username_required, \
spotify_link_required, cloud_task, validate_args, no_locked_users
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_jwt
@spotify_link_required
@lastfm_username_required
@no_locked_users
def count(auth=None, user=None):
playlist_name = request.args.get('playlist_name', None)
if uri := request.args.get('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_jwt
@spotify_link_required
@lastfm_username_required
@no_locked_users
@validate_args(('name', str))
def playlist_refresh(auth=None, 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_jwt
@admin_required
@no_locked_users
def run_users(auth=None, 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_jwt
@no_locked_users
def run_user(auth=None, 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,37 +0,0 @@
from flask import Blueprint, request, jsonify
import logging
from music.api.decorators import login_or_jwt, spotify_link_required, no_locked_users
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_jwt
@spotify_link_required
@no_locked_users
def sort(auth=None, 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,156 +0,0 @@
from flask import Blueprint, jsonify, request
import logging
import os
import json
from music.api.decorators import login_or_jwt, cloud_task, no_locked_users
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_jwt
@no_locked_users
def tags(auth=None, 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_jwt
@no_locked_users
def tag_route(tag_id, auth=None, 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 name := request_json.get('name'):
db_tag.name = name.strip()
if time_objects := request_json.get('time_objects') is not None:
db_tag.time_objects = time_objects
if tracks := request_json.get('tracks') is not None:
db_tag.tracks = [
{
'name': track['name'].strip(),
'artist': track['artist'].strip()
}
for track in tracks
if track.get('name') and track.get('artist')
]
if albums := request_json.get('albums') is not None:
db_tag.albums = [
{
'name': album['name'].strip(),
'artist': album['artist'].strip()
}
for album in albums
if album.get('name') and album.get('artist')
]
if artists := request_json.get('artists') is not None:
db_tag.artists = [
{
'name': artist['name'].strip()
}
for artist in 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_jwt
@no_locked_users
def tag_refresh(tag_id, auth=None, 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,4 +0,0 @@
"""Security related endpoints including login/logout and reset password
"""
from .auth import blueprint as auth_blueprint

View File

@ -1,294 +0,0 @@
from flask import Blueprint, session, flash, request, redirect, url_for, render_template, jsonify
from werkzeug.security import generate_password_hash
from music.model.user import User, get_admins
from music.model.config import Config
from music.auth.jwt_keys import generate_key
from music.api.decorators import no_cache
from music.notif.notifier import notify_admin_new_user
from music.magic_strings import SPOT_CLIENT_URI, SPOT_SECRET_URI, STATIC_BUCKET
from urllib.parse import urlencode, urlunparse
import datetime
from datetime import timedelta
from numbers import Number
import logging
from base64 import b64encode
from google.cloud import secretmanager
import requests
blueprint = Blueprint('authapi', __name__)
logger = logging.getLogger(__name__)
secret_client = secretmanager.SecretManagerServiceClient()
@blueprint.route('/login', methods=['GET', 'POST'])
@no_cache
def login():
"""Login route allowing retrieval of HTML page and submission of results
Returns:
HTTP Response: Home page redirect for GET, login request on POST
"""
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_redirect'))
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'])
@no_cache
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('/token', methods=['POST'])
@no_cache
def jwt_token():
"""Generate JWT
Returns:
HTTP Response: token request on POST
"""
request_json = request.get_json()
username = request_json.get('username', None)
password = request_json.get('password', None)
if username is None or password is None:
return jsonify({"message": 'username and password fields required', "status": "error"}), 400
user = User.collection.filter('username', '==', username.strip().lower()).get()
if user is None:
return jsonify({"message": 'user not found', "status": "error"}), 404
if user.check_password(password):
if user.locked:
logger.warning(f'locked account token attempt {username}')
return jsonify({"message": 'user locked', "status": "error"}), 403
user.last_keygen = datetime.datetime.utcnow()
user.update()
logger.info(f'generating token for {username}')
config = Config.collection.get("config/music-tools")
if isinstance(expiry := request_json.get('expiry', None), Number):
expiry = min(expiry, config.jwt_max_length)
else:
expiry = config.jwt_default_length
generated_token = generate_key(user, timeout=timedelta(seconds=expiry))
return jsonify({"token": generated_token, "status": "success"}), 200
else:
logger.warning(f'failed token attempt {username}')
return jsonify({"message": 'authentication failed', "status": "error"}), 401
@blueprint.route('/register', methods=['GET', 'POST'])
@no_cache
def register():
if 'username' in session:
return redirect(url_for('index'))
if request.method == 'GET':
return render_template('register.html', bucket=STATIC_BUCKET)
else:
api_user = False
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:
if (request_json := request.get_json()) is not None:
username = request_json.get('username', None)
password = request_json.get('password', None)
password_again = request_json.get('password_again', None)
api_user = True
if username is None or password is None or password_again is None:
logger.info(f'malformed register api request, {username}')
return jsonify({'status': 'error', 'message': 'malformed request'}), 400
else:
flash('malformed request')
return redirect('authapi.register')
username = username.lower()
if password != password_again:
if api_user:
return jsonify({'message': 'passwords didnt match', 'status': 'error'}), 400
else:
flash('password mismatch')
return redirect('authapi.register')
if username in [i.username for i in
User.collection.fetch()]:
if api_user:
return jsonify({'message': 'user already exists', 'status': 'error'}), 409
else:
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}')
for admin in get_admins():
notify_admin_new_user(admin, username)
if api_user:
return jsonify({'message': 'account created', 'status': 'succeeded'}), 201
else:
session['username'] = username
return redirect(url_for('authapi.auth'))
@blueprint.route('/spotify')
@no_cache
def auth():
if 'username' in session:
config = Config.collection.get("config/music-tools")
spot_client = secret_client.access_secret_version(request={"name": SPOT_CLIENT_URI})
params = urlencode(
{
'client_id': spot_client.payload.data.decode("UTF-8"),
'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': f'https://{config.spotify_callback}/auth/spotify/token'
}
)
return redirect(urlunparse(['https', 'accounts.spotify.com', 'authorize', '', params, '']))
return redirect(url_for('index'))
@blueprint.route('/spotify/token')
@no_cache
def token():
if 'username' in session:
code = request.args.get('code', None)
if code is None:
flash('authorization failed')
return redirect('app_route')
else:
spot_client = secret_client.access_secret_version(request={"name": SPOT_CLIENT_URI})
spot_secret = secret_client.access_secret_version(request={"name": SPOT_SECRET_URI})
config = Config.collection.get("config/music-tools")
idsecret = b64encode(
bytes(spot_client.payload.data.decode("UTF-8") + ':' + spot_secret.payload.data.decode("UTF-8"), "utf-8")
).decode("ascii")
headers = {'Authorization': 'Basic %s' % idsecret}
data = {
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': f'https://{config.spotify_callback}/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')
@no_cache
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,41 +0,0 @@
from datetime import timedelta, datetime, timezone
import jwt
from music.magic_strings import JWT_SECRET_URI
from music.model.user import User
from google.cloud import secretmanager
secret_client = secretmanager.SecretManagerServiceClient()
def get_jwt_secret_key() -> str:
return secret_client.access_secret_version(request={"name": JWT_SECRET_URI}).payload.data.decode("UTF-8")
def generate_key(user: User, timeout: datetime | timedelta = timedelta(minutes=60)) -> str:
if isinstance(timeout, timedelta):
exp = timeout + datetime.now(tz=timezone.utc)
else:
exp = timeout
payload = {
"exp": exp,
"iss": "mixonomer-api",
"sub": user.username
}
return jwt.encode(payload, get_jwt_secret_key(), algorithm="HS512")
def validate_key(key: str) -> dict:
try:
decoded = jwt.decode(key, get_jwt_secret_key(), algorithms=["HS512"], options={
"require": ["exp", "sub"]
})
return decoded
except jwt.exceptions.PyJWTError as e:
pass

View File

@ -1,52 +0,0 @@
"""Infrastucture code include handing off tasks to Cloud Tasks or Cloud Functions
"""
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,48 +0,0 @@
import logging
import os
from google.cloud import pubsub_v1
publisher = pubsub_v1.PublisherClient()
logger = logging.getLogger(__name__)
def update_tag(username: str, tag_id: str) -> None:
"""Queue serverless tag update for user
Args:
username (str): Subject username
tag_id (str): Subject tag ID
"""
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(f'projects/{os.environ["GOOGLE_CLOUD_PROJECT"]}/topics/update_tag', b'', tag_id=tag_id, username=username)
def run_user_playlist_function(username: str, playlist_name: str) -> None:
"""Queue serverless playlist update for user
Args:
username (str): Subject username
playlist_name (str): Subject tag ID
"""
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(f'projects/{os.environ["GOOGLE_CLOUD_PROJECT"]}/topics/run_user_playlist', b'', name=playlist_name, username=username)

View File

@ -1,291 +0,0 @@
"""Functions for creating GCP Cloud Tasks for long running operatings
"""
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(os.environ['GOOGLE_CLOUD_PROJECT'], '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(parent=task_path, task=task)
seconds_delay += 30
def update_playlists(username: str):
"""Refresh all playlists for given user, environment dependent
Args:
username (str): Subject user's username
"""
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: str, playlist_name: str, delay: int = 0):
"""Create tasks for a users given playlist
Args:
username (str): Subject user's username
playlist_name (str): Subject playlist name
delay (int, optional): Seconds to delay execution by. Defaults to 0.
"""
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(parent=task_path, task=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: str):
"""Refresh all playlist stats for given user, environment dependent
Args:
username (str): Subject user's username
"""
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: str, delay: int = 0):
"""Create user playlist stats refresh task
Args:
username (str): Subject user's username
delay (int, optional): Seconds to delay execution by. Defaults to 0.
"""
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(parent=task_path, task=task)
def refresh_playlist_task(username: str, playlist_name: str, delay: int = 0):
"""Create user playlist stats refresh tasks
Args:
username (str): Subject user's username
playlist_name (str): Subject playlist name
delay (int, optional): Seconds to delay execution by. Defaults to 0.
"""
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(parent=task_path, task=track_task)
tasker.create_task(parent=task_path, task=album_task)
tasker.create_task(parent=task_path, task=artist_task)
def update_all_user_tags():
"""Create user tag refresh task for 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(parent=task_path, task=task)
seconds_delay += 10

View File

@ -1,2 +0,0 @@
"""Database interfacing components aside from the music.model ORM layer
"""

View File

@ -1,112 +0,0 @@
from dataclasses import dataclass
import logging
from datetime import timedelta, datetime, timezone
from typing import Optional
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.magic_strings import SPOT_CLIENT_URI, SPOT_SECRET_URI, LASTFM_CLIENT_URI
from google.cloud import secretmanager
logger = logging.getLogger(__name__)
secret_client = secretmanager.SecretManagerServiceClient()
def refresh_token_database_callback(user: User) -> None:
"""Callback for handling when a spotframework network updates user credemtials
Used to store newly authenticated credentials
Args:
user (User): Subject 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: User) -> Optional[SpotifyNetwork]:
"""Get an authenticated spotframework network for a given user
Args:
user (User): Subject user to retrieve a network for
Returns:
Optional[SpotifyNetwork]: Authenticated spotframework network
"""
if user is not None:
if user.spotify_linked:
spot_client = secret_client.access_secret_version(request={"name": SPOT_CLIENT_URI})
spot_secret = secret_client.access_secret_version(request={"name": SPOT_SECRET_URI})
user_obj = DatabaseUser(client_id=spot_client.payload.data.decode("UTF-8"),
client_secret=spot_secret.payload.data.decode("UTF-8"),
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: User) -> Optional[FmNetwork]:
"""Get an authenticated fmframework network for a given user
Args:
user (User): Subject user to retrieve a network for
Returns:
Optional[FmNetwork]: Authenticated fmframework network
"""
if user is not None:
if user.lastfm_username:
lastfm_client = secret_client.access_secret_version(request={"name": LASTFM_CLIENT_URI})
return FmNetwork(username=user.lastfm_username, api_key=lastfm_client.payload.data.decode("UTF-8"))
else:
logger.error(f'{user.username} has no last.fm username')
else:
logger.error(f'no user provided')
@dataclass
class DatabaseUser(NetworkUser):
"""Adding Mixonomer username to spotframework network user"""
user_id: str = None

View File

@ -1,105 +0,0 @@
from music.model.user import User
from music.model.playlist import Playlist
import logging
from typing import List
from google.cloud.firestore import DocumentReference
logger = logging.getLogger(__name__)
class PartGenerator:
"""Resolve a playlists components from other referenced smart playlists
"""
def __init__(self, user: User = None, username: str = None):
"""Initialise with user to resolve for
Args:
user (User, optional): Subject user. Defaults to None.
username (str, optional): Subject username. Defaults to None.
Raises:
LookupError: No user returned when querying for username
NameError: No user provided
"""
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):
"""Reset internal state for resolved playlists
"""
self.queried_playlists = []
self.parts = []
def get_recursive_parts(self, name: str) -> List[str]:
"""Resolve and return a playlist's component Spotify playlist names
Args:
name (str): Subject smart playlist name
Returns:
List[str]: Resolved list of component playlists
"""
logger.info(f'getting part from {name} for {self.user.username}')
self.reset()
self.process_reference_by_name(name)
return list({i for i in self.parts})
def process_reference_by_name(self, name: str) -> None:
"""Resolve a smart playlist by name, recurses into process_reference_by_reference
Args:
name (str): Subject playlist 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: DocumentReference):
"""Recursive resolution function for walking a playlist's dependencies by DocumentReference
Args:
ref (DocumentReference): Subject Firestore document for resolving
"""
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,11 +0,0 @@
import os
project_id = os.environ.get('GOOGLE_CLOUD_PROJECT')
SPOT_CLIENT_URI = f"projects/{project_id}/secrets/spotify-client/versions/latest"
SPOT_SECRET_URI = f"projects/{project_id}/secrets/spotify-secret/versions/latest"
LASTFM_CLIENT_URI = f"projects/{project_id}/secrets/lastfm-client/versions/latest"
JWT_SECRET_URI = f"projects/{project_id}/secrets/jwt-secret/versions/latest"
COOKIE_SECRET_URI = f"projects/{project_id}/secrets/cookie-secret/versions/latest"
APNS_SIGN_URI = f"projects/{project_id}/secrets/apns-auth-sign-key/versions/1"
STATIC_BUCKET = f'{project_id}-static'

View File

@ -1,2 +0,0 @@
"""ORM layer containing the data model
"""

View File

@ -1,23 +0,0 @@
from fireo.models import Model
from fireo.fields import TextField, NumberField, IDField
class Config(Model):
"""Service-level config data structure for app keys and settings
"""
class Meta:
collection_name = 'config'
"""Set correct path in Firestore
"""
id = IDField()
spotify_callback = TextField()
apns_team_id = TextField()
apns_key_id = TextField()
playlist_cloud_operating_mode = TextField() # task, function
"""Determines whether playlist and tag update operations are done by Cloud Tasks or Functions
"""
jwt_max_length = NumberField()
jwt_default_length = NumberField()

View File

@ -1,98 +0,0 @@
from enum import Enum
from fireo.models import Model
from fireo.fields import TextField, BooleanField, DateTime, NumberField, ListField, IDField
class Sort(Enum):
default = 1
shuffle = 2
release_date = 3
class Playlist(Model):
"""Smart playlist
Args:
Model ([type]): [description]
Returns:
[type]: [description]
"""
class Meta:
collection_name = 'playlists'
id = IDField()
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)
include_spotify_owned = BooleanField(default=True)
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',
'include_spotify_owned',
'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,36 +0,0 @@
from fireo.models import Model
from fireo.fields import TextField, DateTime, NumberField, ListField, BooleanField, IDField
class Tag(Model):
class Meta:
collection_name = 'tags'
id = IDField()
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,112 +0,0 @@
import logging
from fireo.models import Model
from fireo.fields import TextField, BooleanField, DateTime, NumberField, ListField, IDField
from music.model.playlist import Playlist
from werkzeug.security import check_password_hash
logger = logging.getLogger(__name__)
class User(Model):
class Meta:
collection_name = 'spotify_users'
id = IDField()
username = TextField(required=True)
password = TextField(required=True)
email = TextField()
type = TextField(default="user")
last_login = DateTime()
last_keygen = 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()
apns_tokens = ListField(default=[])
notify = BooleanField(default=False)
notify_playlist_updates = BooleanField(default=False)
notify_tag_updates = BooleanField(default=False)
notify_admins = BooleanField(default=False)
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
def get_playlist(self, playlist_name: str, single_return=True, raise_error=True):
"""Get a user's playlist by name with smart case sensitivity
Will return an exact match if possible, otherwise will return the first case-insensitive match
Args:
playlist_name (str): Subject playlist name
single_return (bool, optional): Return the best match, otherwise return (<exact>, <all matches>). <exact> will be None if not found. Defaults to True.
raise_error (bool, optional): Raise a NameError if nothing found. Defaults to True.
Raises:
NameError: If no matching playlists found
Returns:
Optional[Playlist] or (<exact>, <all matches>): Found user's playlists
"""
smart_playlists = Playlist.collection.parent(self.key).fetch()
exact_match = None
matches = list()
for playlist in smart_playlists:
if playlist.name == playlist_name:
exact_match = playlist
if playlist.name.lower() == playlist_name.lower():
matches.append(playlist)
if len(matches) == 0:
# NO PLAYLIST FOUND
logger.critical(f'playlist not found {self.username} / {playlist_name}')
if raise_error:
raise NameError(f'Playlist {playlist_name} not found for {self.username}')
else:
return None
if single_return:
if exact_match:
return exact_match
else:
return matches[0]
else:
return exact_match, matches
def get_playlists(self):
"""Get all playlists for a user
Returns:
List[Playlist]: List of users playlists
"""
return Playlist.collection.parent(self.key).fetch()
def get_admins():
return User.collection.filter('type', '==', 'admin').fetch()

View File

@ -1,71 +0,0 @@
from flask import Flask, render_template, redirect, session, flash, url_for
from google.cloud import secretmanager
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.magic_strings import COOKIE_SECRET_URI, STATIC_BUCKET
logger = logging.getLogger(__name__)
secret_client = secretmanager.SecretManagerServiceClient()
def create_app():
"""Generate and retrieve a ready-to-run flask app
Returns:
Flask App: Mixonomer app
"""
app = Flask(__name__, static_folder=os.path.join(os.path.dirname(__file__), '..', 'build'), template_folder="templates")
app.secret_key = secret_client.access_secret_version(request={"name": COOKIE_SECRET_URI}).payload.data.decode("UTF-8")
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('/app/playlists')
else:
logged_in = False
return render_template('login.html', logged_in=logged_in, bucket=STATIC_BUCKET)
@app.route('/privacy')
def privacy():
return render_template('privacy.html', bucket=STATIC_BUCKET)
@app.route('/app')
def app_route_redirect():
if 'username' not in session:
flash('please log in')
return redirect(url_for('index'))
return redirect('/app/playlists')
@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', bucket=STATIC_BUCKET)
return app
# [END gae_python37_app]

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