Compare commits

..

131 Commits

Author SHA1 Message Date
58163f8070
updating py deps
All checks were successful
test and deploy / Build & Unit Test (push) Successful in 1m9s
test and deploy / Package & Push Container (push) Successful in 7m51s
2024-11-01 23:48:22 +00:00
6f73488c1d
replacing example images
All checks were successful
test and deploy / Build & Unit Test (push) Successful in 47s
test and deploy / Package & Push Container (push) Successful in 5m21s
2024-10-11 22:17:33 +01:00
17c1b311bd
matching selector ui styles 2024-10-11 22:11:05 +01:00
78c8c326ce
fixing test
All checks were successful
test and deploy / Build & Unit Test (push) Successful in 1m56s
test and deploy / Package & Push Container (push) Successful in 7m4s
2024-10-10 23:51:25 +01:00
03e9743abb
tightening frontend
Some checks failed
test and deploy / Build & Unit Test (push) Failing after 1m21s
test and deploy / Package & Push Container (push) Has been skipped
2024-10-10 23:45:22 +01:00
38c2e5e430
fiddling with poetry export, updating py deps
All checks were successful
test and deploy / Build & Unit Test (push) Successful in 54s
test and deploy / Package & Push Container (push) Successful in 51s
2024-07-20 13:56:47 +01:00
0d5255992d
pinning node version to fix error
All checks were successful
test and deploy / Build & Unit Test (push) Successful in 1m3s
test and deploy / Package & Push Container (push) Successful in 1m31s
2024-07-20 12:32:28 +01:00
efa58eecda
using npx, updating more deps
Some checks failed
test and deploy / Build & Unit Test (push) Failing after 37s
test and deploy / Package & Push Container (push) Has been skipped
2024-07-20 12:27:11 +01:00
0033b4055e
trying to fix ci
Some checks failed
test and deploy / Build & Unit Test (push) Failing after 2m3s
test and deploy / Package & Push Container (push) Has been skipped
2024-07-20 11:49:47 +01:00
95c00ae352
resyncing package-lock
Some checks failed
test and deploy / Build & Unit Test (push) Failing after 31s
test and deploy / Package & Push Container (push) Has been skipped
2024-07-20 11:38:09 +01:00
314e58551c
ci tweaking, updating deps
Some checks failed
test and deploy / Build & Unit Test (push) Failing after 37s
test and deploy / Package & Push Container (push) Has been skipped
2024-07-20 11:35:49 +01:00
1c6e7bfd66
adding gitea actions
All checks were successful
test and deploy / Build & Unit Test (push) Successful in 2m0s
test and deploy / Package & Push Container (push) Successful in 1m48s
2024-07-19 18:55:17 +01:00
ec69af1c06
updating pyenv 2024-05-11 07:18:47 +01:00
cfb8808e9a
updating deps 2024-02-18 11:31:17 +00:00
a1c113a73f
Update README.md 2024-02-09 18:57:04 +00:00
6df1b26ab8
resyncing npm packages 2023-10-14 10:27:09 +01:00
2ac7b43f5c
breaking down run user playlist task 2023-10-13 22:28:19 +01:00
75c26e78eb
removing next requirement 2023-10-13 20:30:39 +01:00
c115eebf62
restricting test to next branch 2023-10-13 19:44:22 +01:00
8c2f12a691
readding cryptography, updating deps 2023-10-13 19:43:51 +01:00
70117005d5
adding configurable spotify callback 2023-09-15 19:57:26 +01:00
2bb375d4da
moving dispatch deployment to after app engine 2023-09-08 18:12:00 +01:00
ce3a5d6d42
adding test deployment to ci 2023-09-08 17:48:50 +01:00
01d8715c2f
project specific static resources 2023-09-07 18:03:22 +01:00
d54b8317c9
deploying with project id as env var for functions 2023-09-06 20:07:14 +01:00
6a17775f07
only deploy uses prod env 2023-09-06 19:00:31 +01:00
1667e23d7a
fix run playlist func name 2023-09-05 23:32:31 +01:00
023cdf909f
variable google project id 2023-09-05 23:22:44 +01:00
07d5f42f11
limiting gunicorn version 2023-09-05 21:22:07 +01:00
227139a551
fixing service again 2023-09-05 21:04:02 +01:00
a8e8649c39
fixing service name 2023-09-05 20:52:21 +01:00
6c1d4b3348
loosening func frameworks, skipping dispatch deploy for now 2023-09-05 20:47:23 +01:00
a08bc2d839
changing service name 2023-09-05 20:14:48 +01:00
a3aabb3b88
setting project, adding environments to deployments 2023-09-05 20:05:58 +01:00
4a0c045b27
tweaking function entry, updating admin for gen2 adding func frameworks 2023-09-04 23:06:59 +01:00
2212bbb0c4
just build docker container in jenkins 2023-08-15 17:56:55 +01:00
99dc270b13
update dockerfile 2023-08-08 17:56:49 +01:00
077b8ea4e6
updating python version and all deps 2023-08-08 17:29:12 +01:00
d0b2bba7ca
slim images, building docker container from github actions 2023-05-16 18:30:00 +01:00
c88c8e9f26
using registry subdomain for packages 2023-05-14 19:07:44 +01:00
240c23ff06
skipping poetry install 2023-05-02 20:57:09 +01:00
c049b8ea95
adding jenkinsfile 2023-05-02 20:55:07 +01:00
2be33dc549
adding docker building 2023-05-02 08:58:34 +01:00
2ba7bcc654
updating deps 2023-05-02 08:03:04 +01:00
54b9adbc76
npm install to downgrade react 2023-01-06 21:51:11 +00:00
d993979a8c
updating node version 2023-01-06 21:48:30 +00:00
070ae5a5ae
udpating py deps 2023-01-06 21:42:59 +00:00
1efeefda1d
updating npm deps 2023-01-06 21:34:30 +00:00
64b8faaf1f
updating ci action and poetry versions 2022-12-20 21:33:52 +00:00
5d95dccb7a
fixing deps 2022-12-10 10:25:16 +00:00
fdab7b2bdd
updating deps 2022-12-10 08:44:27 +00:00
b8fea8a68e
fixed can't set notifications settings to false 2022-12-10 08:26:19 +00:00
7a907e918c
adding ios notifications 2022-12-09 08:37:05 +00:00
1ec929ce98
moving magic secret string uris 2022-11-29 22:46:53 +00:00
c7fe8fada5
migrate to secret manager, closes #45 2022-11-29 21:13:26 +00:00
de23eb0065
walrus refactors 2022-11-28 22:30:00 +00:00
c770ac5c7d
adding apns token catching 2022-11-28 18:34:29 +00:00
a23d797f90
linting 2022-11-27 23:36:50 +00:00
2ba341ae7f
adding about link to signature 2022-11-13 21:57:35 +00:00
384eceae05
adding branch instead of head ref for own deps 2022-11-11 07:10:51 +00:00
5c32b8eb19
Revert "stop deploying from admin script, straight from CI"
This reverts commit 237d07eab2.
2022-11-11 07:06:17 +00:00
237d07eab2
stop deploying from admin script, straight from CI 2022-11-10 22:13:50 +00:00
569c4c76dc
fixing dictionary usage, updating deps 2022-11-09 22:22:33 +00:00
3a1045d6ce
fixing a divide by 0, dict instead of list 2022-11-09 09:00:30 +00:00
25df6a3028
Merge pull request #43 from Sarsoo/dependabot/npm_and_yarn/loader-utils-1.4.1
Bump loader-utils from 1.4.0 to 1.4.1
2022-11-09 08:57:51 +00:00
cfef0f09da
Merge pull request #42 from Sarsoo/dependabot/pip/protobuf-3.20.2
Bump protobuf from 3.20.1 to 3.20.2
2022-11-09 08:57:38 +00:00
dependabot[bot]
cf50280dc6
Bump loader-utils from 1.4.0 to 1.4.1
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 1.4.0 to 1.4.1.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v1.4.1/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v1.4.0...v1.4.1)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-09 03:15:50 +00:00
dependabot[bot]
39c543e785
Bump protobuf from 3.20.1 to 3.20.2
Bumps [protobuf](https://github.com/protocolbuffers/protobuf) from 3.20.1 to 3.20.2.
- [Release notes](https://github.com/protocolbuffers/protobuf/releases)
- [Changelog](https://github.com/protocolbuffers/protobuf/blob/main/generate_changelog.py)
- [Commits](https://github.com/protocolbuffers/protobuf/compare/v3.20.1...v3.20.2)

---
updated-dependencies:
- dependency-name: protobuf
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-09-23 22:17:38 +00:00
ccc90761f7 adding readme.md 2022-08-26 19:28:26 +01:00
e410e73647 re-enabling sphinx docs without js 2022-08-26 19:28:17 +01:00
adea01d9be privacy and contact documentation 2022-08-16 23:19:24 +01:00
907020f8f6 adding add and remove single part/ref to post playlist 2022-08-16 23:19:08 +01:00
00092ce0b7 locked user rejection from most api endpoints 2022-08-16 18:00:38 +01:00
127544ddc1 fixing deprecations, changing spotify link 2022-08-14 19:33:01 +01:00
3fbeaa10d0 allow setting no last.fm username, closes #40 2022-08-14 19:32:24 +01:00
e35ee24b2d fixing Google Cloud task querying 2022-08-14 19:30:45 +01:00
419fd895d4 fixing register error, closes #41 2022-08-14 13:32:59 +01:00
f6d79e294e reduced logo size, closes #32 2022-08-12 12:02:19 +01:00
cd904ae0c9 fixing delete messages 2022-08-12 11:57:21 +01:00
978136bb4e adding privacy policy, closes #37 2022-08-12 11:21:31 +01:00
fefbef75eb adding account deletion and API register 2022-08-10 23:15:30 +01:00
06afff5e5d increasing wait before expiry 2022-08-09 18:09:05 +01:00
bc5d54202d
Merge pull request #34 from Sarsoo/jwt
migrating from basic to JWT auth
2022-08-09 18:03:52 +01:00
018926361e adding dispatch deploy to gcloud ci 2022-08-09 08:02:50 +01:00
4c9a17f219 replacing all basic auth with jwt, adding config and expiry time 2022-08-08 22:02:14 +01:00
ac6cc976eb proof of concept jwt auth 2022-08-08 18:37:17 +01:00
eb479274cd changed music link to mixonomer 2022-08-07 19:33:16 +01:00
0bad78a567
Merge pull request #31 from Sarsoo/rebrand
renaming music tools to mixonomer
2022-08-07 13:32:15 +01:00
649e77acb5 renaming music tools to mixonomer 2022-08-07 13:27:59 +01:00
0fe7d8f117 fixed new cloud tasks function call 2022-07-28 22:04:23 +01:00
2a38ed7ead rolling back py to 3.9 for deploying 2022-07-28 07:57:27 +01:00
435ac79a81 downgrading react 2022-07-28 07:02:05 +01:00
18b990937f updating py to 3.10, pulling docs 2022-07-28 07:00:27 +01:00
b5868fd69c rolling back poetry version 2022-07-27 18:00:07 +01:00
5828e4e584 rolling python version back to 3.8 2022-07-27 17:57:16 +01:00
0fceb82fdb python 3.10 not 3.1 2022-07-27 08:39:09 +01:00
4c0a06816a rollback react 2022-07-26 22:29:32 +01:00
c552bd2eb1 properly building python 2022-07-26 22:19:41 +01:00
7d1149a1d5 using gcloud auth, updating build versions 2022-07-26 22:17:39 +01:00
e767241c21 fixing sass error with calc,turning off justMyCode 2022-04-10 22:19:18 +01:00
de1f09456f changed setup gcloud actions pinning 2022-03-28 19:47:27 +01:00
cefe400f7a Merge branch 'master' of github.com:Sarsoo/Music-Tools 2022-03-28 19:44:47 +01:00
9257951569 updated npm packages 2022-03-28 19:44:19 +01:00
fb39c55b66
Merge pull request #28 from Sarsoo/dependabot/npm_and_yarn/minimist-1.2.6
Bump minimist from 1.2.5 to 1.2.6
2022-03-28 18:26:56 +00:00
dependabot[bot]
fa205521da
Bump minimist from 1.2.5 to 1.2.6
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-26 23:14:24 +00:00
9976143120
changing gcloud sdk version again 2022-03-24 10:16:53 +00:00
1bbe941824
updating gcloud sdk branch 2022-03-24 10:14:44 +00:00
9c58e99f51 reducing number of tasks run on pull request 2022-02-20 12:12:32 +00:00
andy
95e1d7279d
Merge pull request #27 from Sarsoo/dependabot/npm_and_yarn/nanoid-3.2.0 2022-02-13 22:21:09 +00:00
dependabot[bot]
de087d9111
Bump nanoid from 3.1.25 to 3.2.0
Bumps [nanoid](https://github.com/ai/nanoid) from 3.1.25 to 3.2.0.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.1.25...3.2.0)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-13 21:17:55 +00:00
cbe563551f
Merge pull request #26 from Sarsoo/dependabot/npm_and_yarn/follow-redirects-1.14.8
Bump follow-redirects from 1.14.4 to 1.14.8
2022-02-13 21:17:16 +00:00
dependabot[bot]
397c14d24d
Bump follow-redirects from 1.14.4 to 1.14.8
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.4 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.4...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-12 17:53:50 +00:00
a92b10b9d6 updated front and back end deps 2021-09-18 15:46:39 +01:00
90d1ce04d9 included spotify owned in mutable keys added to UI 2021-07-10 16:08:58 +01:00
365cafe004 added open in tag UI, added include_spotify_owned 2021-07-10 16:00:30 +01:00
79c841d9d8 centered loading circle, pastel theme 2021-07-05 23:31:13 +01:00
e51976dc1f update versions throughout 2021-07-04 22:34:08 +01:00
8c9896ba51 added description overrides
closes #25
closes #15
closes #9
2021-07-04 22:21:18 +01:00
b5730375d9 remove debugger, added admin script to docs, added launch.json 2021-06-19 12:54:24 +01:00
af293662db added cloud debugger 2021-06-18 22:54:52 +01:00
489b436c58 added smart case insensitivity for user playlist retrieval 2021-06-16 20:40:14 +01:00
e64d0e2cd8 added time bar chart, included all Js docs in sphinx 2021-06-12 13:53:57 +01:00
4fc4676041 updated chart.js, webpack. added js docs, added js to sphinx 2021-06-11 16:36:01 +01:00
a33d91dba4 reducing memory footprint for running playlists 2021-06-05 09:52:31 +01:00
ebc9a0cfa1 updated npm dependencies 2021-05-12 22:15:28 +01:00
c72762f616 updated dependencies 2021-04-04 19:43:32 +01:00
f259096b47 add .db changes 2021-03-27 00:03:04 +00:00
7ade5ccaab adding docstrings, restructured reST 2021-03-24 10:06:54 +00:00
0a18597165 added poetry command wrap, updated poetry lock 2021-03-23 22:38:06 +00:00
b5366b8f38 fixed bad name 2021-03-23 22:27:58 +00:00
fdbda8ef26 Beginning documentation 2021-03-23 22:26:59 +00:00
169 changed files with 17485 additions and 20500 deletions

3
.babelrc Normal file
View File

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

View File

@ -1,4 +0,0 @@
# 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

13
.dockerignore Normal file
View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

26
.gcloudignore Normal file
View File

@ -0,0 +1,26 @@
# 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

90
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,90 @@
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: .

333
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,333 @@
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 Normal file
View File

@ -0,0 +1,128 @@
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/

40
.jenkins/jenkinsfile Normal file
View File

@ -0,0 +1,40 @@
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 Normal file
View File

@ -0,0 +1,38 @@
{
// 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 Normal file
View File

@ -0,0 +1,13 @@
{
"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
View File

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

38
Dockerfile Normal file
View File

@ -0,0 +1,38 @@
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" ]

54
README.md Normal file
View File

@ -0,0 +1,54 @@
[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/).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

View File

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

View File

@ -1,701 +0,0 @@
@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;
}
}

View File

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

View File

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

View File

@ -1,156 +0,0 @@
/*
* 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

@ -1,14 +0,0 @@
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,
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 B

10881
_static/jquery-3.6.0.js vendored

File diff suppressed because it is too large Load Diff

2
_static/jquery.js vendored

File diff suppressed because one or more lines are too long

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 B

View File

@ -1,83 +0,0 @@
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 */

View File

@ -1,566 +0,0 @@
/*
* 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);

View File

@ -1,144 +0,0 @@
/* 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);

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

308
admin.py Executable file
View File

@ -0,0 +1,308 @@
#!/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()

15
app.yaml Normal file
View File

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

4
dispatch.test.yaml Normal file
View File

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

11
dispatch.yaml Normal file
View File

@ -0,0 +1,11 @@
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

20
docs/Makefile Normal file
View File

@ -0,0 +1,20 @@
# 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)

BIN
docs/PlaylistExample.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

BIN
docs/Playlists.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

BIN
docs/TagExample.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

File diff suppressed because one or more lines are too long

55
docs/conf.py Normal file
View File

@ -0,0 +1,55 @@
# 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']

5
docs/jsdoc.json Normal file
View File

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

35
docs/make.bat Normal file
View File

@ -0,0 +1,35 @@
@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

179
docs/paper.lyx Normal file
View File

@ -0,0 +1,179 @@
#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

31
docs/ref.bib Normal file
View File

@ -0,0 +1,31 @@
@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}
}

File diff suppressed because it is too large Load Diff

5
gunicorn.conf.py Normal file
View File

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

View File

@ -1,152 +0,0 @@
<!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>

6
main.api.py Normal file
View File

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

19
main.cron.py Normal file
View File

@ -0,0 +1,19 @@
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()

18
main.run_playlist.py Normal file
View File

@ -0,0 +1,18 @@
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')

19
main.update_tag.py Normal file
View File

@ -0,0 +1,19 @@
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')

50
music/__init__.py Normal file
View File

@ -0,0 +1,50 @@
"""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)

10
music/api/__init__.py Normal file
View File

@ -0,0 +1,10 @@
"""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

43
music/api/admin.py Normal file
View File

@ -0,0 +1,43 @@
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

397
music/api/api.py Normal file
View File

@ -0,0 +1,397 @@
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

276
music/api/decorators.py Normal file
View File

@ -0,0 +1,276 @@
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

26
music/api/fm.py Normal file
View File

@ -0,0 +1,26 @@
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

123
music/api/player.py Normal file
View File

@ -0,0 +1,123 @@
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

167
music/api/spotfm.py Normal file
View File

@ -0,0 +1,167 @@
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

37
music/api/spotify.py Normal file
View File

@ -0,0 +1,37 @@
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

156
music/api/tag.py Normal file
View File

@ -0,0 +1,156 @@
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

4
music/auth/__init__.py Normal file
View File

@ -0,0 +1,4 @@
"""Security related endpoints including login/logout and reset password
"""
from .auth import blueprint as auth_blueprint

294
music/auth/auth.py Normal file
View File

@ -0,0 +1,294 @@
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'))

41
music/auth/jwt_keys.py Normal file
View File

@ -0,0 +1,41 @@
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

52
music/cloud/__init__.py Normal file
View File

@ -0,0 +1,52 @@
"""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)

48
music/cloud/function.py Normal file
View File

@ -0,0 +1,48 @@
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)

291
music/cloud/tasks.py Normal file
View File

@ -0,0 +1,291 @@
"""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

2
music/db/__init__.py Normal file
View File

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

112
music/db/database.py Normal file
View File

@ -0,0 +1,112 @@
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

105
music/db/part_generator.py Normal file
View File

@ -0,0 +1,105 @@
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')

11
music/magic_strings.py Normal file
View File

@ -0,0 +1,11 @@
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'

2
music/model/__init__.py Normal file
View File

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

23
music/model/config.py Normal file
View File

@ -0,0 +1,23 @@
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()

98
music/model/playlist.py Normal file
View File

@ -0,0 +1,98 @@
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

36
music/model/tag.py Normal file
View File

@ -0,0 +1,36 @@
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

112
music/model/user.py Normal file
View File

@ -0,0 +1,112 @@
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()

71
music/music.py Normal file
View File

@ -0,0 +1,71 @@
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