Repository: sublime-music/sublime-music (language: python)

Issue:
Migrate to GitHub Actions



Discussion / hints:

Fix patch:
diff --git a/.builds/publish.yml b/.builds/publish.yml
deleted file mode 100644
index bf7e76dd..00000000
--- a/.builds/publish.yml
+++ /dev/null
@@ -1,38 +0,0 @@
-image: archlinux
-packages:
-  - dbus
-  - gobject-introspection
-  - gtk3
-  - mpv
-  - python-cairo
-  - python-gobject
-  - python-poetry
-  - xorg-server-xvfb
-sources:
-  - https://git.sr.ht/~sumner/sublime-music
-secrets:
-  # PyPi Deploy Credentials for Sublime Music
-  - b4190b11-fdf7-4cee-b939-ea9fce44fa12
-environment:
-  REPO_NAME: sublime-music
-triggers:
-  - action: email
-    condition: failure
-    to: ~sumner/sublime-music-devel@lists.sr.ht
-tasks:
-  - setup: |
-      cd ${REPO_NAME}
-      poetry install
-      echo "cd ${REPO_NAME}" >> ~/.buildenv
-      echo "source $(poetry env info -p)/bin/activate" >> ~/.buildenv
-
-  - build: |
-      poetry build
-
-  - test-deploy-pypi: |
-      poetry publish --dry-run
-
-  - deploy-pypi: |
-      ./cicd/tagged_with_version || echo "Skipping deploy since not tagged with version"
-      ./cicd/tagged_with_version || complete-build
-      poetry publish
diff --git a/.builds/readme.yml b/.builds/readme.yml
deleted file mode 100644
index 8bddd179..00000000
--- a/.builds/readme.yml
+++ /dev/null
@@ -1,38 +0,0 @@
-image: alpine/edge
-packages:
-  - curl
-  - git
-  - py3-pip
-  - python3
-sources:
-  - https://git.sr.ht/~sumner/sublime-music
-secrets:
-  # README Personal Access Token
-  - 2fb5fd72-fa96-46c6-ab90-6b7cabebba16
-environment:
-  REPO_NAME: sublime-music
-triggers:
-  - action: email
-    condition: failure
-    to: ~sumner/sublime-music-devel@lists.sr.ht
-tasks:
-  # Skip everything if not on master.
-  - skip_not_master: |
-      cd $REPO_NAME
-      git branch --contains | grep master || echo "Skipping README deploy since not on master"
-      git branch --contains | grep master || complete-build
-
-  - setup: |
-      pip3 install rst2html5
-      echo "cd ${REPO_NAME}" >> ~/.buildenv
-      echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.buildenv
-
-  - readme: |
-      set +x
-      set -e
-      rst2html5 --template="{body}" README.rst |                            \
-        curl -H "Content-Type: text/html"                                   \
-             -H "Authorization: Bearer $(cat ~/.readme-token)"              \
-             -XPUT                                                          \
-             --data-binary @-                                               \
-             "https://git.sr.ht/api/repos/${REPO_NAME}/readme"
diff --git a/.editorconfig b/.editorconfig
index 4179ddc1..2f28891c 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -12,7 +12,7 @@ indent_style = space
 indent_size = 4
 
 [*.py]
-max_line_length = 88
+max_line_length = 99
 
 [Makefile]
 indent_style = tab
diff --git a/.envrc b/.envrc
index ab0df0eb..40f8cfb2 100644
--- a/.envrc
+++ b/.envrc
@@ -3,10 +3,10 @@ use nix
 # Activate the virtualenv
 source .venv/bin/activate
 
-watch_file pyproject.toml
-watch_file poetry.lock
+watch_file requirements.txt
+watch_file dev-requirements.txt
 watch_file shell.nix
 
-set POETRY_VIRTUALENVS_IN_PROJECT
+pip install -r requirements.txt -r dev-requirements.txt
 
 unset PS1
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 5ace4600..cbd920f6 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -4,3 +4,7 @@ updates:
     directory: "/"
     schedule:
       interval: "weekly"
+  - package-ecosystem: "pip"
+    directory: "/"
+    schedule:
+      interval: "weekly"
diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml
new file mode 100644
index 00000000..9d1babd6
--- /dev/null
+++ b/.github/workflows/deploy.yaml
@@ -0,0 +1,112 @@
+name: Lint and Build
+
+on:
+  pull_request:
+    branches: [master]
+  push:
+  release:
+    types:
+      - created
+
+env:
+  PYTHON_VERSION: "3.10"
+
+jobs:
+  lint:
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python ${{ env.PYTHON_VERSION }}
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ env.PYTHON_VERSION }}
+
+      - name: Install dependencies
+        run: |
+          sudo apt install -y \
+            libgirepository1.0-dev \
+            libgtk-3-dev \
+            libmpv-dev
+          pip install -r all-requirements.txt
+
+      - name: Run pre-commit
+        uses: pre-commit/action@v3.0.0
+
+      - if: always()
+        run: black --check .
+
+      - if: always()
+        run: flake8
+
+      - if: always()
+        run: mypy sublime_music tests/**/*.py
+
+      - name: custom style check
+        if: always()
+        run: ./cicd/custom_style_check.py
+
+  test:
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python ${{ env.PYTHON_VERSION }}
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ env.PYTHON_VERSION }}
+
+      - name: Install dependencies
+        run: |
+          sudo apt install -y \
+            libgirepository1.0-dev \
+            libgtk-3-dev \
+            libmpv-dev
+          pip install -r all-requirements.txt
+
+      - run: pytest
+
+  build:
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python ${{ env.PYTHON_VERSION }}
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ env.PYTHON_VERSION }}
+
+      - name: Install dependencies
+        run: |
+          sudo apt install -y \
+            libgirepository1.0-dev \
+            libgtk-3-dev \
+            libmpv-dev
+          pip install -r dev-requirements.txt
+
+      - run: flit build
+
+  publish:
+    name: Deploy to PyPi
+    runs-on: ubuntu-latest
+    if: ${{ github.event_name == 'release' && github.event.action == 'created' }}
+
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set up Python ${{ env.PYTHON_VERSION }}
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ env.PYTHON_VERSION }}
+      - name: Run image
+        uses: abatilo/actions-poetry@v2.2.0
+
+      - name: Install dependencies
+        run: |
+          sudo apt install -y \
+            libgirepository1.0-dev \
+            libgtk-3-dev \
+            libmpv-dev
+          pip install -r all-requirements.txt
+
+      - run: flit build
+      # - run: flit publish
diff --git a/.github/workflows/pages.yaml b/.github/workflows/pages.yaml
new file mode 100644
index 00000000..2082b64f
--- /dev/null
+++ b/.github/workflows/pages.yaml
@@ -0,0 +1,58 @@
+name: Documentation
+
+on:
+  pull_request:
+    branches: [master]
+  push:
+
+jobs:
+  build-docs:
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-python@v4
+        with:
+          python-version: '3.10'
+
+      - name: Install dependencies
+        run: |
+          sudo apt install -y \
+            libgirepository1.0-dev \
+            libgtk-3-dev \
+            libmpv-dev
+          pip install -r dev-requirements.txt
+
+      - name: Install sublime_music
+        run: pip install -e .
+
+      - name: Build documentation
+        run: cd docs && make html
+
+      - name: Upload GitHub Pages artifact
+        uses: actions/upload-pages-artifact@v1.0.7
+        with:
+          path: docs/_build/html
+
+  deploy-docs:
+    # Add a dependency to the build job
+    needs: build-docs
+
+    if: github.ref == 'refs/heads/master'
+
+    # Grant GITHUB_TOKEN the permissions required to make a Pages deployment
+    permissions:
+      pages: write      # to deploy to Pages
+      id-token: write   # to verify the deployment originates from an appropriate source
+
+    # Deploy to the github-pages environment
+    environment:
+      name: github-pages
+      url: ${{ steps.deployment.outputs.page_url }}
+
+    # Specify runner + deployment step
+    runs-on: ubuntu-latest
+    steps:
+      - name: Deploy to GitHub Pages
+        id: deployment
+        uses: actions/deploy-pages@v1
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 00000000..afcaa3b1
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,61 @@
+repos:
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v4.1.0
+    hooks:
+      - id: trailing-whitespace
+        exclude_types: [markdown]
+      - id: end-of-file-fixer
+      - id: check-yaml
+      - id: check-added-large-files
+
+  # black
+  - repo: local
+    hooks:
+      - id: black
+        name: black
+        files: ^(sublime_music|tests)/.*\.py$
+        entry: black --check
+        language: system
+
+  # isort
+  - repo: https://github.com/PyCQA/isort
+    rev: 5.10.1
+    hooks:
+      - id: isort
+        files: ^(sublime_music|tests)/.*\.py$
+
+  # flake8
+  - repo: https://github.com/pycqa/flake8
+    rev: 4.0.1
+    hooks:
+      - id: flake8
+        files: ^(sublime_music|tests)/.*\.py$
+        additional_dependencies:
+          - flake8-annotations
+          - flake8-bugbear
+          - flake8-comprehensions
+          - flake8-pep3101
+          - flake8-print
+
+  # mypy
+  - repo: https://github.com/pre-commit/mirrors-mypy
+    rev: v0.991
+    hooks:
+      - id: mypy
+        files: ^(sublime_music|tests)/.*\.py$
+        additional_dependencies:
+          - types-bleach
+          - types-peewee
+          - types-python-dateutil
+          - types-pyyaml
+          - types-requests
+          - types-tabulate
+          - types-termcolor
+
+  # custom style checks
+  - repo: local
+    hooks:
+      - id: custom-style-check
+        name: custom style check
+        entry: ./cicd/custom_style_check.py
+        language: system
diff --git a/.vim/coc-settings.json b/.vim/coc-settings.json
index e04bd09b..06cfb32e 100644
--- a/.vim/coc-settings.json
+++ b/.vim/coc-settings.json
@@ -5,5 +5,17 @@
   "python.linting.enabled": true,
   "python.linting.mypyEnabled": true,
   "python.formatting.provider": "black",
-  "python.pythonPath": "/home/sumner/projects/sublime-music/sublime-music/.venv/bin/python"
+  "yaml.schemas": {
+    "https://json.schemastore.org/github-workflow.json": [
+      ".github/workflows/deploy.yaml",
+      ".github/workflows/pages.yaml"
+    ],
+    "https://json.schemastore.org/dependabot-2.0.json": [
+      ".github/dependabot.yml"
+    ],
+    "https://json.schemastore.org/github-funding.json": [".github/FUNDING.yml"],
+    "https://json.schemastore.org/pre-commit-config.json": [
+      ".pre-commit-config.yaml"
+    ]
+  }
 }
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index 945d5fa9..729f31df 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -3,4 +3,4 @@
       "dangmai.workspace-default-settings",
       "ms-python.python"
     ]
-  } 
+  }
diff --git a/.vscode/settings.default.json b/.vscode/settings.default.json
index 3509391b..074b44b5 100644
--- a/.vscode/settings.default.json
+++ b/.vscode/settings.default.json
@@ -3,4 +3,4 @@
     "python.linting.flake8Enabled": true,
     "python.linting.mypy": true,
     "python.linting.enabled": true
-}
\ No newline at end of file
+}
diff --git a/README.rst b/README.rst
index fc221e79..64cba8b0 100644
--- a/README.rst
+++ b/README.rst
@@ -5,6 +5,14 @@ Sublime Music is a native, GTK3
 `Subsonic`_/`Airsonic`_/`Revel`_/`Gonic`_/`Navidrome`_/`Ampache`_/\*sonic client for the
 Linux Desktop.
 
+CI
+  .. image:: https://github.com/sublime-music/sublime-music/actions/workflows/deploy.yaml/badge.svg?branch=master
+     :target: https://github.com/sublime-music/sublime-music/actions/workflows/deploy.yaml
+     :alt: Lint and Build
+  .. image:: https://github.com/sublime-music/sublime-music/actions/workflows/pages.yaml/badge.svg?branch=master
+     :target: https://github.com/sublime-music/sublime-music/actions/workflows/pages.yaml
+     :alt: GitHub Pages
+
 Community
   .. image:: https://img.shields.io/matrix/sublime-music:matrix.org?logo=matrix
      :target: https://matrix.to/#/#sublime-music:nevarro.space?via=matrix.org
@@ -16,6 +24,12 @@ Community
      :target: https://liberapay.com/sumner/donate
      :alt: liberapay donate
 
+Code
+  .. image:: https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336
+     :target: https://pycqa.github.io/isort/
+  .. image:: https://img.shields.io/badge/code%20style-black-000000.svg
+     :target: https://github.com/psf/black
+
 Packages
   .. image:: https://repology.org/badge/vertical-allrepos/sublime-music.svg?columns=3
      :target: https://repology.org/project/sublime-music/versions
diff --git a/all-requirements.txt b/all-requirements.txt
new file mode 100644
index 00000000..c10d5f4e
--- /dev/null
+++ b/all-requirements.txt
@@ -0,0 +1,286 @@
+#
+# This file is autogenerated by pip-compile with Python 3.10
+# by the following command:
+#
+#    pip-compile --all-extras --output-file=all-requirements.txt pyproject.toml
+#
+alabaster==0.7.12
+    # via sphinx
+async-timeout==4.0.2
+    # via zeroconf
+attrs==22.2.0
+    # via
+    #   flake8-annotations
+    #   flake8-bugbear
+    #   pytest
+babel==2.11.0
+    # via sphinx
+black==22.12.0
+    # via sublime_music (pyproject.toml)
+bleach==5.0.1
+    # via sublime_music (pyproject.toml)
+bottle==0.12.23
+    # via sublime_music (pyproject.toml)
+build==0.9.0
+    # via pip-tools
+casttube==0.2.1
+    # via pychromecast
+certifi==2022.12.7
+    # via requests
+cffi==1.15.1
+    # via cryptography
+cfgv==3.3.1
+    # via pre-commit
+charset-normalizer==2.1.1
+    # via requests
+click==8.1.3
+    # via
+    #   black
+    #   pip-tools
+coverage[toml]==7.0.2
+    # via pytest-cov
+cryptography==39.0.0
+    # via secretstorage
+dataclasses-json==0.5.7
+    # via sublime_music (pyproject.toml)
+deepdiff==6.2.2
+    # via sublime_music (pyproject.toml)
+distlib==0.3.6
+    # via virtualenv
+docutils==0.16
+    # via
+    #   flit
+    #   rst2html5
+    #   sphinx
+    #   sphinx-rtd-theme
+    #   sublime_music (pyproject.toml)
+exceptiongroup==1.1.0
+    # via pytest
+filelock==3.9.0
+    # via virtualenv
+flake8==6.0.0
+    # via
+    #   flake8-annotations
+    #   flake8-bugbear
+    #   flake8-comprehensions
+    #   flake8-pep3101
+    #   flake8-print
+    #   sublime_music (pyproject.toml)
+flake8-annotations==2.9.1
+    # via sublime_music (pyproject.toml)
+flake8-bugbear==22.12.6
+    # via sublime_music (pyproject.toml)
+flake8-comprehensions==3.10.1
+    # via sublime_music (pyproject.toml)
+flake8-pep3101==2.0.0
+    # via sublime_music (pyproject.toml)
+flake8-print==5.0.0
+    # via sublime_music (pyproject.toml)
+flit==3.8.0
+    # via sublime_music (pyproject.toml)
+flit-core==3.8.0
+    # via flit
+genshi==0.7.7
+    # via rst2html5
+identify==2.5.11
+    # via pre-commit
+idna==3.4
+    # via requests
+ifaddr==0.2.0
+    # via zeroconf
+imagesize==1.4.1
+    # via sphinx
+importlib-metadata==6.0.0
+    # via keyring
+iniconfig==1.1.1
+    # via pytest
+isort==5.11.4
+    # via sublime_music (pyproject.toml)
+jaraco-classes==3.2.3
+    # via keyring
+jeepney==0.8.0
+    # via
+    #   keyring
+    #   secretstorage
+jinja2==3.1.2
+    # via sphinx
+keyring==23.13.1
+    # via sublime_music (pyproject.toml)
+levenshtein==0.20.9
+    # via python-levenshtein
+markupsafe==2.1.1
+    # via jinja2
+marshmallow==3.19.0
+    # via
+    #   dataclasses-json
+    #   marshmallow-enum
+marshmallow-enum==1.5.1
+    # via dataclasses-json
+mccabe==0.7.0
+    # via flake8
+more-itertools==9.0.0
+    # via jaraco-classes
+mypy==0.991
+    # via sublime_music (pyproject.toml)
+mypy-extensions==0.4.3
+    # via
+    #   black
+    #   mypy
+    #   typing-inspect
+nodeenv==1.7.0
+    # via pre-commit
+ordered-set==4.1.0
+    # via deepdiff
+packaging==22.0
+    # via
+    #   build
+    #   marshmallow
+    #   pytest
+    #   sphinx
+pathspec==0.10.3
+    # via black
+peewee==3.15.4
+    # via sublime_music (pyproject.toml)
+pep517==0.13.0
+    # via build
+pip-tools==6.12.1
+    # via sublime_music (pyproject.toml)
+platformdirs==2.6.2
+    # via
+    #   black
+    #   virtualenv
+pluggy==1.0.0
+    # via pytest
+pre-commit==2.21.0
+    # via sublime_music (pyproject.toml)
+protobuf==4.21.12
+    # via pychromecast
+pycairo==1.23.0
+    # via pygobject
+pychromecast==13.0.4
+    # via sublime_music (pyproject.toml)
+pycodestyle==2.10.0
+    # via
+    #   flake8
+    #   flake8-print
+pycparser==2.21
+    # via cffi
+pyflakes==3.0.1
+    # via flake8
+pygments==2.14.0
+    # via
+    #   rst2html5
+    #   sphinx
+pygobject==3.42.2
+    # via sublime_music (pyproject.toml)
+pytest==7.2.0
+    # via
+    #   pytest-cov
+    #   sublime_music (pyproject.toml)
+pytest-cov==4.0.0
+    # via sublime_music (pyproject.toml)
+python-dateutil==2.8.2
+    # via sublime_music (pyproject.toml)
+python-levenshtein==0.20.9
+    # via sublime_music (pyproject.toml)
+python-mpv==1.0.1
+    # via sublime_music (pyproject.toml)
+pytz==2022.7
+    # via babel
+pyyaml==6.0
+    # via pre-commit
+rapidfuzz==2.13.7
+    # via levenshtein
+requests==2.28.1
+    # via
+    #   casttube
+    #   flit
+    #   sphinx
+    #   sublime_music (pyproject.toml)
+requirements-parser==0.5.0
+    # via sublime_music (pyproject.toml)
+rst2html5==2.0
+    # via sublime_music (pyproject.toml)
+secretstorage==3.3.3
+    # via keyring
+semver==2.13.0
+    # via sublime_music (pyproject.toml)
+six==1.16.0
+    # via
+    #   bleach
+    #   genshi
+    #   python-dateutil
+snowballstemmer==2.2.0
+    # via sphinx
+sphinx==4.5.0
+    # via
+    #   sphinx-rtd-theme
+    #   sublime_music (pyproject.toml)
+sphinx-rtd-theme==1.1.1
+    # via sublime_music (pyproject.toml)
+sphinxcontrib-applehelp==1.0.2
+    # via sphinx
+sphinxcontrib-devhelp==1.0.2
+    # via sphinx
+sphinxcontrib-htmlhelp==2.0.0
+    # via sphinx
+sphinxcontrib-jsmath==1.0.1
+    # via sphinx
+sphinxcontrib-qthelp==1.0.3
+    # via sphinx
+sphinxcontrib-serializinghtml==1.1.5
+    # via sphinx
+termcolor==2.1.1
+    # via sublime_music (pyproject.toml)
+thefuzz @ git+https://github.com/seatgeek/thefuzz@6e68af84e086b3e5f7253d4f9b0d6c7313e34637
+    # via sublime_music (pyproject.toml)
+tomli==2.0.1
+    # via
+    #   black
+    #   build
+    #   coverage
+    #   mypy
+    #   pep517
+    #   pytest
+tomli-w==1.0.0
+    # via flit
+types-bleach==5.0.3.1
+    # via sublime_music (pyproject.toml)
+types-peewee==3.15.0.5
+    # via sublime_music (pyproject.toml)
+types-python-dateutil==2.8.19.5
+    # via sublime_music (pyproject.toml)
+types-pyyaml==6.0.12.2
+    # via sublime_music (pyproject.toml)
+types-requests==2.28.11.7
+    # via sublime_music (pyproject.toml)
+types-setuptools==65.6.0.2
+    # via requirements-parser
+types-tabulate==0.9.0.0
+    # via sublime_music (pyproject.toml)
+types-termcolor==1.1.6
+    # via sublime_music (pyproject.toml)
+types-urllib3==1.26.25.4
+    # via types-requests
+typing-extensions==4.4.0
+    # via
+    #   mypy
+    #   typing-inspect
+typing-inspect==0.8.0
+    # via dataclasses-json
+urllib3==1.26.13
+    # via requests
+virtualenv==20.17.1
+    # via pre-commit
+webencodings==0.5.1
+    # via bleach
+wheel==0.38.4
+    # via pip-tools
+zeroconf==0.47.1
+    # via pychromecast
+zipp==3.11.0
+    # via importlib-metadata
+
+# The following packages are considered to be unsafe in a requirements file:
+# pip
+# setuptools
diff --git a/cicd/create-pypirc.sh b/cicd/create-pypirc.sh
index 36f1be8f..ac1d5951 100755
--- a/cicd/create-pypirc.sh
+++ b/cicd/create-pypirc.sh
@@ -11,4 +11,3 @@ echo "[pypi_test]" >> ~/.pypirc
 echo "repository = https://test.pypi.org/legacy/" >> ~/.pypirc
 echo "username = __token__" >> ~/.pypirc
 echo "password = ${PYPI_TEST_TOKEN}" >> ~/.pypirc
-
diff --git a/cicd/custom_style_check.py b/cicd/custom_style_check.py
index 3e50aad8..49afc2a0 100755
--- a/cicd/custom_style_check.py
+++ b/cicd/custom_style_check.py
@@ -33,13 +33,12 @@ def eprint(*strings):
 
 
 def check_file(path: Path) -> bool:
-    print(f"Checking {path.absolute()}...")  # noqa: T001
     file = path.open()
     valid = True
 
     for i, line in enumerate(file, start=1):
         if todo_re.match(line) and not accounted_for_todo.match(line):
-            eprint(f"{i}: {line}")
+            eprint(f"{path.absolute()}:{i}: {line}")
             valid = False
 
     file.close()
@@ -64,14 +63,6 @@ def check_file(path: Path) -> bool:
     else:  # nobreak
         raise AssertionError("No version in sublime_music/__init__.py")
 
-with open(Path("pyproject.toml")) as f:
-    for line in f:
-        if line.startswith("version ="):
-            assert eval(line.split()[-1]) == version, "Version mismatch: pyproject.toml"
-            break
-    else:  # nobreak
-        raise AssertionError("No version in pyproject.toml")
-
 with open(Path("CHANGELOG.rst")) as f:
     assert f.readline().strip() == f"v{version}", "Version mismatch: CHANGELOG"
 
diff --git a/cicd/install-project-deps.sh b/cicd/install-project-deps.sh
index 882dd2b3..0377da33 100755
--- a/cicd/install-project-deps.sh
+++ b/cicd/install-project-deps.sh
@@ -1,13 +1,48 @@
-#! /usr/bin/env sh
+#! /usr/bin/env bash
+
+set -xe
+
+export TZ=America/Denver DEBIAN_FRONTEND=noninteractive
+
+sudo apt update
+sudo apt install -y \
+    build-essential \
+    curl \
+    dbus \
+    gir1.2-nm-1.0 \
+    gir1.2-notify-0.7 \
+    git \
+    libbz2-dev \
+    libcairo2-dev \
+    libffi-dev \
+    libgirepository1.0-dev \
+    libglib2.0-dev \
+    libgtk-3-dev \
+    liblzma-dev \
+    libmpv-dev \
+    libncurses5-dev \
+    libncursesw5-dev \
+    libreadline-dev \
+    libsqlite3-dev \
+    libssl-dev \
+    libyaml-dev \
+    llvm \
+    make \
+    pkg-config \
+    python3-openssl \
+    python3-pip \
+    python3-venv \
+    tk-dev \
+    wget \
+    xvfb \
+    xz-utils \
+    zlib1g-dev
+
+# Install pyenv
+pushd /usr/local/src
+curl -L https://github.com/pyenv/pyenv-installer/raw/master/bin/pyenv-installer | bash
+popd
 
 export PYENV_ROOT="${HOME}/.pyenv"
 export PATH="${PYENV_ROOT}/bin:$PATH"
 eval "$(pyenv init -)"
-
-apt-get install -y python3-venv
-pip3 install poetry
-
-mkdir -p ~/.config/pypoetry/
-echo "[virtualenvs]" > ~/.config/pypoetry/config.toml
-echo "in-project = true" >> ~/.config/pypoetry/config.toml
-poetry install
diff --git a/cicd/pages-build.sh b/cicd/pages-build.sh
deleted file mode 100755
index a063b88c..00000000
--- a/cicd/pages-build.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#! /usr/bin/env bash
-
-set -xe
-
-pushd docs
-poetry run make html
-popd
-
-mv docs/_build/html public
-mv htmlcov public
diff --git a/dev-requirements.txt b/dev-requirements.txt
new file mode 100644
index 00000000..8b248d25
--- /dev/null
+++ b/dev-requirements.txt
@@ -0,0 +1,248 @@
+#
+# This file is autogenerated by pip-compile with Python 3.10
+# by the following command:
+#
+#    pip-compile --extra=dev --extra=doc --extra=test --output-file=dev-requirements.txt pyproject.toml
+#
+alabaster==0.7.12
+    # via sphinx
+attrs==22.2.0
+    # via
+    #   flake8-annotations
+    #   flake8-bugbear
+    #   pytest
+babel==2.11.0
+    # via sphinx
+black==22.12.0
+    # via sublime_music (pyproject.toml)
+bleach==5.0.1
+    # via sublime_music (pyproject.toml)
+build==0.9.0
+    # via pip-tools
+certifi==2022.12.7
+    # via requests
+cfgv==3.3.1
+    # via pre-commit
+charset-normalizer==2.1.1
+    # via requests
+click==8.1.3
+    # via
+    #   black
+    #   pip-tools
+coverage[toml]==7.0.1
+    # via pytest-cov
+dataclasses-json==0.5.7
+    # via sublime_music (pyproject.toml)
+deepdiff==6.2.2
+    # via sublime_music (pyproject.toml)
+distlib==0.3.6
+    # via virtualenv
+docutils==0.16
+    # via
+    #   flit
+    #   rst2html5
+    #   sphinx
+    #   sphinx-rtd-theme
+    #   sublime_music (pyproject.toml)
+exceptiongroup==1.1.0
+    # via pytest
+filelock==3.9.0
+    # via virtualenv
+flake8==6.0.0
+    # via
+    #   flake8-annotations
+    #   flake8-bugbear
+    #   flake8-comprehensions
+    #   flake8-pep3101
+    #   flake8-print
+    #   sublime_music (pyproject.toml)
+flake8-annotations==2.9.1
+    # via sublime_music (pyproject.toml)
+flake8-bugbear==22.12.6
+    # via sublime_music (pyproject.toml)
+flake8-comprehensions==3.10.1
+    # via sublime_music (pyproject.toml)
+flake8-pep3101==2.0.0
+    # via sublime_music (pyproject.toml)
+flake8-print==5.0.0
+    # via sublime_music (pyproject.toml)
+flit==3.8.0
+    # via sublime_music (pyproject.toml)
+flit-core==3.8.0
+    # via flit
+genshi==0.7.7
+    # via rst2html5
+identify==2.5.11
+    # via pre-commit
+idna==3.4
+    # via requests
+imagesize==1.4.1
+    # via sphinx
+iniconfig==1.1.1
+    # via pytest
+isort==5.11.4
+    # via sublime_music (pyproject.toml)
+jinja2==3.1.2
+    # via sphinx
+levenshtein==0.20.9
+    # via python-levenshtein
+markupsafe==2.1.1
+    # via jinja2
+marshmallow==3.19.0
+    # via
+    #   dataclasses-json
+    #   marshmallow-enum
+marshmallow-enum==1.5.1
+    # via dataclasses-json
+mccabe==0.7.0
+    # via flake8
+mypy==0.991
+    # via sublime_music (pyproject.toml)
+mypy-extensions==0.4.3
+    # via
+    #   black
+    #   mypy
+    #   typing-inspect
+nodeenv==1.7.0
+    # via pre-commit
+ordered-set==4.1.0
+    # via deepdiff
+packaging==22.0
+    # via
+    #   build
+    #   marshmallow
+    #   pytest
+    #   sphinx
+pathspec==0.10.3
+    # via black
+peewee==3.15.4
+    # via sublime_music (pyproject.toml)
+pep517==0.13.0
+    # via build
+pip-tools==6.12.1
+    # via sublime_music (pyproject.toml)
+platformdirs==2.6.2
+    # via
+    #   black
+    #   virtualenv
+pluggy==1.0.0
+    # via pytest
+pre-commit==2.21.0
+    # via sublime_music (pyproject.toml)
+pycairo==1.23.0
+    # via pygobject
+pycodestyle==2.10.0
+    # via
+    #   flake8
+    #   flake8-print
+pyflakes==3.0.1
+    # via flake8
+pygments==2.14.0
+    # via
+    #   rst2html5
+    #   sphinx
+pygobject==3.42.2
+    # via sublime_music (pyproject.toml)
+pytest==7.2.0
+    # via
+    #   pytest-cov
+    #   sublime_music (pyproject.toml)
+pytest-cov==4.0.0
+    # via sublime_music (pyproject.toml)
+python-dateutil==2.8.2
+    # via sublime_music (pyproject.toml)
+python-levenshtein==0.20.9
+    # via sublime_music (pyproject.toml)
+python-mpv==1.0.1
+    # via sublime_music (pyproject.toml)
+pytz==2022.7
+    # via babel
+pyyaml==6.0
+    # via pre-commit
+rapidfuzz==2.13.7
+    # via levenshtein
+requests==2.28.1
+    # via
+    #   flit
+    #   sphinx
+    #   sublime_music (pyproject.toml)
+requirements-parser==0.5.0
+    # via sublime_music (pyproject.toml)
+rst2html5==2.0
+    # via sublime_music (pyproject.toml)
+semver==2.13.0
+    # via sublime_music (pyproject.toml)
+six==1.16.0
+    # via
+    #   bleach
+    #   genshi
+    #   python-dateutil
+snowballstemmer==2.2.0
+    # via sphinx
+sphinx==4.5.0
+    # via
+    #   sphinx-rtd-theme
+    #   sublime_music (pyproject.toml)
+sphinx-rtd-theme==1.1.1
+    # via sublime_music (pyproject.toml)
+sphinxcontrib-applehelp==1.0.2
+    # via sphinx
+sphinxcontrib-devhelp==1.0.2
+    # via sphinx
+sphinxcontrib-htmlhelp==2.0.0
+    # via sphinx
+sphinxcontrib-jsmath==1.0.1
+    # via sphinx
+sphinxcontrib-qthelp==1.0.3
+    # via sphinx
+sphinxcontrib-serializinghtml==1.1.5
+    # via sphinx
+termcolor==2.1.1
+    # via sublime_music (pyproject.toml)
+thefuzz @ git+https://github.com/seatgeek/thefuzz@6e68af84e086b3e5f7253d4f9b0d6c7313e34637
+    # via sublime_music (pyproject.toml)
+tomli==2.0.1
+    # via
+    #   black
+    #   build
+    #   coverage
+    #   mypy
+    #   pytest
+tomli-w==1.0.0
+    # via flit
+types-bleach==5.0.3.1
+    # via sublime_music (pyproject.toml)
+types-peewee==3.15.0.5
+    # via sublime_music (pyproject.toml)
+types-python-dateutil==2.8.19.5
+    # via sublime_music (pyproject.toml)
+types-pyyaml==6.0.12.2
+    # via sublime_music (pyproject.toml)
+types-requests==2.28.11.7
+    # via sublime_music (pyproject.toml)
+types-setuptools==65.6.0.2
+    # via requirements-parser
+types-tabulate==0.9.0.0
+    # via sublime_music (pyproject.toml)
+types-termcolor==1.1.6
+    # via sublime_music (pyproject.toml)
+types-urllib3==1.26.25.4
+    # via types-requests
+typing-extensions==4.4.0
+    # via
+    #   mypy
+    #   typing-inspect
+typing-inspect==0.8.0
+    # via dataclasses-json
+urllib3==1.26.13
+    # via requests
+virtualenv==20.17.1
+    # via pre-commit
+webencodings==0.5.1
+    # via bleach
+wheel==0.38.4
+    # via pip-tools
+
+# The following packages are considered to be unsafe in a requirements file:
+# pip
+# setuptools
diff --git a/docs/conf.py b/docs/conf.py
index c29bc233..0277c851 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -23,7 +23,7 @@
 project = "Sublime Music"
 copyright = f"{datetime.datetime.today().year}, Sumner Evans"
 author = "Sumner Evans"
-gitlab_url = "https://gitlab.com/sublime-music/sublime-music/"
+github_url = "https://github.com/sublime-music/sublime-music/"
 
 # Get the version from the package.
 module_name = "sublime_music"
@@ -86,12 +86,12 @@
     "logo_only": True,
 }
 
-# Edit on GitLab integration
+# Edit on GitHub integration
 html_context = {
-    "display_gitlab": True,
-    "gitlab_user": "sublime-music",
-    "gitlab_repo": "sublime-music",
-    "gitlab_version": "master",
+    "display_github": True,
+    "github_user": "sublime-music",
+    "github_repo": "sublime-music",
+    "github_version": "master",
     "conf_py_path": "/docs/",
 }
 
diff --git a/mypy.ini b/mypy.ini
new file mode 100644
index 00000000..1c3de367
--- /dev/null
+++ b/mypy.ini
@@ -0,0 +1,26 @@
+[mypy]
+check_untyped_defs = True
+
+[mypy-bottle.*]
+ignore_missing_imports = True
+
+[mypy-deepdiff.*]
+ignore_missing_imports = True
+
+[mypy-gi.*]
+ignore_missing_imports = True
+
+[mypy-mpv.*]
+ignore_missing_imports = True
+
+[mypy-osxmmkeys.*]
+ignore_missing_imports = True
+
+[mypy-pychromecast.*]
+ignore_missing_imports = True
+
+[mypy-semver.*]
+ignore_missing_imports = True
+
+[mypy-thefuzz.*]
+ignore_missing_imports = True
diff --git a/poetry.lock b/poetry.lock
deleted file mode 100644
index 34dea251..00000000
--- a/poetry.lock
+++ /dev/null
@@ -1,1729 +0,0 @@
-# This file is automatically @generated by Poetry and should not be changed by hand.
-
-[[package]]
-name = "alabaster"
-version = "0.7.12"
-description = "A configurable sidebar-enabled Sphinx theme"
-category = "dev"
-optional = false
-python-versions = "*"
-files = [
-    {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"},
-    {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"},
-]
-
-[[package]]
-name = "async-timeout"
-version = "4.0.2"
-description = "Timeout context manager for asyncio programs"
-category = "main"
-optional = true
-python-versions = ">=3.6"
-files = [
-    {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"},
-    {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"},
-]
-
-[[package]]
-name = "attrs"
-version = "22.2.0"
-description = "Classes Without Boilerplate"
-category = "dev"
-optional = false
-python-versions = ">=3.6"
-files = [
-    {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"},
-    {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"},
-]
-
-[package.extras]
-cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"]
-dev = ["attrs[docs,tests]"]
-docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"]
-tests = ["attrs[tests-no-zope]", "zope.interface"]
-tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"]
-
-[[package]]
-name = "babel"
-version = "2.11.0"
-description = "Internationalization utilities"
-category = "dev"
-optional = false
-python-versions = ">=3.6"
-files = [
-    {file = "Babel-2.11.0-py3-none-any.whl", hash = "sha256:1ad3eca1c885218f6dce2ab67291178944f810a10a9b5f3cb8382a5a232b64fe"},
-    {file = "Babel-2.11.0.tar.gz", hash = "sha256:5ef4b3226b0180dedded4229651c8b0e1a3a6a2837d45a073272f313e4cf97f6"},
-]
-
-[package.dependencies]
-pytz = ">=2015.7"
-
-[[package]]
-name = "black"
-version = "22.12.0"
-description = "The uncompromising code formatter."
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"},
-    {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"},
-    {file = "black-22.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f"},
-    {file = "black-22.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:7412e75863aa5c5411886804678b7d083c7c28421210180d67dfd8cf1221e1f4"},
-    {file = "black-22.12.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c116eed0efb9ff870ded8b62fe9f28dd61ef6e9ddd28d83d7d264a38417dcee2"},
-    {file = "black-22.12.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f58cbe16dfe8c12b7434e50ff889fa479072096d79f0a7f25e4ab8e94cd8350"},
-    {file = "black-22.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d86c9f3db9b1bf6761244bc0b3572a546f5fe37917a044e02f3166d5aafa7d"},
-    {file = "black-22.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:82d9fe8fee3401e02e79767016b4907820a7dc28d70d137eb397b92ef3cc5bfc"},
-    {file = "black-22.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101c69b23df9b44247bd88e1d7e90154336ac4992502d4197bdac35dd7ee3320"},
-    {file = "black-22.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:559c7a1ba9a006226f09e4916060982fd27334ae1998e7a38b3f33a37f7a2148"},
-    {file = "black-22.12.0-py3-none-any.whl", hash = "sha256:436cc9167dd28040ad90d3b404aec22cedf24a6e4d7de221bec2730ec0c97bcf"},
-    {file = "black-22.12.0.tar.gz", hash = "sha256:229351e5a18ca30f447bf724d007f890f97e13af070bb6ad4c0a441cd7596a2f"},
-]
-
-[package.dependencies]
-click = ">=8.0.0"
-mypy-extensions = ">=0.4.3"
-pathspec = ">=0.9.0"
-platformdirs = ">=2"
-tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""}
-typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}
-
-[package.extras]
-colorama = ["colorama (>=0.4.3)"]
-d = ["aiohttp (>=3.7.4)"]
-jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
-uvloop = ["uvloop (>=0.15.2)"]
-
-[[package]]
-name = "bleach"
-version = "5.0.1"
-description = "An easy safelist-based HTML-sanitizing tool."
-category = "main"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "bleach-5.0.1-py3-none-any.whl", hash = "sha256:085f7f33c15bd408dd9b17a4ad77c577db66d76203e5984b1bd59baeee948b2a"},
-    {file = "bleach-5.0.1.tar.gz", hash = "sha256:0d03255c47eb9bd2f26aa9bb7f2107732e7e8fe195ca2f64709fcf3b0a4a085c"},
-]
-
-[package.dependencies]
-six = ">=1.9.0"
-webencodings = "*"
-
-[package.extras]
-css = ["tinycss2 (>=1.1.0,<1.2)"]
-dev = ["Sphinx (==4.3.2)", "black (==22.3.0)", "build (==0.8.0)", "flake8 (==4.0.1)", "hashin (==0.17.0)", "mypy (==0.961)", "pip-tools (==6.6.2)", "pytest (==7.1.2)", "tox (==3.25.0)", "twine (==4.0.1)", "wheel (==0.37.1)"]
-
-[[package]]
-name = "bottle"
-version = "0.12.23"
-description = "Fast and simple WSGI-framework for small web-applications."
-category = "main"
-optional = true
-python-versions = "*"
-files = [
-    {file = "bottle-0.12.23-py3-none-any.whl", hash = "sha256:9f1c363257c590bd34db5fad4693a7f06ff4217e9ad18337451de69c25137127"},
-    {file = "bottle-0.12.23.tar.gz", hash = "sha256:683de3aa399fb26e87b274dbcf70b1a651385d459131716387abdc3792e04167"},
-]
-
-[[package]]
-name = "casttube"
-version = "0.2.1"
-description = "YouTube chromecast api"
-category = "main"
-optional = true
-python-versions = "*"
-files = [
-    {file = "casttube-0.2.1-py3-none-any.whl", hash = "sha256:36f118007f9eead3959cf30de03c1640b53a263569ff2a3971c0521826c835b2"},
-    {file = "casttube-0.2.1.tar.gz", hash = "sha256:54d2af8c7949aa9c5db87fb11ef0a478a5d3e7ac6d2d2ac8dd1711e3a516fc82"},
-]
-
-[package.dependencies]
-requests = "*"
-
-[[package]]
-name = "certifi"
-version = "2022.12.7"
-description = "Python package for providing Mozilla's CA Bundle."
-category = "main"
-optional = false
-python-versions = ">=3.6"
-files = [
-    {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"},
-    {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"},
-]
-
-[[package]]
-name = "cffi"
-version = "1.15.1"
-description = "Foreign Function Interface for Python calling C code."
-category = "main"
-optional = true
-python-versions = "*"
-files = [
-    {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"},
-    {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"},
-    {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"},
-    {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"},
-    {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"},
-    {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"},
-    {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"},
-    {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"},
-    {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"},
-    {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"},
-    {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"},
-    {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"},
-    {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"},
-    {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"},
-    {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"},
-    {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"},
-    {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"},
-    {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"},
-    {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"},
-    {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"},
-    {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"},
-    {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"},
-    {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"},
-    {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"},
-    {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"},
-    {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"},
-    {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"},
-    {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"},
-    {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"},
-    {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"},
-    {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"},
-    {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"},
-    {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"},
-    {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"},
-    {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"},
-    {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"},
-    {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"},
-    {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"},
-    {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"},
-    {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"},
-    {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"},
-    {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"},
-    {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"},
-    {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"},
-    {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"},
-    {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"},
-    {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"},
-    {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"},
-    {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"},
-    {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"},
-    {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"},
-    {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"},
-    {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"},
-    {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"},
-    {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"},
-    {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"},
-    {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"},
-    {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"},
-    {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"},
-    {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"},
-    {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"},
-    {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"},
-    {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"},
-    {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"},
-]
-
-[package.dependencies]
-pycparser = "*"
-
-[[package]]
-name = "charset-normalizer"
-version = "2.1.1"
-description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
-category = "main"
-optional = false
-python-versions = ">=3.6.0"
-files = [
-    {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"},
-    {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"},
-]
-
-[package.extras]
-unicode-backport = ["unicodedata2"]
-
-[[package]]
-name = "click"
-version = "8.1.3"
-description = "Composable command line interface toolkit"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
-    {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
-]
-
-[package.dependencies]
-colorama = {version = "*", markers = "platform_system == \"Windows\""}
-
-[[package]]
-name = "colorama"
-version = "0.4.6"
-description = "Cross-platform colored terminal text."
-category = "dev"
-optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
-files = [
-    {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
-    {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
-]
-
-[[package]]
-name = "coverage"
-version = "7.0.1"
-description = "Code coverage measurement for Python"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "coverage-7.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b3695c4f4750bca943b3e1f74ad4be8d29e4aeab927d50772c41359107bd5d5c"},
-    {file = "coverage-7.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fa6a5a224b7f4cfb226f4fc55a57e8537fcc096f42219128c2c74c0e7d0953e1"},
-    {file = "coverage-7.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74f70cd92669394eaf8d7756d1b195c8032cf7bbbdfce3bc489d4e15b3b8cf73"},
-    {file = "coverage-7.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b66bb21a23680dee0be66557dc6b02a3152ddb55edf9f6723fa4a93368f7158d"},
-    {file = "coverage-7.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d87717959d4d0ee9db08a0f1d80d21eb585aafe30f9b0a54ecf779a69cb015f6"},
-    {file = "coverage-7.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:854f22fa361d1ff914c7efa347398374cc7d567bdafa48ac3aa22334650dfba2"},
-    {file = "coverage-7.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1e414dc32ee5c3f36544ea466b6f52f28a7af788653744b8570d0bf12ff34bc0"},
-    {file = "coverage-7.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6c5ad996c6fa4d8ed669cfa1e8551348729d008a2caf81489ab9ea67cfbc7498"},
-    {file = "coverage-7.0.1-cp310-cp310-win32.whl", hash = "sha256:691571f31ace1837838b7e421d3a09a8c00b4aac32efacb4fc9bd0a5c647d25a"},
-    {file = "coverage-7.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:89caf4425fe88889e2973a8e9a3f6f5f9bbe5dd411d7d521e86428c08a873a4a"},
-    {file = "coverage-7.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:63d56165a7c76265468d7e0c5548215a5ba515fc2cba5232d17df97bffa10f6c"},
-    {file = "coverage-7.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4f943a3b2bc520102dd3e0bb465e1286e12c9a54f58accd71b9e65324d9c7c01"},
-    {file = "coverage-7.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:830525361249dc4cd013652b0efad645a385707a5ae49350c894b67d23fbb07c"},
-    {file = "coverage-7.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd1b9c5adc066db699ccf7fa839189a649afcdd9e02cb5dc9d24e67e7922737d"},
-    {file = "coverage-7.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e00c14720b8b3b6c23b487e70bd406abafc976ddc50490f645166f111c419c39"},
-    {file = "coverage-7.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6d55d840e1b8c0002fce66443e124e8581f30f9ead2e54fbf6709fb593181f2c"},
-    {file = "coverage-7.0.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:66b18c3cf8bbab0cce0d7b9e4262dc830e93588986865a8c78ab2ae324b3ed56"},
-    {file = "coverage-7.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:12a5aa77783d49e05439fbe6e6b427484f8a0f9f456b46a51d8aac022cfd024d"},
-    {file = "coverage-7.0.1-cp311-cp311-win32.whl", hash = "sha256:b77015d1cb8fe941be1222a5a8b4e3fbca88180cfa7e2d4a4e58aeabadef0ab7"},
-    {file = "coverage-7.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb992c47cb1e5bd6a01e97182400bcc2ba2077080a17fcd7be23aaa6e572e390"},
-    {file = "coverage-7.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e78e9dcbf4f3853d3ae18a8f9272111242531535ec9e1009fa8ec4a2b74557dc"},
-    {file = "coverage-7.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e60bef2e2416f15fdc05772bf87db06c6a6f9870d1db08fdd019fbec98ae24a9"},
-    {file = "coverage-7.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9823e4789ab70f3ec88724bba1a203f2856331986cd893dedbe3e23a6cfc1e4e"},
-    {file = "coverage-7.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9158f8fb06747ac17bd237930c4372336edc85b6e13bdc778e60f9d685c3ca37"},
-    {file = "coverage-7.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:486ee81fa694b4b796fc5617e376326a088f7b9729c74d9defa211813f3861e4"},
-    {file = "coverage-7.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1285648428a6101b5f41a18991c84f1c3959cee359e51b8375c5882fc364a13f"},
-    {file = "coverage-7.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2c44fcfb3781b41409d0f060a4ed748537557de9362a8a9282182fafb7a76ab4"},
-    {file = "coverage-7.0.1-cp37-cp37m-win32.whl", hash = "sha256:d6814854c02cbcd9c873c0f3286a02e3ac1250625cca822ca6bc1018c5b19f1c"},
-    {file = "coverage-7.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f66460f17c9319ea4f91c165d46840314f0a7c004720b20be58594d162a441d8"},
-    {file = "coverage-7.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9b373c9345c584bb4b5f5b8840df7f4ab48c4cbb7934b58d52c57020d911b856"},
-    {file = "coverage-7.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d3022c3007d3267a880b5adcf18c2a9bf1fc64469b394a804886b401959b8742"},
-    {file = "coverage-7.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92651580bd46519067e36493acb394ea0607b55b45bd81dd4e26379ed1871f55"},
-    {file = "coverage-7.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cfc595d2af13856505631be072835c59f1acf30028d1c860b435c5fc9c15b69"},
-    {file = "coverage-7.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b4b3a4d9915b2be879aff6299c0a6129f3d08a775d5a061f503cf79571f73e4"},
-    {file = "coverage-7.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b6f22bb64cc39bcb883e5910f99a27b200fdc14cdd79df8696fa96b0005c9444"},
-    {file = "coverage-7.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72d1507f152abacea81f65fee38e4ef3ac3c02ff8bc16f21d935fd3a8a4ad910"},
-    {file = "coverage-7.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0a79137fc99815fff6a852c233628e735ec15903cfd16da0f229d9c4d45926ab"},
-    {file = "coverage-7.0.1-cp38-cp38-win32.whl", hash = "sha256:b3763e7fcade2ff6c8e62340af9277f54336920489ceb6a8cd6cc96da52fcc62"},
-    {file = "coverage-7.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:09f6b5a8415b6b3e136d5fec62b552972187265cb705097bf030eb9d4ffb9b60"},
-    {file = "coverage-7.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:978258fec36c154b5e250d356c59af7d4c3ba02bef4b99cda90b6029441d797d"},
-    {file = "coverage-7.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:19ec666533f0f70a0993f88b8273057b96c07b9d26457b41863ccd021a043b9a"},
-    {file = "coverage-7.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfded268092a84605f1cc19e5c737f9ce630a8900a3589e9289622db161967e9"},
-    {file = "coverage-7.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07bcfb1d8ac94af886b54e18a88b393f6a73d5959bb31e46644a02453c36e475"},
-    {file = "coverage-7.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:397b4a923cc7566bbc7ae2dfd0ba5a039b61d19c740f1373791f2ebd11caea59"},
-    {file = "coverage-7.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:aec2d1515d9d39ff270059fd3afbb3b44e6ec5758af73caf18991807138c7118"},
-    {file = "coverage-7.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c20cfebcc149a4c212f6491a5f9ff56f41829cd4f607b5be71bb2d530ef243b1"},
-    {file = "coverage-7.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fd556ff16a57a070ce4f31c635953cc44e25244f91a0378c6e9bdfd40fdb249f"},
-    {file = "coverage-7.0.1-cp39-cp39-win32.whl", hash = "sha256:b9ea158775c7c2d3e54530a92da79496fb3fb577c876eec761c23e028f1e216c"},
-    {file = "coverage-7.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:d1991f1dd95eba69d2cd7708ff6c2bbd2426160ffc73c2b81f617a053ebcb1a8"},
-    {file = "coverage-7.0.1-pp37.pp38.pp39-none-any.whl", hash = "sha256:3dd4ee135e08037f458425b8842d24a95a0961831a33f89685ff86b77d378f89"},
-    {file = "coverage-7.0.1.tar.gz", hash = "sha256:a4a574a19eeb67575a5328a5760bbbb737faa685616586a9f9da4281f940109c"},
-]
-
-[package.extras]
-toml = ["tomli"]
-
-[[package]]
-name = "cryptography"
-version = "38.0.4"
-description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
-category = "main"
-optional = true
-python-versions = ">=3.6"
-files = [
-    {file = "cryptography-38.0.4-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:2fa36a7b2cc0998a3a4d5af26ccb6273f3df133d61da2ba13b3286261e7efb70"},
-    {file = "cryptography-38.0.4-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:1f13ddda26a04c06eb57119caf27a524ccae20533729f4b1e4a69b54e07035eb"},
-    {file = "cryptography-38.0.4-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:2ec2a8714dd005949d4019195d72abed84198d877112abb5a27740e217e0ea8d"},
-    {file = "cryptography-38.0.4-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50a1494ed0c3f5b4d07650a68cd6ca62efe8b596ce743a5c94403e6f11bf06c1"},
-    {file = "cryptography-38.0.4-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a10498349d4c8eab7357a8f9aa3463791292845b79597ad1b98a543686fb1ec8"},
-    {file = "cryptography-38.0.4-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:10652dd7282de17990b88679cb82f832752c4e8237f0c714be518044269415db"},
-    {file = "cryptography-38.0.4-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bfe6472507986613dc6cc00b3d492b2f7564b02b3b3682d25ca7f40fa3fd321b"},
-    {file = "cryptography-38.0.4-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce127dd0a6a0811c251a6cddd014d292728484e530d80e872ad9806cfb1c5b3c"},
-    {file = "cryptography-38.0.4-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:53049f3379ef05182864d13bb9686657659407148f901f3f1eee57a733fb4b00"},
-    {file = "cryptography-38.0.4-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:8a4b2bdb68a447fadebfd7d24855758fe2d6fecc7fed0b78d190b1af39a8e3b0"},
-    {file = "cryptography-38.0.4-cp36-abi3-win32.whl", hash = "sha256:1d7e632804a248103b60b16fb145e8df0bc60eed790ece0d12efe8cd3f3e7744"},
-    {file = "cryptography-38.0.4-cp36-abi3-win_amd64.whl", hash = "sha256:8e45653fb97eb2f20b8c96f9cd2b3a0654d742b47d638cf2897afbd97f80fa6d"},
-    {file = "cryptography-38.0.4-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca57eb3ddaccd1112c18fc80abe41db443cc2e9dcb1917078e02dfa010a4f353"},
-    {file = "cryptography-38.0.4-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:c9e0d79ee4c56d841bd4ac6e7697c8ff3c8d6da67379057f29e66acffcd1e9a7"},
-    {file = "cryptography-38.0.4-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0e70da4bdff7601b0ef48e6348339e490ebfb0cbe638e083c9c41fb49f00c8bd"},
-    {file = "cryptography-38.0.4-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:998cd19189d8a747b226d24c0207fdaa1e6658a1d3f2494541cb9dfbf7dcb6d2"},
-    {file = "cryptography-38.0.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67461b5ebca2e4c2ab991733f8ab637a7265bb582f07c7c88914b5afb88cb95b"},
-    {file = "cryptography-38.0.4-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:4eb85075437f0b1fd8cd66c688469a0c4119e0ba855e3fef86691971b887caf6"},
-    {file = "cryptography-38.0.4-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3178d46f363d4549b9a76264f41c6948752183b3f587666aff0555ac50fd7876"},
-    {file = "cryptography-38.0.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:6391e59ebe7c62d9902c24a4d8bcbc79a68e7c4ab65863536127c8a9cd94043b"},
-    {file = "cryptography-38.0.4-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:78e47e28ddc4ace41dd38c42e6feecfdadf9c3be2af389abbfeef1ff06822285"},
-    {file = "cryptography-38.0.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fb481682873035600b5502f0015b664abc26466153fab5c6bc92c1ea69d478b"},
-    {file = "cryptography-38.0.4-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:4367da5705922cf7070462e964f66e4ac24162e22ab0a2e9d31f1b270dd78083"},
-    {file = "cryptography-38.0.4-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b4cad0cea995af760f82820ab4ca54e5471fc782f70a007f31531957f43e9dee"},
-    {file = "cryptography-38.0.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:80ca53981ceeb3241998443c4964a387771588c4e4a5d92735a493af868294f9"},
-    {file = "cryptography-38.0.4.tar.gz", hash = "sha256:175c1a818b87c9ac80bb7377f5520b7f31b3ef2a0004e2420319beadedb67290"},
-]
-
-[package.dependencies]
-cffi = ">=1.12"
-
-[package.extras]
-docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"]
-docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"]
-pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"]
-sdist = ["setuptools-rust (>=0.11.4)"]
-ssh = ["bcrypt (>=3.1.5)"]
-test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pytz"]
-
-[[package]]
-name = "dataclasses-json"
-version = "0.5.7"
-description = "Easily serialize dataclasses to and from JSON"
-category = "main"
-optional = false
-python-versions = ">=3.6"
-files = [
-    {file = "dataclasses-json-0.5.7.tar.gz", hash = "sha256:c2c11bc8214fbf709ffc369d11446ff6945254a7f09128154a7620613d8fda90"},
-    {file = "dataclasses_json-0.5.7-py3-none-any.whl", hash = "sha256:bc285b5f892094c3a53d558858a88553dd6a61a11ab1a8128a0e554385dcc5dd"},
-]
-
-[package.dependencies]
-marshmallow = ">=3.3.0,<4.0.0"
-marshmallow-enum = ">=1.5.1,<2.0.0"
-typing-inspect = ">=0.4.0"
-
-[package.extras]
-dev = ["flake8", "hypothesis", "ipython", "mypy (>=0.710)", "portray", "pytest (>=6.2.3)", "simplejson", "types-dataclasses"]
-
-[[package]]
-name = "deepdiff"
-version = "5.8.1"
-description = "Deep Difference and Search of any Python object/data."
-category = "main"
-optional = false
-python-versions = ">=3.6"
-files = [
-    {file = "deepdiff-5.8.1-py3-none-any.whl", hash = "sha256:e9aea49733f34fab9a0897038d8f26f9d94a97db1790f1b814cced89e9e0d2b7"},
-    {file = "deepdiff-5.8.1.tar.gz", hash = "sha256:8d4eb2c4e6cbc80b811266419cb71dd95a157094a3947ccf937a94d44943c7b8"},
-]
-
-[package.dependencies]
-ordered-set = ">=4.1.0,<4.2.0"
-
-[package.extras]
-cli = ["clevercsv (==0.7.1)", "click (==8.0.3)", "pyyaml (==5.4.1)", "toml (==0.10.2)"]
-
-[[package]]
-name = "docutils"
-version = "0.16"
-description = "Docutils -- Python Documentation Utilities"
-category = "dev"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
-files = [
-    {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"},
-    {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"},
-]
-
-[[package]]
-name = "exceptiongroup"
-version = "1.1.0"
-description = "Backport of PEP 654 (exception groups)"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"},
-    {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"},
-]
-
-[package.extras]
-test = ["pytest (>=6)"]
-
-[[package]]
-name = "flake8"
-version = "3.9.2"
-description = "the modular source code checker: pep8 pyflakes and co"
-category = "dev"
-optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
-files = [
-    {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"},
-    {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"},
-]
-
-[package.dependencies]
-mccabe = ">=0.6.0,<0.7.0"
-pycodestyle = ">=2.7.0,<2.8.0"
-pyflakes = ">=2.3.0,<2.4.0"
-
-[[package]]
-name = "flake8-annotations"
-version = "2.9.1"
-description = "Flake8 Type Annotation Checks"
-category = "dev"
-optional = false
-python-versions = ">=3.7,<4.0"
-files = [
-    {file = "flake8-annotations-2.9.1.tar.gz", hash = "sha256:11f09efb99ae63c8f9d6b492b75fe147fbc323179fddfe00b2e56eefeca42f57"},
-    {file = "flake8_annotations-2.9.1-py3-none-any.whl", hash = "sha256:a4385158a7a9fc8af1d8820a2f4c8d03387997006a83f5f8bfe5bc6085bdf88a"},
-]
-
-[package.dependencies]
-attrs = ">=21.4"
-flake8 = ">=3.7"
-
-[[package]]
-name = "flake8-bugbear"
-version = "20.11.1"
-description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle."
-category = "dev"
-optional = false
-python-versions = ">=3.6"
-files = [
-    {file = "flake8-bugbear-20.11.1.tar.gz", hash = "sha256:528020129fea2dea33a466b9d64ab650aa3e5f9ffc788b70ea4bc6cf18283538"},
-    {file = "flake8_bugbear-20.11.1-py36.py37.py38-none-any.whl", hash = "sha256:f35b8135ece7a014bc0aee5b5d485334ac30a6da48494998cc1fabf7ec70d703"},
-]
-
-[package.dependencies]
-attrs = ">=19.2.0"
-flake8 = ">=3.0.0"
-
-[package.extras]
-dev = ["black", "coverage", "hypothesis", "hypothesmith"]
-
-[[package]]
-name = "flake8-comprehensions"
-version = "3.10.1"
-description = "A flake8 plugin to help you write better list/set/dict comprehensions."
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "flake8-comprehensions-3.10.1.tar.gz", hash = "sha256:412052ac4a947f36b891143430fef4859705af11b2572fbb689f90d372cf26ab"},
-    {file = "flake8_comprehensions-3.10.1-py3-none-any.whl", hash = "sha256:d763de3c74bc18a79c039a7ec732e0a1985b0c79309ceb51e56401ad0a2cd44e"},
-]
-
-[package.dependencies]
-flake8 = ">=3.0,<3.2.0 || >3.2.0"
-
-[[package]]
-name = "flake8-import-order"
-version = "0.18.2"
-description = "Flake8 and pylama plugin that checks the ordering of import statements."
-category = "dev"
-optional = false
-python-versions = "*"
-files = [
-    {file = "flake8-import-order-0.18.2.tar.gz", hash = "sha256:e23941f892da3e0c09d711babbb0c73bc735242e9b216b726616758a920d900e"},
-    {file = "flake8_import_order-0.18.2-py2.py3-none-any.whl", hash = "sha256:82ed59f1083b629b030ee9d3928d9e06b6213eb196fe745b3a7d4af2168130df"},
-]
-
-[package.dependencies]
-pycodestyle = "*"
-setuptools = "*"
-
-[[package]]
-name = "flake8-pep3101"
-version = "1.3.0"
-description = "Checks for old string formatting."
-category = "dev"
-optional = false
-python-versions = "*"
-files = [
-    {file = "flake8-pep3101-1.3.0.tar.gz", hash = "sha256:86e3eb4e42de8326dcd98ebdeaf9a3c6854203a48f34aeb3e7e8ed948107f512"},
-    {file = "flake8_pep3101-1.3.0-py2.py3-none-any.whl", hash = "sha256:a5dae1caca1243b2b40108dce926d97cf5a9f52515c4a4cbb1ffe1ca0c54e343"},
-]
-
-[package.dependencies]
-flake8 = ">=3.0"
-
-[package.extras]
-test = ["pytest", "testfixtures"]
-
-[[package]]
-name = "flake8-print"
-version = "3.1.4"
-description = "print statement checker plugin for flake8"
-category = "dev"
-optional = false
-python-versions = "*"
-files = [
-    {file = "flake8-print-3.1.4.tar.gz", hash = "sha256:324f9e59a522518daa2461bacd7f82da3c34eb26a4314c2a54bd493f8b394a68"},
-]
-
-[package.dependencies]
-flake8 = ">=1.5"
-pycodestyle = "*"
-six = "*"
-
-[[package]]
-name = "genshi"
-version = "0.7.7"
-description = "A toolkit for generation of output for the web"
-category = "dev"
-optional = false
-python-versions = "*"
-files = [
-    {file = "Genshi-0.7.7-py3-none-any.whl", hash = "sha256:bed85e4015485a808bdd8d5bbb25cdc5fc093efab50776e2888be7b45b46f084"},
-    {file = "Genshi-0.7.7.tar.gz", hash = "sha256:c100520862cd69085d10ee1a87e91289e7f59f6b3d9bd622bf58b2804e6b9aab"},
-]
-
-[package.dependencies]
-six = "*"
-
-[package.extras]
-i18n = ["Babel (>=0.8)"]
-plugin = ["setuptools (>=0.6a2)"]
-
-[[package]]
-name = "idna"
-version = "3.4"
-description = "Internationalized Domain Names in Applications (IDNA)"
-category = "main"
-optional = false
-python-versions = ">=3.5"
-files = [
-    {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"},
-    {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"},
-]
-
-[[package]]
-name = "ifaddr"
-version = "0.2.0"
-description = "Cross-platform network interface and IP address enumeration library"
-category = "main"
-optional = true
-python-versions = "*"
-files = [
-    {file = "ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748"},
-    {file = "ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4"},
-]
-
-[[package]]
-name = "imagesize"
-version = "1.4.1"
-description = "Getting image size from png/jpeg/jpeg2000/gif file"
-category = "dev"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-files = [
-    {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"},
-    {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"},
-]
-
-[[package]]
-name = "importlib-metadata"
-version = "5.2.0"
-description = "Read metadata from Python packages"
-category = "main"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "importlib_metadata-5.2.0-py3-none-any.whl", hash = "sha256:0eafa39ba42bf225fc00e67f701d71f85aead9f878569caf13c3724f704b970f"},
-    {file = "importlib_metadata-5.2.0.tar.gz", hash = "sha256:404d48d62bba0b7a77ff9d405efd91501bef2e67ff4ace0bed40a0cf28c3c7cd"},
-]
-
-[package.dependencies]
-zipp = ">=0.5"
-
-[package.extras]
-docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-perf = ["ipython"]
-testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"]
-
-[[package]]
-name = "importlib-resources"
-version = "5.10.2"
-description = "Read resources from Python packages"
-category = "main"
-optional = true
-python-versions = ">=3.7"
-files = [
-    {file = "importlib_resources-5.10.2-py3-none-any.whl", hash = "sha256:7d543798b0beca10b6a01ac7cafda9f822c54db9e8376a6bf57e0cbd74d486b6"},
-    {file = "importlib_resources-5.10.2.tar.gz", hash = "sha256:e4a96c8cc0339647ff9a5e0550d9f276fc5a01ffa276012b58ec108cfd7b8484"},
-]
-
-[package.dependencies]
-zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""}
-
-[package.extras]
-docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
-
-[[package]]
-name = "iniconfig"
-version = "1.1.1"
-description = "iniconfig: brain-dead simple config-ini parsing"
-category = "dev"
-optional = false
-python-versions = "*"
-files = [
-    {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
-    {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
-]
-
-[[package]]
-name = "jaraco-classes"
-version = "3.2.3"
-description = "Utility functions for Python class constructs"
-category = "main"
-optional = true
-python-versions = ">=3.7"
-files = [
-    {file = "jaraco.classes-3.2.3-py3-none-any.whl", hash = "sha256:2353de3288bc6b82120752201c6b1c1a14b058267fa424ed5ce5984e3b922158"},
-    {file = "jaraco.classes-3.2.3.tar.gz", hash = "sha256:89559fa5c1d3c34eff6f631ad80bb21f378dbcbb35dd161fd2c6b93f5be2f98a"},
-]
-
-[package.dependencies]
-more-itertools = "*"
-
-[package.extras]
-docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"]
-testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
-
-[[package]]
-name = "jeepney"
-version = "0.8.0"
-description = "Low-level, pure Python DBus protocol wrapper."
-category = "main"
-optional = true
-python-versions = ">=3.7"
-files = [
-    {file = "jeepney-0.8.0-py3-none-any.whl", hash = "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755"},
-    {file = "jeepney-0.8.0.tar.gz", hash = "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806"},
-]
-
-[package.extras]
-test = ["async-timeout", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"]
-trio = ["async_generator", "trio"]
-
-[[package]]
-name = "jinja2"
-version = "3.1.2"
-description = "A very fast and expressive template engine."
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"},
-    {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"},
-]
-
-[package.dependencies]
-MarkupSafe = ">=2.0"
-
-[package.extras]
-i18n = ["Babel (>=2.7)"]
-
-[[package]]
-name = "keyring"
-version = "23.13.1"
-description = "Store and access your passwords safely."
-category = "main"
-optional = true
-python-versions = ">=3.7"
-files = [
-    {file = "keyring-23.13.1-py3-none-any.whl", hash = "sha256:771ed2a91909389ed6148631de678f82ddc73737d85a927f382a8a1b157898cd"},
-    {file = "keyring-23.13.1.tar.gz", hash = "sha256:ba2e15a9b35e21908d0aaf4e0a47acc52d6ae33444df0da2b49d41a46ef6d678"},
-]
-
-[package.dependencies]
-importlib-metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""}
-importlib-resources = {version = "*", markers = "python_version < \"3.9\""}
-"jaraco.classes" = "*"
-jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""}
-pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""}
-SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""}
-
-[package.extras]
-completion = ["shtab"]
-docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"]
-testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
-
-[[package]]
-name = "markupsafe"
-version = "2.1.1"
-description = "Safely add untrusted strings to HTML/XML markup."
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"},
-    {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"},
-    {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"},
-    {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"},
-    {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"},
-    {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"},
-    {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"},
-    {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"},
-    {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"},
-    {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"},
-    {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"},
-    {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"},
-    {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"},
-    {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"},
-    {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"},
-    {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"},
-    {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"},
-    {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"},
-    {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"},
-    {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"},
-    {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"},
-    {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"},
-    {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"},
-    {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"},
-    {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"},
-    {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"},
-    {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"},
-    {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"},
-    {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"},
-    {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"},
-    {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"},
-    {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"},
-    {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"},
-    {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"},
-    {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"},
-    {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"},
-    {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"},
-    {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"},
-    {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"},
-    {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"},
-]
-
-[[package]]
-name = "marshmallow"
-version = "3.19.0"
-description = "A lightweight library for converting complex datatypes to and from native Python datatypes."
-category = "main"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "marshmallow-3.19.0-py3-none-any.whl", hash = "sha256:93f0958568da045b0021ec6aeb7ac37c81bfcccbb9a0e7ed8559885070b3a19b"},
-    {file = "marshmallow-3.19.0.tar.gz", hash = "sha256:90032c0fd650ce94b6ec6dc8dfeb0e3ff50c144586462c389b81a07205bedb78"},
-]
-
-[package.dependencies]
-packaging = ">=17.0"
-
-[package.extras]
-dev = ["flake8 (==5.0.4)", "flake8-bugbear (==22.10.25)", "mypy (==0.990)", "pre-commit (>=2.4,<3.0)", "pytest", "pytz", "simplejson", "tox"]
-docs = ["alabaster (==0.7.12)", "autodocsumm (==0.2.9)", "sphinx (==5.3.0)", "sphinx-issues (==3.0.1)", "sphinx-version-warning (==1.1.2)"]
-lint = ["flake8 (==5.0.4)", "flake8-bugbear (==22.10.25)", "mypy (==0.990)", "pre-commit (>=2.4,<3.0)"]
-tests = ["pytest", "pytz", "simplejson"]
-
-[[package]]
-name = "marshmallow-enum"
-version = "1.5.1"
-description = "Enum field for Marshmallow"
-category = "main"
-optional = false
-python-versions = "*"
-files = [
-    {file = "marshmallow-enum-1.5.1.tar.gz", hash = "sha256:38e697e11f45a8e64b4a1e664000897c659b60aa57bfa18d44e226a9920b6e58"},
-    {file = "marshmallow_enum-1.5.1-py2.py3-none-any.whl", hash = "sha256:57161ab3dbfde4f57adeb12090f39592e992b9c86d206d02f6bd03ebec60f072"},
-]
-
-[package.dependencies]
-marshmallow = ">=2.0.0"
-
-[[package]]
-name = "mccabe"
-version = "0.6.1"
-description = "McCabe checker, plugin for flake8"
-category = "dev"
-optional = false
-python-versions = "*"
-files = [
-    {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
-    {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
-]
-
-[[package]]
-name = "more-itertools"
-version = "9.0.0"
-description = "More routines for operating on iterables, beyond itertools"
-category = "main"
-optional = true
-python-versions = ">=3.7"
-files = [
-    {file = "more-itertools-9.0.0.tar.gz", hash = "sha256:5a6257e40878ef0520b1803990e3e22303a41b5714006c32a3fd8304b26ea1ab"},
-    {file = "more_itertools-9.0.0-py3-none-any.whl", hash = "sha256:250e83d7e81d0c87ca6bd942e6aeab8cc9daa6096d12c5308f3f92fa5e5c1f41"},
-]
-
-[[package]]
-name = "mypy"
-version = "0.782"
-description = "Optional static typing for Python"
-category = "dev"
-optional = false
-python-versions = ">=3.5"
-files = [
-    {file = "mypy-0.782-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:2c6cde8aa3426c1682d35190b59b71f661237d74b053822ea3d748e2c9578a7c"},
-    {file = "mypy-0.782-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9c7a9a7ceb2871ba4bac1cf7217a7dd9ccd44c27c2950edbc6dc08530f32ad4e"},
-    {file = "mypy-0.782-cp35-cp35m-win_amd64.whl", hash = "sha256:c05b9e4fb1d8a41d41dec8786c94f3b95d3c5f528298d769eb8e73d293abc48d"},
-    {file = "mypy-0.782-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:6731603dfe0ce4352c555c6284c6db0dc935b685e9ce2e4cf220abe1e14386fd"},
-    {file = "mypy-0.782-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f05644db6779387ccdb468cc47a44b4356fc2ffa9287135d05b70a98dc83b89a"},
-    {file = "mypy-0.782-cp36-cp36m-win_amd64.whl", hash = "sha256:b7fbfabdbcc78c4f6fc4712544b9b0d6bf171069c6e0e3cb82440dd10ced3406"},
-    {file = "mypy-0.782-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:3fdda71c067d3ddfb21da4b80e2686b71e9e5c72cca65fa216d207a358827f86"},
-    {file = "mypy-0.782-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d7df6eddb6054d21ca4d3c6249cae5578cb4602951fd2b6ee2f5510ffb098707"},
-    {file = "mypy-0.782-cp37-cp37m-win_amd64.whl", hash = "sha256:a4a2cbcfc4cbf45cd126f531dedda8485671545b43107ded25ce952aac6fb308"},
-    {file = "mypy-0.782-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6bb93479caa6619d21d6e7160c552c1193f6952f0668cdda2f851156e85186fc"},
-    {file = "mypy-0.782-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:81c7908b94239c4010e16642c9102bfc958ab14e36048fa77d0be3289dda76ea"},
-    {file = "mypy-0.782-cp38-cp38-win_amd64.whl", hash = "sha256:5dd13ff1f2a97f94540fd37a49e5d255950ebcdf446fb597463a40d0df3fac8b"},
-    {file = "mypy-0.782-py3-none-any.whl", hash = "sha256:e0b61738ab504e656d1fe4ff0c0601387a5489ca122d55390ade31f9ca0e252d"},
-    {file = "mypy-0.782.tar.gz", hash = "sha256:eff7d4a85e9eea55afa34888dfeaccde99e7520b51f867ac28a48492c0b1130c"},
-]
-
-[package.dependencies]
-mypy-extensions = ">=0.4.3,<0.5.0"
-typed-ast = ">=1.4.0,<1.5.0"
-typing-extensions = ">=3.7.4"
-
-[package.extras]
-dmypy = ["psutil (>=4.0)"]
-
-[[package]]
-name = "mypy-extensions"
-version = "0.4.3"
-description = "Experimental type system extensions for programs checked with the mypy typechecker."
-category = "main"
-optional = false
-python-versions = "*"
-files = [
-    {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
-    {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
-]
-
-[[package]]
-name = "ordered-set"
-version = "4.1.0"
-description = "An OrderedSet is a custom MutableSet that remembers its order, so that every"
-category = "main"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8"},
-    {file = "ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562"},
-]
-
-[package.extras]
-dev = ["black", "mypy", "pytest"]
-
-[[package]]
-name = "packaging"
-version = "22.0"
-description = "Core utilities for Python packages"
-category = "main"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "packaging-22.0-py3-none-any.whl", hash = "sha256:957e2148ba0e1a3b282772e791ef1d8083648bc131c8ab0c1feba110ce1146c3"},
-    {file = "packaging-22.0.tar.gz", hash = "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3"},
-]
-
-[[package]]
-name = "pathspec"
-version = "0.10.3"
-description = "Utility library for gitignore style pattern matching of file paths."
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "pathspec-0.10.3-py3-none-any.whl", hash = "sha256:3c95343af8b756205e2aba76e843ba9520a24dd84f68c22b9f93251507509dd6"},
-    {file = "pathspec-0.10.3.tar.gz", hash = "sha256:56200de4077d9d0791465aa9095a01d421861e405b5096955051deefd697d6f6"},
-]
-
-[[package]]
-name = "peewee"
-version = "3.15.4"
-description = "a little orm"
-category = "main"
-optional = false
-python-versions = "*"
-files = [
-    {file = "peewee-3.15.4.tar.gz", hash = "sha256:2581520c8dfbacd9d580c2719ae259f0637a9e46eda47dfc0ce01864c6366205"},
-]
-
-[[package]]
-name = "platformdirs"
-version = "2.6.2"
-description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "platformdirs-2.6.2-py3-none-any.whl", hash = "sha256:83c8f6d04389165de7c9b6f0c682439697887bca0aa2f1c87ef1826be3584490"},
-    {file = "platformdirs-2.6.2.tar.gz", hash = "sha256:e1fea1fe471b9ff8332e229df3cb7de4f53eeea4998d3b6bfff542115e998bd2"},
-]
-
-[package.extras]
-docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"]
-test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
-
-[[package]]
-name = "pluggy"
-version = "1.0.0"
-description = "plugin and hook calling mechanisms for python"
-category = "dev"
-optional = false
-python-versions = ">=3.6"
-files = [
-    {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
-    {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
-]
-
-[package.extras]
-dev = ["pre-commit", "tox"]
-testing = ["pytest", "pytest-benchmark"]
-
-[[package]]
-name = "protobuf"
-version = "4.21.12"
-description = ""
-category = "main"
-optional = true
-python-versions = ">=3.7"
-files = [
-    {file = "protobuf-4.21.12-cp310-abi3-win32.whl", hash = "sha256:b135410244ebe777db80298297a97fbb4c862c881b4403b71bac9d4107d61fd1"},
-    {file = "protobuf-4.21.12-cp310-abi3-win_amd64.whl", hash = "sha256:89f9149e4a0169cddfc44c74f230d7743002e3aa0b9472d8c28f0388102fc4c2"},
-    {file = "protobuf-4.21.12-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:299ea899484ee6f44604deb71f424234f654606b983cb496ea2a53e3c63ab791"},
-    {file = "protobuf-4.21.12-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:d1736130bce8cf131ac7957fa26880ca19227d4ad68b4888b3be0dea1f95df97"},
-    {file = "protobuf-4.21.12-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:78a28c9fa223998472886c77042e9b9afb6fe4242bd2a2a5aced88e3f4422aa7"},
-    {file = "protobuf-4.21.12-cp37-cp37m-win32.whl", hash = "sha256:3d164928ff0727d97022957c2b849250ca0e64777ee31efd7d6de2e07c494717"},
-    {file = "protobuf-4.21.12-cp37-cp37m-win_amd64.whl", hash = "sha256:f45460f9ee70a0ec1b6694c6e4e348ad2019275680bd68a1d9314b8c7e01e574"},
-    {file = "protobuf-4.21.12-cp38-cp38-win32.whl", hash = "sha256:6ab80df09e3208f742c98443b6166bcb70d65f52cfeb67357d52032ea1ae9bec"},
-    {file = "protobuf-4.21.12-cp38-cp38-win_amd64.whl", hash = "sha256:1f22ac0ca65bb70a876060d96d914dae09ac98d114294f77584b0d2644fa9c30"},
-    {file = "protobuf-4.21.12-cp39-cp39-win32.whl", hash = "sha256:27f4d15021da6d2b706ddc3860fac0a5ddaba34ab679dc182b60a8bb4e1121cc"},
-    {file = "protobuf-4.21.12-cp39-cp39-win_amd64.whl", hash = "sha256:237216c3326d46808a9f7c26fd1bd4b20015fb6867dc5d263a493ef9a539293b"},
-    {file = "protobuf-4.21.12-py2.py3-none-any.whl", hash = "sha256:a53fd3f03e578553623272dc46ac2f189de23862e68565e83dde203d41b76fc5"},
-    {file = "protobuf-4.21.12-py3-none-any.whl", hash = "sha256:b98d0148f84e3a3c569e19f52103ca1feacdac0d2df8d6533cf983d1fda28462"},
-    {file = "protobuf-4.21.12.tar.gz", hash = "sha256:7cd532c4566d0e6feafecc1059d04c7915aec8e182d1cf7adee8b24ef1e2e6ab"},
-]
-
-[[package]]
-name = "pycairo"
-version = "1.23.0"
-description = "Python interface for cairo"
-category = "main"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "pycairo-1.23.0-cp310-cp310-win32.whl", hash = "sha256:564601e5f528531c6caec1c0177c3d0709081e1a2a5cccc13561f715080ae535"},
-    {file = "pycairo-1.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:e7cde633986435d87a86b6118b7b6109c384266fd719ef959883e2729f6eafae"},
-    {file = "pycairo-1.23.0-cp311-cp311-win32.whl", hash = "sha256:3a71f758e461180d241e62ef52e85499c843bd2660fd6d87cec99c9833792bfa"},
-    {file = "pycairo-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:2dec5378133778961993fb59d66df16070e03f4d491b67eb695ca9ad7a696008"},
-    {file = "pycairo-1.23.0-cp37-cp37m-win32.whl", hash = "sha256:d6bacff15d688ed135b4567965a4b664d9fb8de7417a7865bb138ad612043c9f"},
-    {file = "pycairo-1.23.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ec305fc7f2f0299df78aadec0eaf6eb9accb90eda242b5d3492544d3f2b28027"},
-    {file = "pycairo-1.23.0-cp38-cp38-win32.whl", hash = "sha256:1a6d8e0f353062ad92954784e33dbbaf66c880c9c30e947996c542ed9748aaaf"},
-    {file = "pycairo-1.23.0-cp38-cp38-win_amd64.whl", hash = "sha256:82e335774a17870bc038e0c2fb106c1e5e7ad0c764662023886dfcfce5bb5a52"},
-    {file = "pycairo-1.23.0-cp39-cp39-win32.whl", hash = "sha256:a4b1f525bbdf637c40f4d91378de36c01ec2b7f8ecc585b700a079b9ff83298e"},
-    {file = "pycairo-1.23.0-cp39-cp39-win_amd64.whl", hash = "sha256:87efd62a7b7afad9a0a420f05b6008742a6cfc59077697be65afe8dc73ae15ad"},
-    {file = "pycairo-1.23.0.tar.gz", hash = "sha256:9b61ac818723adc04367301317eb2e814a83522f07bbd1f409af0dada463c44c"},
-]
-
-[[package]]
-name = "pychromecast"
-version = "9.4.0"
-description = "Python module to talk to Google Chromecast."
-category = "main"
-optional = true
-python-versions = "*"
-files = [
-    {file = "PyChromecast-9.4.0-py2.py3-none-any.whl", hash = "sha256:3a903be26be96a08a13490204867b5edb0294d5cd141399ac866db206d608b03"},
-    {file = "PyChromecast-9.4.0.tar.gz", hash = "sha256:63c3cbae3c591e697b066925109fd55c6aa44729de032f90540e6bbac3de0474"},
-]
-
-[package.dependencies]
-casttube = ">=0.2.0"
-protobuf = ">=3.0.0"
-zeroconf = ">=0.25.1"
-
-[[package]]
-name = "pycodestyle"
-version = "2.7.0"
-description = "Python style guide checker"
-category = "dev"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-files = [
-    {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"},
-    {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"},
-]
-
-[[package]]
-name = "pycparser"
-version = "2.21"
-description = "C parser in Python"
-category = "main"
-optional = true
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-files = [
-    {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
-    {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
-]
-
-[[package]]
-name = "pyflakes"
-version = "2.3.1"
-description = "passive checker of Python programs"
-category = "dev"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-files = [
-    {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"},
-    {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"},
-]
-
-[[package]]
-name = "pygments"
-version = "2.13.0"
-description = "Pygments is a syntax highlighting package written in Python."
-category = "dev"
-optional = false
-python-versions = ">=3.6"
-files = [
-    {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"},
-    {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"},
-]
-
-[package.extras]
-plugins = ["importlib-metadata"]
-
-[[package]]
-name = "pygobject"
-version = "3.42.2"
-description = "Python bindings for GObject Introspection"
-category = "main"
-optional = false
-python-versions = ">=3.6, <4"
-files = [
-    {file = "PyGObject-3.42.2.tar.gz", hash = "sha256:21524cef33100c8fd59dc135948b703d79d303e368ce71fa60521cc971cd8aa7"},
-]
-
-[package.dependencies]
-pycairo = ">=1.16,<2.0"
-
-[[package]]
-name = "pytest"
-version = "7.2.0"
-description = "pytest: simple powerful testing with Python"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"},
-    {file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"},
-]
-
-[package.dependencies]
-attrs = ">=19.2.0"
-colorama = {version = "*", markers = "sys_platform == \"win32\""}
-exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
-iniconfig = "*"
-packaging = "*"
-pluggy = ">=0.12,<2.0"
-tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
-
-[package.extras]
-testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
-
-[[package]]
-name = "pytest-cov"
-version = "2.12.1"
-description = "Pytest plugin for measuring coverage."
-category = "dev"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
-files = [
-    {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"},
-    {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"},
-]
-
-[package.dependencies]
-coverage = ">=5.2.1"
-pytest = ">=4.6"
-toml = "*"
-
-[package.extras]
-testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"]
-
-[[package]]
-name = "python-dateutil"
-version = "2.8.2"
-description = "Extensions to the standard Python datetime module"
-category = "main"
-optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
-files = [
-    {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
-    {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
-]
-
-[package.dependencies]
-six = ">=1.5"
-
-[[package]]
-name = "python-levenshtein"
-version = "0.12.2"
-description = "Python extension for computing string edit distances and similarities."
-category = "main"
-optional = false
-python-versions = "*"
-files = [
-    {file = "python-Levenshtein-0.12.2.tar.gz", hash = "sha256:dc2395fbd148a1ab31090dd113c366695934b9e85fe5a4b2a032745efd0346f6"},
-]
-
-[package.dependencies]
-setuptools = "*"
-
-[[package]]
-name = "python-mpv"
-version = "1.0.1"
-description = "A python interface to the mpv media player"
-category = "main"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "python-mpv-1.0.1.tar.gz", hash = "sha256:4c5cc55df484232827ebfa9e498e8370698c6bf7283ba6b1e0329cc42f3aeb8e"},
-]
-
-[package.extras]
-screenshot-raw = ["Pillow"]
-
-[[package]]
-name = "pytz"
-version = "2022.7"
-description = "World timezone definitions, modern and historical"
-category = "dev"
-optional = false
-python-versions = "*"
-files = [
-    {file = "pytz-2022.7-py2.py3-none-any.whl", hash = "sha256:93007def75ae22f7cd991c84e02d434876818661f8df9ad5df9e950ff4e52cfd"},
-    {file = "pytz-2022.7.tar.gz", hash = "sha256:7ccfae7b4b2c067464a6733c6261673fdb8fd1be905460396b97a073e9fa683a"},
-]
-
-[[package]]
-name = "pywin32-ctypes"
-version = "0.2.0"
-description = ""
-category = "main"
-optional = true
-python-versions = "*"
-files = [
-    {file = "pywin32-ctypes-0.2.0.tar.gz", hash = "sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942"},
-    {file = "pywin32_ctypes-0.2.0-py2.py3-none-any.whl", hash = "sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98"},
-]
-
-[[package]]
-name = "requests"
-version = "2.28.1"
-description = "Python HTTP for Humans."
-category = "main"
-optional = false
-python-versions = ">=3.7, <4"
-files = [
-    {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"},
-    {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"},
-]
-
-[package.dependencies]
-certifi = ">=2017.4.17"
-charset-normalizer = ">=2,<3"
-idna = ">=2.5,<4"
-urllib3 = ">=1.21.1,<1.27"
-
-[package.extras]
-socks = ["PySocks (>=1.5.6,!=1.5.7)"]
-use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
-
-[[package]]
-name = "requirements-parser"
-version = "0.2.0"
-description = "Parses Pip requirement files"
-category = "dev"
-optional = false
-python-versions = "*"
-files = [
-    {file = "requirements-parser-0.2.0.tar.gz", hash = "sha256:5963ee895c2d05ae9f58d3fc641082fb38021618979d6a152b6b1398bd7d4ed4"},
-    {file = "requirements_parser-0.2.0-py2-none-any.whl", hash = "sha256:76650b4a9d98fc65edf008a7920c076bb2a76c08eaae230ce4cfc6f51ea6a773"},
-]
-
-[[package]]
-name = "rst2html5"
-version = "1.10.6"
-description = "Generates (X)HTML5 documents from standalone reStructuredText sources"
-category = "dev"
-optional = false
-python-versions = "*"
-files = [
-    {file = "rst2html5-1.10.6-py2.py3-none-any.whl", hash = "sha256:8e64e80c52d0383aa04f2701a7627c2f2a09d59c61a3dc3555d845333bc31648"},
-]
-
-[package.dependencies]
-docutils = ">=0.14"
-genshi = ">=0.7"
-pygments = ">=2.0.2"
-
-[[package]]
-name = "secretstorage"
-version = "3.3.3"
-description = "Python bindings to FreeDesktop.org Secret Service API"
-category = "main"
-optional = true
-python-versions = ">=3.6"
-files = [
-    {file = "SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99"},
-    {file = "SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77"},
-]
-
-[package.dependencies]
-cryptography = ">=2.0"
-jeepney = ">=0.6"
-
-[[package]]
-name = "semver"
-version = "2.13.0"
-description = "Python helper for Semantic Versioning (http://semver.org/)"
-category = "main"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-files = [
-    {file = "semver-2.13.0-py2.py3-none-any.whl", hash = "sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4"},
-    {file = "semver-2.13.0.tar.gz", hash = "sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f"},
-]
-
-[[package]]
-name = "setuptools"
-version = "65.6.3"
-description = "Easily download, build, install, upgrade, and uninstall Python packages"
-category = "main"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "setuptools-65.6.3-py3-none-any.whl", hash = "sha256:57f6f22bde4e042978bcd50176fdb381d7c21a9efa4041202288d3737a0c6a54"},
-    {file = "setuptools-65.6.3.tar.gz", hash = "sha256:a7620757bf984b58deaf32fc8a4577a9bbc0850cf92c20e1ce41c38c19e5fb75"},
-]
-
-[package.extras]
-docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
-testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
-testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
-
-[[package]]
-name = "six"
-version = "1.16.0"
-description = "Python 2 and 3 compatibility utilities"
-category = "main"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
-files = [
-    {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
-    {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
-]
-
-[[package]]
-name = "snowballstemmer"
-version = "2.2.0"
-description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms."
-category = "dev"
-optional = false
-python-versions = "*"
-files = [
-    {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"},
-    {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"},
-]
-
-[[package]]
-name = "sphinx"
-version = "4.5.0"
-description = "Python documentation generator"
-category = "dev"
-optional = false
-python-versions = ">=3.6"
-files = [
-    {file = "Sphinx-4.5.0-py3-none-any.whl", hash = "sha256:ebf612653238bcc8f4359627a9b7ce44ede6fdd75d9d30f68255c7383d3a6226"},
-    {file = "Sphinx-4.5.0.tar.gz", hash = "sha256:7bf8ca9637a4ee15af412d1a1d9689fec70523a68ca9bb9127c2f3eeb344e2e6"},
-]
-
-[package.dependencies]
-alabaster = ">=0.7,<0.8"
-babel = ">=1.3"
-colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""}
-docutils = ">=0.14,<0.18"
-imagesize = "*"
-importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""}
-Jinja2 = ">=2.3"
-packaging = "*"
-Pygments = ">=2.0"
-requests = ">=2.5.0"
-snowballstemmer = ">=1.1"
-sphinxcontrib-applehelp = "*"
-sphinxcontrib-devhelp = "*"
-sphinxcontrib-htmlhelp = ">=2.0.0"
-sphinxcontrib-jsmath = "*"
-sphinxcontrib-qthelp = "*"
-sphinxcontrib-serializinghtml = ">=1.1.5"
-
-[package.extras]
-docs = ["sphinxcontrib-websupport"]
-lint = ["docutils-stubs", "flake8 (>=3.5.0)", "isort", "mypy (>=0.931)", "types-requests", "types-typed-ast"]
-test = ["cython", "html5lib", "pytest", "pytest-cov", "typed-ast"]
-
-[[package]]
-name = "sphinx-rtd-theme"
-version = "0.5.2"
-description = "Read the Docs theme for Sphinx"
-category = "dev"
-optional = false
-python-versions = "*"
-files = [
-    {file = "sphinx_rtd_theme-0.5.2-py2.py3-none-any.whl", hash = "sha256:4a05bdbe8b1446d77a01e20a23ebc6777c74f43237035e76be89699308987d6f"},
-    {file = "sphinx_rtd_theme-0.5.2.tar.gz", hash = "sha256:32bd3b5d13dc8186d7a42fc816a23d32e83a4827d7d9882948e7b837c232da5a"},
-]
-
-[package.dependencies]
-docutils = "<0.17"
-sphinx = "*"
-
-[package.extras]
-dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client"]
-
-[[package]]
-name = "sphinxcontrib-applehelp"
-version = "1.0.2"
-description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books"
-category = "dev"
-optional = false
-python-versions = ">=3.5"
-files = [
-    {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"},
-    {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"},
-]
-
-[package.extras]
-lint = ["docutils-stubs", "flake8", "mypy"]
-test = ["pytest"]
-
-[[package]]
-name = "sphinxcontrib-devhelp"
-version = "1.0.2"
-description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document."
-category = "dev"
-optional = false
-python-versions = ">=3.5"
-files = [
-    {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"},
-    {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"},
-]
-
-[package.extras]
-lint = ["docutils-stubs", "flake8", "mypy"]
-test = ["pytest"]
-
-[[package]]
-name = "sphinxcontrib-htmlhelp"
-version = "2.0.0"
-description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files"
-category = "dev"
-optional = false
-python-versions = ">=3.6"
-files = [
-    {file = "sphinxcontrib-htmlhelp-2.0.0.tar.gz", hash = "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"},
-    {file = "sphinxcontrib_htmlhelp-2.0.0-py2.py3-none-any.whl", hash = "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07"},
-]
-
-[package.extras]
-lint = ["docutils-stubs", "flake8", "mypy"]
-test = ["html5lib", "pytest"]
-
-[[package]]
-name = "sphinxcontrib-jsmath"
-version = "1.0.1"
-description = "A sphinx extension which renders display math in HTML via JavaScript"
-category = "dev"
-optional = false
-python-versions = ">=3.5"
-files = [
-    {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"},
-    {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"},
-]
-
-[package.extras]
-test = ["flake8", "mypy", "pytest"]
-
-[[package]]
-name = "sphinxcontrib-qthelp"
-version = "1.0.3"
-description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document."
-category = "dev"
-optional = false
-python-versions = ">=3.5"
-files = [
-    {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"},
-    {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"},
-]
-
-[package.extras]
-lint = ["docutils-stubs", "flake8", "mypy"]
-test = ["pytest"]
-
-[[package]]
-name = "sphinxcontrib-serializinghtml"
-version = "1.1.5"
-description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)."
-category = "dev"
-optional = false
-python-versions = ">=3.5"
-files = [
-    {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"},
-    {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"},
-]
-
-[package.extras]
-lint = ["docutils-stubs", "flake8", "mypy"]
-test = ["pytest"]
-
-[[package]]
-name = "termcolor"
-version = "1.1.0"
-description = "ANSII Color formatting for output in terminal."
-category = "dev"
-optional = false
-python-versions = "*"
-files = [
-    {file = "termcolor-1.1.0.tar.gz", hash = "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"},
-]
-
-[[package]]
-name = "thefuzz"
-version = "0.19.0"
-description = "Fuzzy string matching in python"
-category = "main"
-optional = false
-python-versions = "*"
-files = [
-    {file = "thefuzz-0.19.0-py2.py3-none-any.whl", hash = "sha256:4fcdde8e40f5ca5e8106bc7665181f9598a9c8b18b0a4d38c41a095ba6788972"},
-    {file = "thefuzz-0.19.0.tar.gz", hash = "sha256:6f7126db2f2c8a54212b05e3a740e45f4291c497d75d20751728f635bb74aa3d"},
-]
-
-[package.extras]
-speedup = ["python-levenshtein (>=0.12)"]
-
-[[package]]
-name = "toml"
-version = "0.10.2"
-description = "Python Library for Tom's Obvious, Minimal Language"
-category = "dev"
-optional = false
-python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
-files = [
-    {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
-    {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
-]
-
-[[package]]
-name = "tomli"
-version = "2.0.1"
-description = "A lil' TOML parser"
-category = "dev"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
-    {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
-]
-
-[[package]]
-name = "typed-ast"
-version = "1.4.3"
-description = "a fork of Python 2 and 3 ast modules with type comment support"
-category = "dev"
-optional = false
-python-versions = "*"
-files = [
-    {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"},
-    {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"},
-    {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"},
-    {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"},
-    {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"},
-    {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"},
-    {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"},
-    {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"},
-    {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"},
-    {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"},
-    {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"},
-    {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"},
-    {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"},
-    {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"},
-    {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"},
-    {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"},
-    {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"},
-    {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"},
-    {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"},
-    {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"},
-    {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"},
-    {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"},
-    {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"},
-    {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"},
-    {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"},
-    {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"},
-    {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"},
-    {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"},
-    {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"},
-    {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"},
-]
-
-[[package]]
-name = "typing-extensions"
-version = "4.4.0"
-description = "Backported and Experimental Type Hints for Python 3.7+"
-category = "main"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"},
-    {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"},
-]
-
-[[package]]
-name = "typing-inspect"
-version = "0.8.0"
-description = "Runtime inspection utilities for typing module."
-category = "main"
-optional = false
-python-versions = "*"
-files = [
-    {file = "typing_inspect-0.8.0-py3-none-any.whl", hash = "sha256:5fbf9c1e65d4fa01e701fe12a5bca6c6e08a4ffd5bc60bfac028253a447c5188"},
-    {file = "typing_inspect-0.8.0.tar.gz", hash = "sha256:8b1ff0c400943b6145df8119c41c244ca8207f1f10c9c057aeed1560e4806e3d"},
-]
-
-[package.dependencies]
-mypy-extensions = ">=0.3.0"
-typing-extensions = ">=3.7.4"
-
-[[package]]
-name = "urllib3"
-version = "1.26.13"
-description = "HTTP library with thread-safe connection pooling, file post, and more."
-category = "main"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
-files = [
-    {file = "urllib3-1.26.13-py2.py3-none-any.whl", hash = "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc"},
-    {file = "urllib3-1.26.13.tar.gz", hash = "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8"},
-]
-
-[package.extras]
-brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"]
-secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"]
-socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
-
-[[package]]
-name = "webencodings"
-version = "0.5.1"
-description = "Character encoding aliases for legacy web content"
-category = "main"
-optional = false
-python-versions = "*"
-files = [
-    {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"},
-    {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"},
-]
-
-[[package]]
-name = "zeroconf"
-version = "0.47.1"
-description = "A pure python implementation of multicast DNS service discovery"
-category = "main"
-optional = true
-python-versions = ">=3.7,<4.0"
-files = [
-    {file = "zeroconf-0.47.1-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:1bab8bcb6f0810ccebb54830b4f13111111bfb5aa43e412dbf84640c960d8862"},
-    {file = "zeroconf-0.47.1-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:56c8299ddde1ce2e8855dc3cbaa5f24073273b292874557b09935642a1fea175"},
-    {file = "zeroconf-0.47.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c8af291e5bbbd1c10c06732363abed48a0fa61cfc8fb13946a55dfc52294788"},
-    {file = "zeroconf-0.47.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:955cc2bd4534e39a92306ec4cf93033336561f083093eb6b36938f80d874a854"},
-    {file = "zeroconf-0.47.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:86f5e4de8701a51f28b3e60895f522ffc31746622924f351850bf01e95dd6c1a"},
-    {file = "zeroconf-0.47.1-cp310-cp310-win32.whl", hash = "sha256:a44d32e8b51826a6a020c6a9cd0c8958b87e9bb3a25bae4049dbd7113b140e05"},
-    {file = "zeroconf-0.47.1-cp310-cp310-win_amd64.whl", hash = "sha256:05e5a06696325ef5053a8e7b89d5548815420b5e4b5cd25b6b5b8830727b993a"},
-    {file = "zeroconf-0.47.1-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:5554bd4c4c5662b9c91aa2c0c57e214f1d87ed46d88cf07c65df88a8de27a423"},
-    {file = "zeroconf-0.47.1-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:a7aabb9e102b682024eb9df88e526a423272b156bb5db61c64802db6ed1278f3"},
-    {file = "zeroconf-0.47.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e6f04d2dc9c9d39129259b092d2720ca691be7c7f63ebe44206cdac6b60ccfb"},
-    {file = "zeroconf-0.47.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6fd17cc7f33e75caf1cea9805cc5f1d91a5c9b9b11cbbd7e195ed03399b1ef8e"},
-    {file = "zeroconf-0.47.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e8f41bc41f34d53464322bf7d44fd2014329722f8637e639e48320bdbaf799bf"},
-    {file = "zeroconf-0.47.1-cp311-cp311-win32.whl", hash = "sha256:72ae8bf088ec60d00d5af471fbf9d414e8094e1cca77bcf9a6b83d83301382ce"},
-    {file = "zeroconf-0.47.1-cp311-cp311-win_amd64.whl", hash = "sha256:4c41e445433c15c2f69f5cb9f9c8e24ffb1cc401b9a0bd960d97e121c295f21a"},
-    {file = "zeroconf-0.47.1-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:d9500dda1b460dcfb6b3a2b99f5a55cbf73a1cef558c090598a4c225149204d3"},
-    {file = "zeroconf-0.47.1-cp37-cp37m-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:189c259b2e752bb25d4dd04be22641cb60fa72201361ff5d9a3e82e450c8d42e"},
-    {file = "zeroconf-0.47.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48535ec547bd0ed9fc959ba205bb059629dfbea8261cb92e9c35aa61a619952a"},
-    {file = "zeroconf-0.47.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2aa6c551f7fb607a458326c3be9688bc9bf62bc131b16e0c0c968e85cef65cc7"},
-    {file = "zeroconf-0.47.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7caadfa40f4614ef45600b846a642d19a0a692dbded1861d8621032f16eafef5"},
-    {file = "zeroconf-0.47.1-cp37-cp37m-win32.whl", hash = "sha256:962faca9aded170d27ef1cb8930c0d86252f6caf257f6e1377ab18b2d5d5b005"},
-    {file = "zeroconf-0.47.1-cp37-cp37m-win_amd64.whl", hash = "sha256:2d2bfe6f4d06c9c213e9266655a5a6cb5643a94993c80c04e0015fe803206a83"},
-    {file = "zeroconf-0.47.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:7c0fc1390b09198e5ec1b05fa50010ac143e6e64d17ccd88f7f3e633c4b2b93d"},
-    {file = "zeroconf-0.47.1-cp38-cp38-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:c404494dc75a91c4d4c9620a413829b8f39e5444ba908158e7d0f3abbcb9d97c"},
-    {file = "zeroconf-0.47.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f57847dda5a0c382342b9d05c1aa07983fec9423cea6f0b58f2435759671555c"},
-    {file = "zeroconf-0.47.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:88a52323b2965574f6e64f7ec0dc612fa6d3197a44f34fa88e2cb0d176b778e8"},
-    {file = "zeroconf-0.47.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:87895f255eda9fe6a073ba0a13b1b0e513da803a4a78a31f56c5f3922758ed9c"},
-    {file = "zeroconf-0.47.1-cp38-cp38-win32.whl", hash = "sha256:34f8a84bbc07af05e770538be95791207e47ee1107639a6557990d670dd9f4c9"},
-    {file = "zeroconf-0.47.1-cp38-cp38-win_amd64.whl", hash = "sha256:6570c3a5b4b73e0eecb4bf1536bd357066cac96259b474b98bb6d25a060d5dba"},
-    {file = "zeroconf-0.47.1-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:4d122030ef4d63bcad22d8b2ac0c6f62a722f0b38bb1422bc317b8e636082602"},
-    {file = "zeroconf-0.47.1-cp39-cp39-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:34ae29fc765a4b4e0df42d52fc7c0f9f85ddf4b5213ab48014cdb882e5788a66"},
-    {file = "zeroconf-0.47.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18f45637e94f6520df664ff3313e9e8fdc197199c33c49ed8e6244f06b15dcec"},
-    {file = "zeroconf-0.47.1-cp39-cp39-manylinux_2_31_x86_64.whl", hash = "sha256:e96a9dceb76ad515d048d61a2257dea49088a2a4bf2d9fc1e7be053e0bc385b8"},
-    {file = "zeroconf-0.47.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:1e5bfdc90f819ca92db9ad38e55bbebe5918d7ab737eb2b105ea9813d03d18da"},
-    {file = "zeroconf-0.47.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7f87cc6b150beec2462dcd25ed18def1eb66de400458467b7ba6efba2ca2fd16"},
-    {file = "zeroconf-0.47.1-cp39-cp39-win32.whl", hash = "sha256:bc94af03952d3985db0de12d547bf394bedf003d5829d84956bb40cbd1d056ab"},
-    {file = "zeroconf-0.47.1-cp39-cp39-win_amd64.whl", hash = "sha256:f0bce96b47189fd1238460d12d9f6700df3a001c004562aac04dc2234a57cb6c"},
-    {file = "zeroconf-0.47.1-pp37-pypy37_pp73-macosx_11_0_x86_64.whl", hash = "sha256:65cf72e5715a60e05abcfd1673ca7b72d0180ad0d18c64b7ad540fc81d4e59e9"},
-    {file = "zeroconf-0.47.1-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:80ea37996779704686d9fa11b5744b9901b1b01f6fbec82cbc26dcaef6d862a8"},
-    {file = "zeroconf-0.47.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cf819b34259adfb5d53528115e1ed90560618f61cf5b402a3055d2a34af5cd5"},
-    {file = "zeroconf-0.47.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:6767ca3d02d587efd834bd876cc023b64a567622e1ee6100d0faf9ef241ac67a"},
-    {file = "zeroconf-0.47.1-pp38-pypy38_pp73-macosx_11_0_x86_64.whl", hash = "sha256:557241798f54b153801cc8548e614e96132971186a8d2622aeb357a0a7f36c3a"},
-    {file = "zeroconf-0.47.1-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:01cc66239b927f6a5cbe815e8becea10796665a924651567cb981b1514b9412e"},
-    {file = "zeroconf-0.47.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7e96b6d7161c029f8f0c068c476dada6f6ab76d8007af41ce80ffa10c1085b3"},
-    {file = "zeroconf-0.47.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:e4845cb2321da8fea8a6df2e6555169d633f8cc3292cc90676761af43cad9f9f"},
-    {file = "zeroconf-0.47.1-pp39-pypy39_pp73-macosx_11_0_x86_64.whl", hash = "sha256:d24f94ee1ae3200882dd7f38c6ed88055e6a6945f65fd33e032e8bb2ca85e06c"},
-    {file = "zeroconf-0.47.1-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:a4c2e52057e095e190943f5b9ea763a3c9ffef16962907da4eb39cb9a416bd07"},
-    {file = "zeroconf-0.47.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cf72f7196d2807d3a0f1a99da52956f4687d01d735bcb8784712547734635bf"},
-    {file = "zeroconf-0.47.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:77b74b556319fc86db145d7ec2b78dac79133de1dd1ab524f61697f8434c4a1e"},
-    {file = "zeroconf-0.47.1.tar.gz", hash = "sha256:65ab91068f8fafe00856b63756c72296b69682709681e96e8bb5d101345d5011"},
-]
-
-[package.dependencies]
-async-timeout = {version = ">=3.0.0", markers = "python_version < \"3.11\""}
-ifaddr = ">=0.1.7"
-
-[[package]]
-name = "zipp"
-version = "3.11.0"
-description = "Backport of pathlib-compatible object wrapper for zip files"
-category = "main"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "zipp-3.11.0-py3-none-any.whl", hash = "sha256:83a28fcb75844b5c0cdaf5aa4003c2d728c77e05f5aeabe8e95e56727005fbaa"},
-    {file = "zipp-3.11.0.tar.gz", hash = "sha256:a7a22e05929290a67401440b39690ae6563279bced5f314609d9d03798f56766"},
-]
-
-[package.extras]
-docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"]
-testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
-
-[extras]
-chromecast = ["pychromecast"]
-keyring = ["keyring"]
-server = ["bottle"]
-
-[metadata]
-lock-version = "2.0"
-python-versions = "^3.8"
-content-hash = "64f30a55f097ab59e672db99f5a061f1d3c0751858d504a738107f620198c56a"
diff --git a/pyproject.toml b/pyproject.toml
index f5ad98a3..1c915ad5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,18 +1,18 @@
-[tool.poetry]
+[build-system]
+requires = ["flit_core >=3.2,<4"]
+build-backend = "flit_core.buildapi"
+
+[project]
 name = "sublime_music"
-version = "0.11.16"
-description = "A native GTK *sonic client."
-license = "GPL-3.0-or-later"
-authors = ["Sumner Evans <inquiries@sumnerevans.com>"]
+authors = [
+    {name = "Sumner Evans", email = "inquiries@sumnerevans.com"}
+]
+dynamic = ["version", "description"]
 readme = "README.rst"
-homepage = "https://sublimemusic.app"
-repository = "https://gitlab.com/sublime-music/sublime-music"
-documentation = "https://sublime-music.gitlab.io/sublime-music"
+requires-python = ">=3.10,<4"
+license = {file = "LICENSE"}
 keywords = ["airsonic", "music", "GTK", "chromecast", "subsonic"]
 classifiers = [
-    #   3 - Alpha
-    #   4 - Beta
-    #   5 - Production/Stable
     "Development Status :: 4 - Beta",
     "Environment :: X11 Applications :: GTK",
     "Intended Audience :: End Users/Desktop",
@@ -20,57 +20,73 @@ classifiers = [
     "Topic :: Multimedia :: Sound/Audio :: Players",
 ]
 
-exclude = [
-    "tests",
-    "sublime_music/adapters/subsonic/api_specs"
+dependencies = [
+    "bleach",
+    "dataclasses-json",
+    "deepdiff",
+    "thefuzz @ git+https://github.com/seatgeek/thefuzz@6e68af84e086b3e5f7253d4f9b0d6c7313e34637",
+    "peewee",
+    "PyGObject",
+    "python-dateutil",
+    "python-Levenshtein",
+    "python-mpv",
+    "requests",
+    "semver",
 ]
 
-[tool.poetry.urls]
-"Bug Tracker" = "https://gitlab.com/sublime-music/sublime-music/-/issues"
-
-[tool.poetry.scripts]
-sublime-music = 'sublime_music.__main__:main'
-
-[tool.poetry.dependencies]
-python = "^3.8"
-bleach = ">=3.3.0"
-bottle = {version = "^0.12.18", optional = true}
-dataclasses-json = "^0.5.2"
-deepdiff = "^5.8.1"
-thefuzz = "^0.19.0"
-keyring = {version = "^23.0.0", optional = true}
-peewee = "^3.13.3"
-pychromecast = {version = "^9.1.1", optional = true}
-PyGObject = "^3.42.0"
-python-dateutil = "^2.8.1"
-python-Levenshtein = "^0.12.0"
-python-mpv = "^1.0.1"
-requests = "^2.28.1"
-semver = "^2.10.2"
-
-[tool.poetry.dev-dependencies]
-black = "^22.8.0"
-docutils = "^0.16"
-flake8 = "^3.8.3"
-flake8-annotations = "^2.4.0"
-flake8-bugbear = "^20.1.4"
-flake8-comprehensions = "^3.2.3"
-flake8-import-order = "^0.18.1"
-flake8-pep3101 = "^1.3.0"
-flake8-print = "^3.1.4"
-mypy = "^0.782"
-pytest-cov = "^2.10.1"
-requirements-parser = "^0.2.0"
-rst2html5 = "^1.10.6"
-sphinx = "^4.0.2"
-sphinx_rtd_theme = "^0.5.0"
-termcolor = "^1.1.0"
-
-[tool.poetry.extras]
+[project.optional-dependencies]
+dev = [
+    "black",
+    "flake8",
+    "flake8-annotations",
+    "flake8-bugbear",
+    "flake8-comprehensions",
+    "flake8-pep3101",
+    "flake8-print",
+    "flit",
+    "isort",
+    "mypy",
+    "pip-tools",
+    "pre-commit",
+    "requirements-parser",
+    "termcolor",
+    "types-bleach",
+    "types-peewee",
+    "types-python-dateutil",
+    "types-pyyaml",
+    "types-requests",
+    "types-tabulate",
+    "types-termcolor",
+]
+doc = [
+    "docutils <0.17",
+    "rst2html5",
+    "sphinx <5",
+    "sphinx_rtd_theme",
+]
+test = [
+    "pytest",
+    "pytest-cov",
+]
 chromecast = ["pychromecast"]
 keyring = ["keyring"]
 server = ["bottle"]
 
-[build-system]
-requires = ["poetry-core"]
-build-backend = "poetry.core.masonry.api"
+[project.urls]
+Homepage = "https://sublimemusic.app"
+"Source Code" = "https://github.com/sublime-music/sublime-music"
+Documentation = "https://sublime-music.gitlab.io/sublime-music"
+"Bug Tracker" = "https://github.com/sublime-music/sublime-music/issues"
+
+[project.scripts]
+sublime-music = 'sublime_music.__main__:main'
+
+[tool.isort]
+profile = "black"
+combine_as_imports = true
+known_first_party = "sublime_music"
+line_length = 99
+
+[tool.black]
+line-length = 99
+target-version = ["py38"]
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 00000000..ba4969c3
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,64 @@
+#
+# This file is autogenerated by pip-compile with Python 3.10
+# by the following command:
+#
+#    pip-compile --output-file=requirements.txt pyproject.toml
+#
+bleach==5.0.1
+    # via sublime_music (pyproject.toml)
+certifi==2022.12.7
+    # via requests
+charset-normalizer==2.1.1
+    # via requests
+dataclasses-json==0.5.7
+    # via sublime_music (pyproject.toml)
+deepdiff==6.2.2
+    # via sublime_music (pyproject.toml)
+idna==3.4
+    # via requests
+levenshtein==0.20.9
+    # via python-levenshtein
+marshmallow==3.19.0
+    # via
+    #   dataclasses-json
+    #   marshmallow-enum
+marshmallow-enum==1.5.1
+    # via dataclasses-json
+mypy-extensions==0.4.3
+    # via typing-inspect
+ordered-set==4.1.0
+    # via deepdiff
+packaging==22.0
+    # via marshmallow
+peewee==3.15.4
+    # via sublime_music (pyproject.toml)
+pycairo==1.23.0
+    # via pygobject
+pygobject==3.42.2
+    # via sublime_music (pyproject.toml)
+python-dateutil==2.8.2
+    # via sublime_music (pyproject.toml)
+python-levenshtein==0.20.9
+    # via sublime_music (pyproject.toml)
+python-mpv==1.0.1
+    # via sublime_music (pyproject.toml)
+rapidfuzz==2.13.7
+    # via levenshtein
+requests==2.28.1
+    # via sublime_music (pyproject.toml)
+semver==2.13.0
+    # via sublime_music (pyproject.toml)
+six==1.16.0
+    # via
+    #   bleach
+    #   python-dateutil
+thefuzz @ git+https://github.com/seatgeek/thefuzz@6e68af84e086b3e5f7253d4f9b0d6c7313e34637
+    # via sublime_music (pyproject.toml)
+typing-extensions==4.4.0
+    # via typing-inspect
+typing-inspect==0.8.0
+    # via dataclasses-json
+urllib3==1.26.13
+    # via requests
+webencodings==0.5.1
+    # via bleach
diff --git a/setup.cfg b/setup.cfg
index 1c8dc32a..11002d9f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,53 +1,10 @@
 [flake8]
-extend-ignore = E203, E402, E722, E731, W503, ANN002, ANN003, ANN101, ANN102, ANN204
+extend-ignore = E203, E402, E722, E731, W503, ANN002, ANN003, ANN101, ANN102, ANN204, B019, B023
 exclude = .git,__pycache__,build,dist,.venv
-max-line-length = 88
+max-line-length = 99
 suppress-none-returning = True
 suppress-dummy-args = True
 application-import-names = sublime_music
-import-order-style = edited
-
-[mypy-bottle]
-ignore_missing_imports = True
-
-[mypy-deepdiff]
-ignore_missing_imports = True
-
-[mypy-deprecated]
-ignore_missing_imports = True
-
-[mypy-thefuzz]
-ignore_missing_imports = True
-
-[mypy-gi]
-ignore_missing_imports = True
-
-[mypy-gi.repository]
-ignore_missing_imports = True
-
-[mypy-keyring]
-ignore_missing_imports = True
-
-[mypy-mpv]
-ignore_missing_imports = True
-
-[mypy-osxmmkeys]
-ignore_missing_imports = True
-
-[mypy-pychromecast]
-ignore_missing_imports = True
-
-[mypy-pytest]
-ignore_missing_imports = True
-
-[mypy-playhouse.sqliteq]
-ignore_missing_imports = True
-
-[mypy-peewee]
-ignore_missing_imports = True
-
-[mypy-semver]
-ignore_missing_imports = True
 
 [tool:pytest]
 python_files = tests/**/*.py tests/*.py
diff --git a/shell.nix b/shell.nix
index 4f46d7e4..fa4933a3 100644
--- a/shell.nix
+++ b/shell.nix
@@ -22,15 +22,7 @@ pkgs.mkShell {
   propagatedBuildInputs = with pkgs; [
     cairo
     mpv
-    poetry
-    (
-      python38.withPackages (
-        ps: with ps; [
-          jedi
-          neovim
-        ]
-      )
-    )
+    python310
     rnix-lsp
   ];
 
@@ -39,10 +31,6 @@ pkgs.mkShell {
     export LD_LIBRARY_PATH=${pkgs.mpv}/lib
     export XDG_DATA_DIRS="$GSETTINGS_SCHEMA_PATH:${pkgs.arc-theme}/share:${pkgs.arc-icon-theme}/share"
     export SOURCE_DATE_EPOCH=315532800
-
-    # An update happened to the shell.nix, so remove and reinstall everything in the virtualenv
-    rm -rf .venv
-    poetry install -E chromecast -E keyring -E server
     set +x
   '';
 }
diff --git a/sublime_music/__init__.py b/sublime_music/__init__.py
index 1e6e8065..5a7a300b 100644
--- a/sublime_music/__init__.py
+++ b/sublime_music/__init__.py
@@ -1,1 +1,3 @@
+"""A native GTK *sonic client."""
+
 __version__ = "0.11.16"
diff --git a/sublime_music/__main__.py b/sublime_music/__main__.py
index 6cfb557b..00a0f2fb 100644
--- a/sublime_music/__main__.py
+++ b/sublime_music/__main__.py
@@ -16,9 +16,7 @@
 
 def main():
     parser = argparse.ArgumentParser(description="Sublime Music")
-    parser.add_argument(
-        "-v", "--version", help="show version and exit", action="store_true"
-    )
+    parser.add_argument("-v", "--version", help="show version and exit", action="store_true")
     parser.add_argument("-l", "--logfile", help="the filename to send logs to")
     parser.add_argument(
         "-m",
@@ -34,12 +32,12 @@ def main():
 
     args, unknown_args = parser.parse_known_args()
     if args.version:
-        print(f"Sublime Music v{sublime_music.__version__}")  # noqa: T001
+        print(f"Sublime Music v{sublime_music.__version__}")  # noqa: T201
         return
 
     min_log_level = getattr(logging, args.loglevel.upper(), None)
     if not isinstance(min_log_level, int):
-        print(f"Invalid log level: {args.loglevel.upper()}.")  # noqa: T001
+        print(f"Invalid log level: {args.loglevel.upper()}.")  # noqa: T201
         min_log_level = logging.WARNING
 
     logging.basicConfig(
diff --git a/sublime_music/adapters/adapter_base.py b/sublime_music/adapters/adapter_base.py
index 19fd1982..f64fb4d6 100644
--- a/sublime_music/adapters/adapter_base.py
+++ b/sublime_music/adapters/adapter_base.py
@@ -6,17 +6,7 @@
 from datetime import timedelta
 from enum import Enum
 from pathlib import Path
-from typing import (
-    Any,
-    cast,
-    Dict,
-    Iterable,
-    List,
-    Optional,
-    Sequence,
-    Set,
-    Tuple,
-)
+from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple, cast
 
 import gi
 
@@ -30,17 +20,8 @@
 except Exception:
     keyring_imported = False
 
-from .api_objects import (
-    Album,
-    Artist,
-    Directory,
-    Genre,
-    Playlist,
-    PlayQueue,
-    SearchResult,
-    Song,
-)
 from ..util import this_decade
+from .api_objects import Album, Artist, Directory, Genre, Playlist, PlayQueue, SearchResult, Song
 
 
 class SongCacheStatus(Enum):
@@ -187,9 +168,7 @@ def __repr__(self) -> str:
 
     def clone(self) -> "ConfigurationStore":
         configuration_store = ConfigurationStore(**copy.deepcopy(self))
-        configuration_store._changed_secrets_store = copy.deepcopy(
-            self._changed_secrets_store
-        )
+        configuration_store._changed_secrets_store = copy.deepcopy(self._changed_secrets_store)
         return configuration_store
 
     def persist_secrets(self):
@@ -230,7 +209,7 @@ def get_secret(self, key: str) -> Optional[str]:
             "plaintext": lambda: storage_key,
         }[storage_type]()
 
-    def set_secret(self, key: str, value: str = None):
+    def set_secret(self, key: str, value: str | None = None):
         """
         Set the secret value of the given key in the store. This should be used for
         things such as passwords or API tokens. When :class:`persist_secrets` is called,
@@ -349,8 +328,7 @@ def can_be_cached(self) -> bool:
         return True
 
     @property
-    @staticmethod
-    def can_be_ground_truth() -> bool:
+    def can_be_ground_truth(self) -> bool:
         """
         Whether or not this adapter can be used as a ground truth adapter.
         """
@@ -369,6 +347,7 @@ def is_networked(self) -> bool:
         """
         return True
 
+    @abc.abstractmethod
     def on_offline_mode_change(self, offline_mode: bool):
         """
         This function should be used to handle any operations that need to be performed
@@ -591,7 +570,7 @@ def get_playlist_details(self, playlist_id: str) -> Playlist:
         raise self._check_can_error("get_playlist_details")
 
     def create_playlist(
-        self, name: str, songs: Sequence[Song] = None
+        self, name: str, songs: Sequence[Song] | None = None
     ) -> Optional[Playlist]:
         """
         Creates a playlist of the given name with the given songs.
@@ -607,10 +586,10 @@ def create_playlist(
     def update_playlist(
         self,
         playlist_id: str,
-        name: str = None,
-        comment: str = None,
-        public: bool = None,
-        song_ids: Sequence[str] = None,
+        name: str | None = None,
+        comment: str | None = None,
+        public: bool | None = None,
+        song_ids: Sequence[str] | None = None,
     ) -> Playlist:
         """
         Updates a given playlist. If a parameter is ``None``, then it will be ignored
@@ -780,8 +759,8 @@ def get_play_queue(self) -> Optional[PlayQueue]:
     def save_play_queue(
         self,
         song_ids: Sequence[str],
-        current_song_index: int = None,
-        position: timedelta = None,
+        current_song_index: int | None = None,
+        position: timedelta | None = None,
     ):
         """
         Save the current play queue to the cloud.
@@ -916,9 +895,7 @@ def delete_data(self, data_key: CachedDataKey, param: Optional[str]):
     # Cache-Specific Methods
     # ==================================================================================
     @abc.abstractmethod
-    def get_cached_statuses(
-        self, song_ids: Sequence[str]
-    ) -> Dict[str, SongCacheStatus]:
+    def get_cached_statuses(self, song_ids: Sequence[str]) -> Dict[str, SongCacheStatus]:
         """
         Returns the cache statuses for the given list of songs. See the
         :class:`SongCacheStatus` documentation for more details about what each status
diff --git a/sublime_music/adapters/api_objects.py b/sublime_music/adapters/api_objects.py
index 9d70249c..c15656f5 100644
--- a/sublime_music/adapters/api_objects.py
+++ b/sublime_music/adapters/api_objects.py
@@ -8,7 +8,6 @@
 from typing import (
     Any,
     Callable,
-    cast,
     Dict,
     Iterable,
     List,
@@ -17,6 +16,7 @@
     Tuple,
     TypeVar,
     Union,
+    cast,
 )
 
 from thefuzz import fuzz
@@ -145,7 +145,7 @@ class SearchResult:
     both server and local results.
     """
 
-    def __init__(self, query: str = None):
+    def __init__(self, query: Optional[str] = None):
         self.query = query
         self.similiarity_partial = partial(
             similarity_ratio, self.query.lower() if self.query else ""
@@ -157,7 +157,7 @@ def __init__(self, query: str = None):
 
     def __repr__(self) -> str:
         fields = ("query", "_artists", "_albums", "_songs", "_playlists")
-        formatted_fields = map(lambda f: f"{f}={getattr(self, f)}", fields)
+        formatted_fields = (f"{f}={getattr(self, f)}" for f in fields)
         return f"<SearchResult {' '.join(formatted_fields)}>"
 
     def add_results(self, result_type: str, results: Iterable):
@@ -190,9 +190,7 @@ def _to_result(
                 continue
 
             max_similarity = max(
-                self.similiarity_partial(t.lower())
-                for t in transformed
-                if t is not None
+                self.similiarity_partial(t.lower()) for t in transformed if t is not None
             )
             if max_similarity < 60:
                 continue
@@ -225,15 +223,11 @@ def _try_get_artist_name(self, obj: Union[Album, Song]) -> Optional[str]:
 
     @property
     def albums(self) -> List[Album]:
-        return self._to_result(
-            self._albums, lambda a: (a.name, self._try_get_artist_name(a))
-        )
+        return self._to_result(self._albums, lambda a: (a.name, self._try_get_artist_name(a)))
 
     @property
     def songs(self) -> List[Song]:
-        return self._to_result(
-            self._songs, lambda s: (s.title, self._try_get_artist_name(s))
-        )
+        return self._to_result(self._songs, lambda s: (s.title, self._try_get_artist_name(s)))
 
     @property
     def playlists(self) -> List[Playlist]:
diff --git a/sublime_music/adapters/configure_server_form.py b/sublime_music/adapters/configure_server_form.py
index 47427b16..4ac3f0e6 100644
--- a/sublime_music/adapters/configure_server_form.py
+++ b/sublime_music/adapters/configure_server_form.py
@@ -6,10 +6,9 @@
 from functools import partial
 from pathlib import Path
 from time import sleep
-from typing import Any, Callable, cast, Dict, Iterable, Optional, Tuple, Type, Union
+from typing import Any, Callable, Dict, Iterable, Optional, Tuple, Type, Union, cast
 
 import bleach
-
 from gi.repository import GLib, GObject, Gtk, Pango
 
 from . import ConfigurationStore
@@ -220,9 +219,7 @@ def toggle_expander(*args):
                 orientation=Gtk.Orientation.HORIZONTAL, spacing=10
             )
 
-            advanced_label = Gtk.Label(
-                label="<b>Advanced Settings</b>", use_markup=True
-            )
+            advanced_label = Gtk.Label(label="<b>Advanced Settings</b>", use_markup=True)
             advanced_expander_button_box.add(advanced_label)
             advanced_expander_button_box.add(advanced_expander_icon)
 
@@ -252,7 +249,7 @@ def toggle_expander(*args):
     verifying_in_progress = False
 
     def _set_verification_status(
-        self, verifying: bool, is_valid: bool = False, error_text: str = None
+        self, verifying: bool, is_valid: bool = False, error_text: str | None = None
     ):
         if verifying:
             if not self.verifying_in_progress:
@@ -262,9 +259,7 @@ def _set_verification_status(
                     Gtk.Spinner(active=True, name="verify-config-spinner")
                 )
                 self.config_verification_box.add(
-                    Gtk.Label(
-                        label="<b>Verifying configuration...</b>", use_markup=True
-                    )
+                    Gtk.Label(label="<b>Verifying configuration...</b>", use_markup=True)
                 )
             self.verifying_in_progress = True
         else:
@@ -285,9 +280,7 @@ def set_icon_and_label(icon_name: str, label_text: str):
                 self.config_verification_box.add(label)
 
             if is_valid:
-                set_icon_and_label(
-                    "config-ok-symbolic", "<b>Configuration is valid</b>"
-                )
+                set_icon_and_label("config-ok-symbolic", "<b>Configuration is valid</b>")
             elif escaped := bleach.clean(error_text or ""):
                 set_icon_and_label("config-error-symbolic", escaped)
 
@@ -363,6 +356,4 @@ def verify_with_delay() -> Dict[str, Optional[str]]:
                 return self.verify_configuration()
 
             errors_result: Result[Dict[str, Optional[str]]] = Result(verify_with_delay)
-            errors_result.add_done_callback(
-                lambda f: GLib.idle_add(on_verify_result, f.result())
-            )
+            errors_result.add_done_callback(lambda f: GLib.idle_add(on_verify_result, f.result()))
diff --git a/sublime_music/adapters/filesystem/adapter.py b/sublime_music/adapters/filesystem/adapter.py
index 171e907b..0e145664 100644
--- a/sublime_music/adapters/filesystem/adapter.py
+++ b/sublime_music/adapters/filesystem/adapter.py
@@ -4,14 +4,14 @@
 import threading
 from datetime import datetime
 from pathlib import Path
-from typing import Any, cast, Dict, Iterable, Optional, Sequence, Set, Tuple
+from typing import Any, Dict, Iterable, Optional, Sequence, Set, Tuple, cast
 
+import peewee
 from gi.repository import Gtk
 from peewee import fn, prefetch
 
 from sublime_music.adapters import api_objects as API
 
-from . import models
 from .. import (
     AlbumSearchQuery,
     CacheMissError,
@@ -22,6 +22,7 @@
     SongCacheStatus,
     UIInfo,
 )
+from . import models
 
 KEYS = CachingAdapter.CachedDataKey
 
@@ -97,6 +98,9 @@ def _migrate_db(self):
     can_be_ground_truth = False  # TODO (#188)
     is_networked = False  # Doesn't access the network.
 
+    def on_offline_mode_change(self, _: bool):
+        pass
+
     # TODO (#200) make these dependent on cache state. Need to do this kinda efficiently
     can_get_cover_art_uri = True
     can_get_song_file_uri = True
@@ -153,7 +157,7 @@ def _get_list(
         model: Any,
         cache_key: CachingAdapter.CachedDataKey,
         ignore_cache_miss: bool = False,
-        where_clauses: Tuple[Any, ...] = None,
+        where_clauses: Tuple[Any, ...] | None = None,
         order_by: Any = None,
     ) -> Sequence:
         result = model.select()
@@ -202,7 +206,7 @@ def _compute_song_filename(self, cache_info: models.CacheInfo) -> Path:
                 # Make sure that the path is somewhere in the cache directory and a
                 # malicious server (or MITM attacker) isn't trying to override files in
                 # other parts of the system.
-                path = self.music_dir.joinpath(path_str)
+                path = self.music_dir.joinpath(str(path_str))
                 if self.music_dir in path.parents:
                     return path
         except Exception:
@@ -210,16 +214,14 @@ def _compute_song_filename(self, cache_info: models.CacheInfo) -> Path:
 
         # Fall back to using the song file hash as the filename. This shouldn't happen
         # with good servers, but just to be safe.
-        return self.music_dir.joinpath(cache_info.file_hash)
+        return self.music_dir.joinpath(str(cache_info.file_hash))
 
     # Data Retrieval Methods
     # ==================================================================================
-    def get_cached_statuses(
-        self, song_ids: Sequence[str]
-    ) -> Dict[str, SongCacheStatus]:
+    def get_cached_statuses(self, song_ids: Sequence[str]) -> Dict[str, SongCacheStatus]:
         def compute_song_cache_status(song: models.Song) -> SongCacheStatus:
             try:
-                file = song.file
+                file = cast(models.CacheInfo, song.file)
                 if self._compute_song_filename(file).exists():
                     if file.valid:
                         if file.cache_permanently:
@@ -240,10 +242,7 @@ def compute_song_cache_status(song: models.Song) -> SongCacheStatus:
             )
             song_models = models.Song.select().where(models.Song.id.in_(song_ids))
             cached_statuses.update(
-                {
-                    s.id: compute_song_cache_status(s)
-                    for s in prefetch(song_models, file_models)
-                }
+                {s.id: compute_song_cache_status(s) for s in prefetch(song_models, file_models)}
             )
         except Exception:
             pass
@@ -293,21 +292,19 @@ def get_song_file_uri(self, song_id: str, schemes: Iterable[str]) -> str:
                 raise Exception(f"Song {song_id} does not exist.")
 
         try:
-            if (song_file := song.file) and (
-                filename := self._compute_song_filename(song_file)
-            ):
+            if (song_file := song.file) and (filename := self._compute_song_filename(song_file)):
                 if filename.exists():
                     file_uri = f"file://{filename}"
                     if song_file.valid:
                         return file_uri
                     else:
                         raise CacheMissError(partial_data=file_uri)
-        except models.CacheInfo.DoesNotExist:
+        except peewee.DoesNotExist:
             pass
 
         raise CacheMissError()
 
-    def get_song_details(self, song_id: str) -> models.Song:
+    def get_song_details(self, song_id: str) -> API.Song:
         return self._get_object_details(
             models.Song,
             song_id,
@@ -353,9 +350,7 @@ def get_albums(
         # If we haven't ever cached the query result, try to construct one, and return
         # it as a CacheMissError result.
 
-        sql_query = models.Album.select().where(
-            ~(models.Album.id.startswith("invalid:"))
-        )
+        sql_query = models.Album.select().where(~(models.Album.id.startswith("invalid:")))
 
         Type = AlbumSearchQuery.Type
         if query.type == Type.GENRE:
@@ -393,21 +388,17 @@ def get_all_albums(self) -> Sequence[API.Album]:
         )
 
     def get_album(self, album_id: str) -> API.Album:
-        return self._get_object_details(
-            models.Album, album_id, CachingAdapter.CachedDataKey.ALBUM
-        )
+        return self._get_object_details(models.Album, album_id, CachingAdapter.CachedDataKey.ALBUM)
 
     def get_ignored_articles(self) -> Set[str]:
-        return set(
-            map(
-                lambda i: i.name,
-                self._get_list(
-                    models.IgnoredArticle, CachingAdapter.CachedDataKey.IGNORED_ARTICLES
-                ),
+        return {
+            i.name
+            for i in self._get_list(
+                models.IgnoredArticle, CachingAdapter.CachedDataKey.IGNORED_ARTICLES
             )
-        )
+        }
 
-    def get_directory(self, directory_id: str) -> models.Directory:
+    def get_directory(self, directory_id: str) -> API.Directory:
         return self._get_object_details(
             models.Directory, directory_id, CachingAdapter.CachedDataKey.DIRECTORY
         )
@@ -478,9 +469,7 @@ def _do_ingest_new_data(
         # TODO (#201): this entire function is not exactly efficient due to the nested
         # dependencies and everything. I'm not sure how to improve it, and I'm not sure
         # if it needs improving at this point.
-        logging.debug(
-            f"_do_ingest_new_data param={param} data_key={data_key} data={data}"
-        )
+        logging.debug(f"_do_ingest_new_data param={param} data_key={data_key} data={data}")
 
         def getattrs(obj: Any, keys: Iterable[str]) -> Dict[str, Any]:
             return {k: getattr(obj, k) for k in keys}
@@ -544,9 +533,7 @@ def compute_file_hash(filename: str) -> str:
                     ],
                 ),
                 "genre": (
-                    self._do_ingest_new_data(KEYS.GENRE, None, g)
-                    if (g := album.genre)
-                    else None
+                    self._do_ingest_new_data(KEYS.GENRE, None, g) if (g := album.genre) else None
                 ),
                 "artist": (
                     self._do_ingest_new_data(KEYS.ARTIST, ar.id, ar, partial=True)
@@ -554,25 +541,18 @@ def compute_file_hash(filename: str) -> str:
                     else None
                 ),
                 "_songs": (
-                    [
-                        self._do_ingest_new_data(KEYS.SONG, s.id, s)
-                        for s in album.songs or []
-                    ]
+                    [self._do_ingest_new_data(KEYS.SONG, s.id, s) for s in album.songs or []]
                     if not partial
                     else None
                 ),
                 "_cover_art": (
-                    self._do_ingest_new_data(
-                        KEYS.COVER_ART_FILE, album.cover_art, data=None
-                    )
+                    self._do_ingest_new_data(KEYS.COVER_ART_FILE, album.cover_art, data=None)
                     if album.cover_art
                     else None
                 ),
             }
 
-            db_album, created = models.Album.get_or_create(
-                id=album_id, defaults=album_data
-            )
+            db_album, created = models.Album.get_or_create(id=album_id, defaults=album_data)
 
             if not created:
                 setattrs(db_album, album_data)
@@ -581,10 +561,7 @@ def compute_file_hash(filename: str) -> str:
             return_val = db_album
 
         elif data_key == KEYS.ALBUMS:
-            albums = [
-                self._do_ingest_new_data(KEYS.ALBUM, a.id, a, partial=True)
-                for a in data
-            ]
+            albums = [self._do_ingest_new_data(KEYS.ALBUM, a.id, a, partial=True) for a in data]
             album_query_result, created = models.AlbumQueryResult.get_or_create(
                 query_hash=param, defaults={"query_hash": param, "albums": albums}
             )
@@ -641,9 +618,7 @@ def compute_file_hash(filename: str) -> str:
                 ),
             }
 
-            db_artist, created = models.Artist.get_or_create(
-                id=artist_id, defaults=artist_data
-            )
+            db_artist, created = models.Artist.get_or_create(id=artist_id, defaults=artist_data)
 
             if not created:
                 setattrs(db_artist, artist_data)
@@ -671,9 +646,7 @@ def compute_file_hash(filename: str) -> str:
 
         elif data_key == KEYS.DIRECTORY:
             api_directory = cast(API.Directory, data)
-            directory_data: Dict[str, Any] = getattrs(
-                api_directory, ["id", "name", "parent_id"]
-            )
+            directory_data: Dict[str, Any] = getattrs(api_directory, ["id", "name", "parent_id"])
 
             if not partial:
                 directory_data["directory_children"] = []
@@ -681,9 +654,7 @@ def compute_file_hash(filename: str) -> str:
                 for c in api_directory.children:
                     if hasattr(c, "children"):  # directory
                         directory_data["directory_children"].append(
-                            self._do_ingest_new_data(
-                                KEYS.DIRECTORY, c.id, c, partial=True
-                            )
+                            self._do_ingest_new_data(KEYS.DIRECTORY, c.id, c, partial=True)
                         )
                     else:
                         directory_data["song_children"].append(
@@ -707,9 +678,7 @@ def compute_file_hash(filename: str) -> str:
         elif data_key == KEYS.GENRE:
             api_genre = cast(API.Genre, data)
             genre_data = getattrs(api_genre, ["name", "song_count", "album_count"])
-            genre, created = models.Genre.get_or_create(
-                name=api_genre.name, defaults=genre_data
-            )
+            genre, created = models.Genre.get_or_create(name=api_genre.name, defaults=genre_data)
 
             if not created:
                 setattrs(genre, genre_data)
@@ -719,11 +688,9 @@ def compute_file_hash(filename: str) -> str:
 
         elif data_key == KEYS.IGNORED_ARTICLES:
             models.IgnoredArticle.insert_many(
-                map(lambda s: {"name": s}, data)
+                {"name": s} for s in data
             ).on_conflict_replace().execute()
-            models.IgnoredArticle.delete().where(
-                models.IgnoredArticle.name.not_in(data)
-            ).execute()
+            models.IgnoredArticle.delete().where(models.IgnoredArticle.name.not_in(data)).execute()
 
         elif data_key == KEYS.PLAYLIST_DETAILS:
             api_playlist = cast(API.Playlist, data)
@@ -743,9 +710,7 @@ def compute_file_hash(filename: str) -> str:
                     ],
                 ),
                 "_cover_art": (
-                    self._do_ingest_new_data(
-                        KEYS.COVER_ART_FILE, api_playlist.cover_art, None
-                    )
+                    self._do_ingest_new_data(KEYS.COVER_ART_FILE, api_playlist.cover_art, None)
                     if api_playlist.cover_art
                     else None
                 ),
@@ -754,8 +719,7 @@ def compute_file_hash(filename: str) -> str:
             if not partial:
                 # If it's partial, then don't ingest the songs.
                 playlist_data["_songs"] = [
-                    self._do_ingest_new_data(KEYS.SONG, s.id, s)
-                    for s in api_playlist.songs
+                    self._do_ingest_new_data(KEYS.SONG, s.id, s) for s in api_playlist.songs
                 ]
 
             playlist, playlist_created = models.Playlist.get_or_create(
@@ -797,9 +761,7 @@ def compute_file_hash(filename: str) -> str:
                 api_song, ["id", "title", "track", "year", "duration", "parent_id"]
             )
             song_data["genre"] = (
-                self._do_ingest_new_data(KEYS.GENRE, None, g)
-                if (g := api_song.genre)
-                else None
+                self._do_ingest_new_data(KEYS.GENRE, None, g) if (g := api_song.genre) else None
             )
             song_data["artist"] = (
                 self._do_ingest_new_data(KEYS.ARTIST, ar.id, ar, partial=True)
@@ -830,9 +792,7 @@ def compute_file_hash(filename: str) -> str:
                 else None
             )
 
-            song, created = models.Song.get_or_create(
-                id=song_data["id"], defaults=song_data
-            )
+            song, created = models.Song.get_or_create(id=song_data["id"], defaults=song_data)
 
             if not created:
                 setattrs(song, song_data)
@@ -888,9 +848,7 @@ def _do_invalidate_data(
             if artist := models.Artist.get_or_none(models.Artist.id == param):
                 self._do_invalidate_data(KEYS.COVER_ART_FILE, artist.artist_image_url)
                 for album in artist.albums or []:
-                    self._do_invalidate_data(
-                        CachingAdapter.CachedDataKey.ALBUM, album.id
-                    )
+                    self._do_invalidate_data(CachingAdapter.CachedDataKey.ALBUM, album.id)
 
         elif data_key == KEYS.PLAYLIST_DETAILS:
             # Invalidate the corresponding cover art.
@@ -902,9 +860,7 @@ def _do_invalidate_data(
             if song := models.Song.get_or_none(models.Song.id == param):
                 self._do_invalidate_data(KEYS.COVER_ART_FILE, song.cover_art)
 
-    def _do_delete_data(
-        self, data_key: CachingAdapter.CachedDataKey, param: Optional[str]
-    ):
+    def _do_delete_data(self, data_key: CachingAdapter.CachedDataKey, param: Optional[str]):
         logging.debug(f"_do_delete_data param={param} data_key={data_key}")
         cache_info = models.CacheInfo.get_or_none(
             models.CacheInfo.cache_key == data_key,
@@ -913,9 +869,7 @@ def _do_delete_data(
 
         if data_key == KEYS.COVER_ART_FILE:
             if cache_info:
-                self.cover_art_dir.joinpath(str(cache_info.file_hash)).unlink(
-                    missing_ok=True
-                )
+                self.cover_art_dir.joinpath(str(cache_info.file_hash)).unlink(missing_ok=True)
 
         elif data_key == KEYS.PLAYLIST_DETAILS:
             # Delete the playlist and corresponding cover art.
diff --git a/sublime_music/adapters/filesystem/models.py b/sublime_music/adapters/filesystem/models.py
index 475414c0..e0a82af6 100644
--- a/sublime_music/adapters/filesystem/models.py
+++ b/sublime_music/adapters/filesystem/models.py
@@ -6,10 +6,10 @@
     ForeignKeyField,
     IntegerField,
     Model,
-    prefetch,
     Query,
     SqliteDatabase,
     TextField,
+    prefetch,
 )
 
 from .sqlite_extensions import (
@@ -119,7 +119,12 @@ def songs(self) -> List["Song"]:
         albums = Album.select()
         artists = Album.select()
         return sorted(
-            prefetch(self._songs, albums, artists),
+            prefetch(
+                # _songs is a backref from Song
+                self._songs,  # type: ignore
+                albums,
+                artists,
+            ),
             key=lambda s: (s.disc_number or 1, s.track),
         )
 
@@ -143,9 +148,9 @@ class Directory(BaseModel):
     @property
     def children(self) -> List[Union["Directory", "Song"]]:
         if not self._children:
-            self._children = list(
-                Directory.select().where(Directory.parent_id == self.id)
-            ) + list(Song.select().where(Song.parent_id == self.id))
+            self._children = list(Directory.select().where(Directory.parent_id == self.id)) + list(
+                Song.select().where(Song.parent_id == self.id)
+            )
         return self._children
 
     @children.setter
diff --git a/sublime_music/adapters/filesystem/sqlite_extensions.py b/sublime_music/adapters/filesystem/sqlite_extensions.py
index efaa1f88..06673bc6 100644
--- a/sublime_music/adapters/filesystem/sqlite_extensions.py
+++ b/sublime_music/adapters/filesystem/sqlite_extensions.py
@@ -1,9 +1,9 @@
 from datetime import datetime, timedelta
 from typing import Any, Optional, Sequence
 
-from peewee import (
+from peewee import ensure_tuple  # type: ignore
+from peewee import (  # type: ignore
     DoubleField,
-    ensure_tuple,
     ForeignKeyField,
     IntegerField,
     ManyToManyField,
@@ -50,8 +50,8 @@ def add(self, value: Sequence[Any], clear_existing: bool = False):
         if clear_existing:
             self.clear()
 
-        accessor = self._accessor
-        src_id = getattr(self._instance, self._src_attr)
+        accessor = self._accessor  # type: ignore
+        src_id = getattr(self._instance, self._src_attr)  # type: ignore
         assert not isinstance(value, SelectQuery)
         value = ensure_tuple(value)
         if not value:
@@ -63,7 +63,7 @@ def add(self, value: Sequence[Any], clear_existing: bool = False):
                 accessor.dest_fk.name: rel_id,
                 "position": i,
             }
-            for i, rel_id in enumerate(self._id_list(value))
+            for i, rel_id in enumerate(self._id_list(value))  # type: ignore
         ]
         accessor.through_model.insert_many(inserts).execute()
 
@@ -84,9 +84,9 @@ def __get__(
 
             src_id = getattr(instance, self.src_fk.rel_field.name)
             return (
-                SortedManyToManyQuery(instance, self, self.rel_model)
+                SortedManyToManyQuery(instance, self, self.rel_model)  # type: ignore
                 .join(self.through_model)
-                .join(self.model)
+                .join(self.model)  # type: ignore
                 .where(self.src_fk == src_id)
                 .order_by(self.through_model.position)
             )
@@ -111,7 +111,7 @@ class Meta:
             table_name = "{}_{}_through".format(*tables)
             indexes = (((lhs._meta.name, rhs._meta.name, "position"), True),)
 
-        params = {"on_delete": self._on_delete, "on_update": self._on_update}
+        params = {"on_delete": self._on_delete, "on_update": self._on_update}  # type: ignore
         attrs = {
             lhs._meta.name: ForeignKeyField(lhs, **params),
             rhs._meta.name: ForeignKeyField(rhs, **params),
diff --git a/sublime_music/adapters/manager.py b/sublime_music/adapters/manager.py
index 36cd771a..adf3bfff 100644
--- a/sublime_music/adapters/manager.py
+++ b/sublime_music/adapters/manager.py
@@ -15,7 +15,6 @@
 from typing import (
     Any,
     Callable,
-    cast,
     Dict,
     Generic,
     Iterable,
@@ -26,12 +25,14 @@
     Tuple,
     TypeVar,
     Union,
+    cast,
 )
 
 import requests
 
 from sublime_music.config import ProviderConfiguration
 
+from ..util import resolve_path
 from .adapter_base import (
     Adapter,
     AlbumSearchQuery,
@@ -39,19 +40,9 @@
     CachingAdapter,
     SongCacheStatus,
 )
-from .api_objects import (
-    Album,
-    Artist,
-    Directory,
-    Genre,
-    Playlist,
-    PlayQueue,
-    SearchResult,
-    Song,
-)
+from .api_objects import Album, Artist, Directory, Genre, Playlist, PlayQueue, SearchResult, Song
 from .filesystem import FilesystemAdapter
 from .subsonic import SubsonicAdapter
-from ..util import resolve_path
 
 REQUEST_DELAY: Optional[Tuple[float, float]] = None
 if delay_str := os.environ.get("REQUEST_DELAY"):
@@ -90,8 +81,8 @@ def __init__(
         data_resolver: Union[T, Callable[[], T]],
         *args,
         is_download: bool = False,
-        default_value: T = None,
-        on_cancel: Callable[[], None] = None,
+        default_value: T | None = None,
+        on_cancel: Callable[[], None] | None = None,
     ):
         """
         Creates a :class:`Result` object.
@@ -103,9 +94,7 @@ def __init__(
         """
         if callable(data_resolver):
             if is_download:
-                self._future = AdapterManager.download_executor.submit(
-                    data_resolver, *args
-                )
+                self._future = AdapterManager.download_executor.submit(data_resolver, *args)
             else:
                 self._future = AdapterManager.executor.submit(data_resolver, *args)
             self._future.add_done_callback(self._on_future_complete)
@@ -135,7 +124,7 @@ def result(self) -> T:
             if self._future is not None:
                 return self._future.result()
 
-            assert 0, "AdapterManager.Result had neither _data nor _future member!"
+            raise Exception("AdapterManager.Result had neither _data nor _future member!")
         except Exception as e:
             if self._default_value:
                 return self._default_value
@@ -208,16 +197,14 @@ class AdapterManager:
     @dataclass
     class _AdapterManagerInternal:
         ground_truth_adapter: Adapter
-        on_song_download_progress: Callable[[Any, str, DownloadProgress], None]
+        on_song_download_progress: Callable[[str, DownloadProgress], None]
         caching_adapter: Optional[CachingAdapter] = None
         concurrent_download_limit: int = 5
 
         def __post_init__(self):
             self._download_dir = tempfile.TemporaryDirectory()
             self.download_path = Path(self._download_dir.name)
-            self.download_limiter_semaphore = threading.Semaphore(
-                self.concurrent_download_limit
-            )
+            self.download_limiter_semaphore = threading.Semaphore(self.concurrent_download_limit)
 
         def song_download_progress(self, file_id: str, progress: DownloadProgress):
             self.on_song_download_progress(file_id, progress)
@@ -271,7 +258,7 @@ def shutdown():
     @staticmethod
     def reset(
         config: Any,
-        on_song_download_progress: Callable[[Any, str, DownloadProgress], None],
+        on_song_download_progress: Callable[[str, DownloadProgress], None],
     ):
         from sublime_music.config import AppConfiguration
 
@@ -352,7 +339,7 @@ def _can_use_cache(force: bool, action_name: str) -> bool:
     def _create_ground_truth_result(
         function_name: str,
         *params: Any,
-        before_download: Callable[[], None] = None,
+        before_download: Callable[[], None] | None = None,
         partial_data: Any = None,
         **kwargs,
     ) -> Result:
@@ -382,8 +369,8 @@ def future_fn() -> Any:
     def _create_download_result(
         uri: str,
         id: str,
-        before_download: Callable[[], None] = None,
-        expected_size: int = None,
+        before_download: Callable[[], None] | None = None,
+        expected_size: int | None = None,
         **result_args,
     ) -> Result[str]:
         """
@@ -435,9 +422,7 @@ def download_fn() -> str:
                 try:
                     if REQUEST_DELAY is not None:
                         delay = random.uniform(*REQUEST_DELAY)
-                        logging.info(
-                            f"REQUEST_DELAY enabled. Pausing for {delay} seconds"
-                        )
+                        logging.info(f"REQUEST_DELAY enabled. Pausing for {delay} seconds")
                         sleep(delay)
 
                     if NETWORK_ALWAYS_ERROR:
@@ -516,9 +501,7 @@ def on_download_cancel():
             nonlocal download_cancelled
             download_cancelled = True
 
-        return Result(
-            download_fn, is_download=True, on_cancel=on_download_cancel, **result_args
-        )
+        return Result(download_fn, is_download=True, on_cancel=on_download_cancel, **result_args)
 
     @staticmethod
     def _create_caching_done_callback(
@@ -534,18 +517,14 @@ def _create_caching_done_callback(
         def future_finished(f: Result):
             assert AdapterManager._instance
             assert AdapterManager._instance.caching_adapter
-            AdapterManager._instance.caching_adapter.ingest_new_data(
-                cache_key, param, f.result()
-            )
+            AdapterManager._instance.caching_adapter.ingest_new_data(cache_key, param, f.result())
 
         return future_finished
 
     @staticmethod
     def get_supported_artist_query_types() -> Set[AlbumSearchQuery.Type]:
         assert AdapterManager._instance
-        return (
-            AdapterManager._instance.ground_truth_adapter.supported_artist_query_types
-        )
+        return AdapterManager._instance.ground_truth_adapter.supported_artist_query_types
 
     R = TypeVar("R")
 
@@ -553,13 +532,13 @@ def get_supported_artist_query_types() -> Set[AlbumSearchQuery.Type]:
     def _get_from_cache_or_ground_truth(
         function_name: str,
         param: Optional[Union[str, AlbumSearchQuery]],
-        cache_key: CachingAdapter.CachedDataKey = None,
-        before_download: Callable[[], None] = None,
+        cache_key: CachingAdapter.CachedDataKey | None = None,
+        before_download: Callable[[], None] | None = None,
         use_ground_truth_adapter: bool = False,
         allow_download: bool = True,
-        on_result_finished: Callable[[Result], None] = None,
+        on_result_finished: Callable[[Result], None] | None = None,
         **kwargs: Any,
-    ) -> Result[R]:
+    ) -> Result:
         """
         Get data from one of the adapters.
 
@@ -592,18 +571,11 @@ def _get_from_cache_or_ground_truth(
                 logging.exception(f"Error on {function_name} retrieving from cache.")
 
         param_str = param.strhash() if isinstance(param, AlbumSearchQuery) else param
-        if (
-            cache_key
-            and AdapterManager._instance.caching_adapter
-            and use_ground_truth_adapter
-        ):
-            AdapterManager._instance.caching_adapter.invalidate_data(
-                cache_key, param_str
-            )
+        if cache_key and AdapterManager._instance.caching_adapter and use_ground_truth_adapter:
+            AdapterManager._instance.caching_adapter.invalidate_data(cache_key, param_str)
 
         if (
-            not allow_download
-            and AdapterManager._instance.ground_truth_adapter.is_networked
+            not allow_download and AdapterManager._instance.ground_truth_adapter.is_networked
         ) or not AdapterManager._ground_truth_can_do(function_name):
             logging.info(f"END: NO DOWNLOAD: {function_name}")
 
@@ -612,7 +584,7 @@ def cache_miss_result():
 
             return Result(cache_miss_result)
 
-        result: Result[AdapterManager.R] = AdapterManager._create_ground_truth_result(
+        result = AdapterManager._create_ground_truth_result(
             function_name,
             *((param,) if param is not None else ()),
             before_download=before_download,
@@ -735,7 +707,7 @@ def get_playlist_details(
 
     @staticmethod
     def create_playlist(
-        name: str, songs: Sequence[Song] = None
+        name: str, songs: Sequence[Song] | None = None
     ) -> Result[Optional[Playlist]]:
         def on_result_finished(f: Result[Optional[Playlist]]):
             assert AdapterManager._instance
@@ -762,11 +734,11 @@ def on_result_finished(f: Result[Optional[Playlist]]):
     @staticmethod
     def update_playlist(
         playlist_id: str,
-        name: str = None,
-        comment: str = None,
+        name: str | None = None,
+        comment: str | None = None,
         public: bool = False,
-        song_ids: Sequence[str] = None,
-        append_song_ids: Sequence[str] = None,
+        song_ids: Sequence[str] | None = None,
+        append_song_ids: Sequence[str] | None = None,
         before_download: Callable[[], None] = lambda: None,
     ) -> Result[Playlist]:
         return AdapterManager._get_from_cache_or_ground_truth(
@@ -787,9 +759,7 @@ def delete_playlist(playlist_id: str):
         assert AdapterManager._instance
         ground_truth_adapter = AdapterManager._instance.ground_truth_adapter
         if AdapterManager._offline_mode and ground_truth_adapter.is_networked:
-            raise AssertionError(
-                "You should never call delete_playlist in offline mode"
-            )
+            raise AssertionError("You should never call delete_playlist in offline mode")
 
         # TODO (#190): make non-blocking?
         ground_truth_adapter.delete_playlist(playlist_id)
@@ -813,21 +783,16 @@ def get_cover_art_uri(
         cover_art_id: Optional[str],
         scheme: str,
         size: int = 300,
-        before_download: Callable[[], None] = None,
+        before_download: Callable[[], None] | None = None,
         force: bool = False,
         allow_download: bool = True,
     ) -> Result[str]:
         existing_filename = str(resolve_path("adapters/images/default-album-art.png"))
-        if (
-            not AdapterManager._ground_truth_can_do("get_cover_art_uri")
-            or not cover_art_id
-        ):
+        if not AdapterManager._ground_truth_can_do("get_cover_art_uri") or not cover_art_id:
             return Result(existing_filename if scheme == "file" else "")
 
         assert AdapterManager._instance
-        supported_schemes = (
-            AdapterManager._instance.ground_truth_adapter.supported_schemes
-        )
+        supported_schemes = AdapterManager._instance.ground_truth_adapter.supported_schemes
 
         # If the scheme is supported natively, then return it.
         if scheme in supported_schemes:
@@ -837,9 +802,7 @@ def get_cover_art_uri(
             return Result(uri)
 
         # If the scheme is "file", then we may need to try to download.
-        if scheme == "file" and (
-            "http" in supported_schemes or "https" in supported_schemes
-        ):
+        if scheme == "file" and ("http" in supported_schemes or "https" in supported_schemes):
             if AdapterManager._can_use_cache(force, "get_cover_art_uri"):
                 assert AdapterManager._instance.caching_adapter
                 try:
@@ -853,9 +816,7 @@ def get_cover_art_uri(
                         existing_filename = cast(str, e.partial_data)
                     logging.info("Cache Miss on get_cover_art_uri.")
                 except Exception:
-                    logging.exception(
-                        "Error on get_cover_art_uri retrieving from cache."
-                    )
+                    logging.exception("Error on get_cover_art_uri retrieving from cache.")
 
             # If we are forcing, invalidate the existing cached data.
             if AdapterManager._instance.caching_adapter and force:
@@ -925,13 +886,11 @@ def get_song_stream_uri(song: Song) -> str:
         assert AdapterManager._instance
         if not AdapterManager._ground_truth_can_do("get_song_stream_uri"):
             raise Exception(f"Can't stream song '{song.title}'.")
-        return AdapterManager._instance.ground_truth_adapter.get_song_stream_uri(
-            song.id
-        )
+        return AdapterManager._instance.ground_truth_adapter.get_song_stream_uri(song.id)
 
     @staticmethod
     def batch_download_songs(
-        song_ids: Sequence[str],
+        song_ids: Iterable[str],
         before_download: Callable[[str], None],
         on_song_download_complete: Callable[[str], None],
         one_at_a_time: bool = False,
@@ -942,9 +901,7 @@ def batch_download_songs(
             AdapterManager._offline_mode
             and AdapterManager._instance.ground_truth_adapter.is_networked
         ):
-            raise AssertionError(
-                "You should never call batch_download_songs in offline mode"
-            )
+            raise AssertionError("You should never call batch_download_songs in offline mode")
 
         # This only really makes sense if we have a caching_adapter.
         if not AdapterManager._instance.caching_adapter:
@@ -975,9 +932,7 @@ def do_download_song(song_id: str) -> Result:
             # Download the actual song file.
             try:
                 # If the song file is already cached, just indicate done immediately.
-                AdapterManager._instance.caching_adapter.get_song_file_uri(
-                    song_id, "file"
-                )
+                AdapterManager._instance.caching_adapter.get_song_file_uri(song_id, "file")
                 AdapterManager._instance.download_limiter_semaphore.release()
                 AdapterManager._instance.song_download_progress(
                     song_id,
@@ -986,15 +941,12 @@ def do_download_song(song_id: str) -> Result:
                 return Result("", is_download=True)
             except CacheMissError:
                 # The song is not already cached.
-                if before_download:
-                    before_download(song_id)
+                before_download(song_id)
 
                 song = AdapterManager.get_song_details(song_id).result()
 
                 # Download the song.
-                song_tmp_filename_result: Result[
-                    str
-                ] = AdapterManager._create_download_result(
+                song_tmp_filename_result: Result[str] = AdapterManager._create_download_result(
                     AdapterManager._instance.ground_truth_adapter.get_song_file_uri(
                         song_id, AdapterManager._get_networked_scheme()
                     ),
@@ -1026,12 +978,9 @@ def on_download_done(f: Result):
 
         def do_batch_download_songs():
             sleep(delay)
-            if (
-                AdapterManager.is_shutting_down
-                or AdapterManager._offline_mode
-                or cancelled
-            ):
+            if AdapterManager.is_shutting_down or AdapterManager._offline_mode or cancelled:
                 return
+            assert AdapterManager._instance
 
             # Alert the UI that the downloads are queued.
             for song_id in song_ids:
@@ -1055,6 +1004,7 @@ def do_batch_download_songs():
         def on_cancel():
             nonlocal cancelled
             cancelled = True
+            assert AdapterManager._instance
 
             # Cancel the individual song downloads
             AdapterManager.cancel_download_songs(song_ids)
@@ -1069,7 +1019,7 @@ def on_cancel():
         return Result(do_batch_download_songs, is_download=True, on_cancel=on_cancel)
 
     @staticmethod
-    def cancel_download_songs(song_ids: Sequence[str]):
+    def cancel_download_songs(song_ids: Iterable[str]):
         assert AdapterManager._instance
         AdapterManager._cancelled_song_ids = AdapterManager._cancelled_song_ids.union(
             set(song_ids)
@@ -1097,9 +1047,7 @@ def batch_permanently_cache_songs(
         raise NotImplementedError()
 
     @staticmethod
-    def batch_delete_cached_songs(
-        song_ids: Sequence[str], on_song_delete: Callable[[str], None]
-    ):
+    def batch_delete_cached_songs(song_ids: Sequence[str], on_song_delete: Callable[[str], None]):
         assert AdapterManager._instance
 
         # This only really makes sense if we have a caching_adapter.
@@ -1196,9 +1144,7 @@ def sort_by_ignored_articles(
         key: Callable[[_S], str],
         use_ground_truth_adapter: bool = False,
     ) -> List[_S]:
-        ignored_articles = AdapterManager._get_ignored_articles(
-            use_ground_truth_adapter
-        )
+        ignored_articles = AdapterManager._get_ignored_articles(use_ground_truth_adapter)
         strip_fn = partial(
             AdapterManager._strip_ignored_articles,
             use_ground_truth_adapter,
@@ -1299,8 +1245,8 @@ def get_play_queue() -> Result[Optional[PlayQueue]]:
     @staticmethod
     def save_play_queue(
         song_ids: Sequence[str],
-        current_song_index: int = None,
-        position: timedelta = None,
+        current_song_index: int | None = None,
+        position: timedelta | None = None,
     ):
         assert AdapterManager._instance
         AdapterManager._create_ground_truth_result(
@@ -1344,12 +1290,8 @@ def do_search() -> bool:
             if AdapterManager._can_use_cache(False, "search"):
                 assert AdapterManager._instance.caching_adapter
                 try:
-                    logging.info(
-                        f"Returning caching adapter search results for '{query}'."
-                    )
-                    search_result.update(
-                        AdapterManager._instance.caching_adapter.search(query)
-                    )
+                    logging.info(f"Returning caching adapter search results for '{query}'.")
+                    search_result.update(AdapterManager._instance.caching_adapter.search(query))
                     search_callback(search_result)
                 except Exception:
                     logging.exception("Error on caching adapter search")
@@ -1359,25 +1301,19 @@ def do_search() -> bool:
 
             # Wait longer to see if the user types anything else so we don't peg the
             # server with tons of requests.
-            sleep(
-                1 if AdapterManager._instance.ground_truth_adapter.is_networked else 0.3
-            )
+            sleep(1 if AdapterManager._instance.ground_truth_adapter.is_networked else 0.3)
             if cancelled:
                 logging.info(f"Cancelled query {query} before server results")
                 return True
 
             try:
-                ground_truth_search_results = (
-                    AdapterManager._instance.ground_truth_adapter.search(  # noqa: E501
-                        query
-                    )
-                )
+                ground_truth_search_results = AdapterManager._instance.ground_truth_adapter.search(
+                    query
+                )  # noqa: E501
                 search_result.update(ground_truth_search_results)
                 search_callback(search_result)
             except Exception:
-                logging.exception(
-                    "Failed getting search results from server for query '{query}'"
-                )
+                logging.exception("Failed getting search results from server for query '{query}'")
 
             if AdapterManager._instance.caching_adapter:
                 AdapterManager._instance.caching_adapter.ingest_new_data(
@@ -1404,9 +1340,7 @@ def get_cached_statuses(song_ids: Sequence[str]) -> Sequence[SongCacheStatus]:
         if not AdapterManager._instance.caching_adapter:
             return list(itertools.repeat(SongCacheStatus.NOT_CACHED, len(song_ids)))
 
-        cached_statuses = AdapterManager._instance.caching_adapter.get_cached_statuses(
-            song_ids
-        )
+        cached_statuses = AdapterManager._instance.caching_adapter.get_cached_statuses(song_ids)
         return [
             SongCacheStatus.DOWNLOADING
             if (
diff --git a/sublime_music/adapters/subsonic/adapter.py b/sublime_music/adapters/subsonic/adapter.py
index 93cb4233..d23dc844 100644
--- a/sublime_music/adapters/subsonic/adapter.py
+++ b/sublime_music/adapters/subsonic/adapter.py
@@ -11,18 +11,7 @@
 from datetime import datetime, timedelta
 from pathlib import Path
 from time import sleep
-from typing import (
-    Any,
-    cast,
-    Dict,
-    Iterable,
-    List,
-    Optional,
-    Sequence,
-    Set,
-    Tuple,
-    Union,
-)
+from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple, Union, cast
 from urllib.parse import urlencode, urlparse
 
 import requests
@@ -31,16 +20,16 @@
 
 from sublime_music.util import resolve_path
 
-from .api_objects import Directory, Response
 from .. import (
     Adapter,
     AlbumSearchQuery,
-    api_objects as API,
     ConfigParamDescriptor,
     ConfigurationStore,
     ConfigureServerForm,
     UIInfo,
+    api_objects as API,
 )
+from .api_objects import Directory, Response
 
 try:
     import gi
@@ -178,9 +167,7 @@ def verify_configuration() -> Dict[str, Optional[str]]:
                         #   LDAP users." so fall back to password auth
                         try:
                             config_store["salt_auth"] = False
-                            tmp_adapter = SubsonicAdapter(
-                                config_store, Path(tmp_dir_name)
-                            )
+                            tmp_adapter = SubsonicAdapter(config_store, Path(tmp_dir_name))
                             tmp_adapter._get_json(
                                 tmp_adapter._make_url("ping"),
                                 timeout=2,
@@ -215,9 +202,7 @@ def migrate_configuration(config_store: ConfigurationStore):
 
     def __init__(self, config: ConfigurationStore, data_directory: Path):
         self.data_directory = data_directory
-        self.ignored_articles_cache_file = self.data_directory.joinpath(
-            "ignored_articles.pickle"
-        )
+        self.ignored_articles_cache_file = self.data_directory.joinpath("ignored_articles.pickle")
 
         self.hostname = config["server_address"]
         if (
@@ -277,14 +262,17 @@ def _exponential_backoff(self, n: int):
         if self._ping_process:
             self._ping_process.terminate()
 
-        self._ping_process = multiprocessing.Process(
-            target=self._check_ping_thread, args=(n,)
-        )
+        self._ping_process = multiprocessing.Process(target=self._check_ping_thread, args=(n,))
         self._ping_process.start()
 
     def _check_ping_thread(self, n: int):
         i = 0
-        while i < n and not self._offline_mode and not self._server_available.value:
+        while (
+            i < n
+            and not self._offline_mode
+            # typing doesn't support multiprocessing.Value very well
+            and not self._server_available.value  # type: ignore
+        ):
             try:
                 self._set_ping_status(timeout=2 * (i + 1))
             except Exception:
@@ -295,7 +283,8 @@ def _check_ping_thread(self, n: int):
     def _set_ping_status(self, timeout: int = 2):
         logging.info(f"SET PING STATUS timeout={timeout}")
         now = datetime.now().timestamp()
-        if now - self._last_ping_timestamp.value < 15:
+        # typing doesn't support multiprocessing.Value very well
+        if now - self._last_ping_timestamp.value < 15:  # type: ignore
             return
 
         # Try to ping the server.
@@ -310,7 +299,8 @@ def on_offline_mode_change(self, offline_mode: bool):
 
     @property
     def ping_status(self) -> bool:
-        return self._server_available.value
+        # typing doesn't support multiprocessing.Value very well
+        return self._server_available.value  # type: ignore
 
     can_create_playlist = True
     can_delete_playlist = True
@@ -332,9 +322,10 @@ def ping_status(self) -> bool:
     can_update_playlist = True
 
     def version_at_least(self, version: str) -> bool:
-        if not self._version.value:
-            return False
-        return semver.VersionInfo.parse(self._version.value.decode()) >= version
+        # typing doesn't support multiprocessing.Value very well
+        if v := self._version.value:  # type: ignore
+            return semver.VersionInfo.parse(v.decode()) >= version
+        return False
 
     @property
     def can_get_genres(self) -> bool:
@@ -384,7 +375,8 @@ def _get_params(self) -> Dict[str, str]:
             "u": self.username,
             "c": "Sublime Music",
             "f": "json",
-            "v": self._version.value.decode() or "1.8.0",
+            # typing doesn't support multiprocessing.Value very well
+            "v": self._version.value.decode() or "1.8.0",  # type: ignore
         }
 
         if self.use_salt_auth:
@@ -481,13 +473,15 @@ def _get(
                     result.status_code, f"{url} returned status={result.status_code}."
                 )
             # Any time that a server request succeeds, then we win.
-            self._server_available.value = True
-            self._last_ping_timestamp.value = datetime.now().timestamp()
+            # typing doesn't support multiprocessing.Value very well
+            self._server_available.value = True  # type: ignore
+            self._last_ping_timestamp.value = datetime.now().timestamp()  # type: ignore
 
         except Exception:
             logging.exception(f"[FAIL] get: {url} failed")
-            self._server_available.value = False
-            self._last_ping_timestamp.value = datetime.now().timestamp()
+            # typing doesn't support multiprocessing.Value very well
+            self._server_available.value = False  # type: ignore
+            self._last_ping_timestamp.value = datetime.now().timestamp()  # type: ignore
             if not is_exponential_backoff_ping:
                 self._exponential_backoff(5)
             raise
@@ -526,7 +520,8 @@ def _get_json(
                 subsonic_response["error"].get("message"),
             )
 
-        self._version.value = subsonic_response["version"].encode()
+        # typing doesn't support multiprocessing.Value very well
+        self._version.value = subsonic_response["version"].encode()  # type: ignore
 
         logging.debug(f"Response from {url}: {subsonic_response}")
         return Response.from_dict(subsonic_response)
@@ -574,7 +569,7 @@ def get_playlist_details(self, playlist_id: str) -> API.Playlist:
         return result
 
     def create_playlist(
-        self, name: str, songs: Sequence[API.Song] = None
+        self, name: str, songs: Sequence[API.Song] | None = None
     ) -> Optional[API.Playlist]:
         return self._get_json(
             self._make_url("createPlaylist"),
@@ -585,11 +580,11 @@ def create_playlist(
     def update_playlist(
         self,
         playlist_id: str,
-        name: str = None,
-        comment: str = None,
-        public: bool = None,
-        song_ids: Sequence[str] = None,
-        append_song_ids: Sequence[str] = None,
+        name: str | None = None,
+        comment: str | None = None,
+        public: bool | None = None,
+        song_ids: Sequence[str] | None = None,
+        append_song_ids: Sequence[str] | None = None,
     ) -> API.Playlist:
         if any(x is not None for x in (name, comment, public, append_song_ids)):
             self._get_json(
@@ -657,9 +652,7 @@ def get_artist(self, artist_id: str) -> API.Artist:
         assert artist, f"Error getting artist {artist_id}"
         if self.version_at_least("1.11.0"):
             try:
-                artist_info = self._get_json(
-                    self._make_url("getArtistInfo2"), id=artist_id
-                )
+                artist_info = self._get_json(self._make_url("getArtistInfo2"), id=artist_id)
                 artist.augment_with_artist_info(artist_info.artist_info)
             except Exception:
                 pass
@@ -701,14 +694,12 @@ def get_albums(
 
         extra_args: Dict[str, Any] = {}
         if query.type == AlbumSearchQuery.Type.YEAR_RANGE:
-            assert (year_range := query.year_range)
             extra_args = {
-                "fromYear": year_range[0],
-                "toYear": year_range[1],
+                "fromYear": query.year_range[0],
+                "toYear": query.year_range[1],
             }
         elif query.type == AlbumSearchQuery.Type.GENRE:
-            assert (genre := query.genre)
-            extra_args = {"genre": genre.name}
+            extra_args = {"genre": query.genre.name}
 
         albums: List[API.Album] = []
         page_size = 50 if query.type == AlbumSearchQuery.Type.RANDOM else 500
@@ -746,7 +737,7 @@ def _get_indexes(self) -> API.Directory:
 
         root_dir_items: List[Dict[str, Any]] = []
         for index in indexes.index:
-            root_dir_items.extend(map(lambda x: {**x, "isDir": True}, index.artist))
+            root_dir_items.extend({**x, "isDir": True} for x in index.artist)
         return Directory(id="root", _children=root_dir_items)
 
     def get_directory(self, directory_id: str) -> API.Directory:
@@ -754,9 +745,7 @@ def get_directory(self, directory_id: str) -> API.Directory:
             return self._get_indexes()
 
         # TODO (#187) make sure to filter out all non-song files
-        directory = self._get_json(
-            self._make_url("getMusicDirectory"), id=directory_id
-        ).directory
+        directory = self._get_json(self._make_url("getMusicDirectory"), id=directory_id).directory
         assert directory, f"Error getting directory {directory_id}"
         return directory
 
@@ -771,8 +760,8 @@ def get_play_queue(self) -> Optional[API.PlayQueue]:
     def save_play_queue(
         self,
         song_ids: Sequence[str],
-        current_song_index: int = None,
-        position: timedelta = None,
+        current_song_index: int | None = None,
+        position: timedelta | None = None,
     ):
         # TODO (sonic-extensions-api/specification#1) make an extension that allows you
         # to save the play queue position by index instead of id.
@@ -780,9 +769,7 @@ def save_play_queue(
             self._make_url("savePlayQueue"),
             id=song_ids,
             timeout=2,
-            current=song_ids[current_song_index]
-            if current_song_index is not None
-            else None,
+            current=song_ids[current_song_index] if current_song_index is not None else None,
             position=math.floor(position.total_seconds() * 1000) if position else None,
         )
 
diff --git a/sublime_music/adapters/subsonic/api_objects.py b/sublime_music/adapters/subsonic/api_objects.py
index 730a7257..4043c7c7 100644
--- a/sublime_music/adapters/subsonic/api_objects.py
+++ b/sublime_music/adapters/subsonic/api_objects.py
@@ -7,12 +7,7 @@
 from typing import Any, Dict, List, Optional, Union
 
 import dataclasses_json
-from dataclasses_json import (
-    config,
-    dataclass_json,
-    DataClassJsonMixin,
-    LetterCase,
-)
+from dataclasses_json import DataClassJsonMixin, LetterCase, config, dataclass_json
 from dateutil import parser
 
 from .. import api_objects as SublimeAPI
@@ -60,9 +55,7 @@ class Album(SublimeAPI.Album):
     year: Optional[int] = None
     duration: Optional[timedelta] = None
     created: Optional[datetime] = None
-    songs: List["Song"] = field(
-        default_factory=list, metadata=config(field_name="song")
-    )
+    songs: List["Song"] = field(default_factory=list, metadata=config(field_name="song"))
     play_count: Optional[int] = None
     starred: Optional[datetime] = None
 
@@ -79,7 +72,7 @@ def __post_init__(self):
         # Initialize the cross-references
         self.artist = (
             None
-            if not self.artist_id and not self._artist
+            if not self.artist_id or not self._artist
             else ArtistAndArtistInfo(id=self.artist_id, name=self._artist)
         )
         self.genre = None if not self._genre else Genre(self._genre)
@@ -90,9 +83,7 @@ def __post_init__(self):
 class ArtistAndArtistInfo(SublimeAPI.Artist):
     name: str
     id: Optional[str]
-    albums: List[Album] = field(
-        default_factory=list, metadata=config(field_name="album")
-    )
+    albums: List[Album] = field(default_factory=list, metadata=config(field_name="album"))
     album_count: Optional[int] = None
     cover_art: Optional[str] = None
     artist_image_url: Optional[str] = None
@@ -115,9 +106,7 @@ def augment_with_artist_info(self, artist_info: Optional["ArtistInfo"]):
             self.similar_artists = artist_info.similar_artists
             self.biography = artist_info.biography
             self.last_fm_url = artist_info.last_fm_url
-            self.artist_image_url = (
-                artist_info.artist_image_url or self.artist_image_url
-            )
+            self.artist_image_url = artist_info.artist_image_url or self.artist_image_url
             self.music_brainz_id = artist_info.music_brainz_id
 
 
@@ -148,7 +137,7 @@ def __post_init__(self):
 
 @dataclass_json(letter_case=LetterCase.CAMEL)
 @dataclass
-class Directory(SublimeAPI.Directory):
+class Directory(SublimeAPI.Directory, DataClassJsonMixin):
     id: str
     name: Optional[str] = None
     title: Optional[str] = None
@@ -166,8 +155,7 @@ def __post_init__(self):
 
         self.name = self.name or self.title
         self.children = [
-            Directory.from_dict(c) if c.get("isDir") else Song.from_dict(c)
-            for c in self._children
+            Directory.from_dict(c) if c.get("isDir") else Song.from_dict(c) for c in self._children
         ]
 
 
@@ -207,13 +195,9 @@ def __post_init__(self):
             self.id = str(self.id)
         self.parent_id = (self.parent_id or "root") if self.id != "root" else None
         self.artist = (
-            None
-            if not self._artist
-            else ArtistAndArtistInfo(id=self.artist_id, name=self._artist)
-        )
-        self.album = (
-            None if not self._album else Album(id=self.album_id, name=self._album)
+            None if not self._artist else ArtistAndArtistInfo(id=self.artist_id, name=self._artist)
         )
+        self.album = None if not self._album else Album(id=self.album_id, name=self._album)
         self.genre = None if not self._genre else Genre(self._genre)
 
 
@@ -242,9 +226,7 @@ def __post_init__(self):
 
         if self.duration is None:
             self.duration = timedelta(
-                seconds=sum(
-                    s.duration.total_seconds() if s.duration else 0 for s in self.songs
-                )
+                seconds=sum(s.duration.total_seconds() if s.duration else 0 for s in self.songs)
             )
 
 
@@ -333,9 +315,7 @@ class Response(DataClassJsonMixin):
         default=None, metadata=config(field_name="artistInfo2")
     )
 
-    albums: Optional[AlbumList2] = field(
-        default=None, metadata=config(field_name="albumList2")
-    )
+    albums: Optional[AlbumList2] = field(default=None, metadata=config(field_name="albumList2"))
     album: Optional[Album] = None
 
     directory: Optional[Directory] = None
@@ -347,9 +327,7 @@ class Response(DataClassJsonMixin):
     playlist: Optional[Playlist] = None
     playlists: Optional[Playlists] = None
 
-    play_queue: Optional[PlayQueue] = field(
-        default=None, metadata=config(field_name="playQueue")
-    )
+    play_queue: Optional[PlayQueue] = field(default=None, metadata=config(field_name="playQueue"))
 
     song: Optional[Song] = None
 
diff --git a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.1.0.xsd b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.1.0.xsd
index 04349b2d..48a2fc0f 100644
--- a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.1.0.xsd
+++ b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.1.0.xsd
@@ -153,4 +153,4 @@
         <xs:attribute name="message" type="xs:string" use="optional"/>
     </xs:complexType>
 
-</xs:schema>
\ No newline at end of file
+</xs:schema>
diff --git a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.1.1.xsd b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.1.1.xsd
index d9f440ae..b2770d53 100644
--- a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.1.1.xsd
+++ b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.1.1.xsd
@@ -154,4 +154,4 @@
         <xs:attribute name="message" type="xs:string" use="optional"/>
     </xs:complexType>
 
-</xs:schema>
\ No newline at end of file
+</xs:schema>
diff --git a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.10.2.xsd b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.10.2.xsd
index a409c45a..8383078d 100644
--- a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.10.2.xsd
+++ b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.10.2.xsd
@@ -477,7 +477,7 @@
 
     <xs:complexType name="User">
         <xs:attribute name="username" type="xs:string" use="required"/>
-        <xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 --> 
+        <xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 -->
         <xs:attribute name="scrobblingEnabled" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
         <xs:attribute name="adminRole" type="xs:boolean" use="required"/>
         <xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
@@ -497,4 +497,4 @@
         <xs:attribute name="message" type="xs:string" use="optional"/>
     </xs:complexType>
 
-</xs:schema>
\ No newline at end of file
+</xs:schema>
diff --git a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.11.0.xsd b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.11.0.xsd
index d2ccf241..0a2c5641 100644
--- a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.11.0.xsd
+++ b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.11.0.xsd
@@ -545,4 +545,4 @@
         <xs:attribute name="message" type="xs:string" use="optional"/>
     </xs:complexType>
 
-</xs:schema>
\ No newline at end of file
+</xs:schema>
diff --git a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.12.0.xsd b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.12.0.xsd
index 61043487..bfddfc2b 100644
--- a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.12.0.xsd
+++ b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.12.0.xsd
@@ -560,4 +560,4 @@
         <xs:attribute name="message" type="xs:string" use="optional"/>
     </xs:complexType>
 
-</xs:schema>
\ No newline at end of file
+</xs:schema>
diff --git a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.13.0.xsd b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.13.0.xsd
index 30845f10..c5d2d6f2 100644
--- a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.13.0.xsd
+++ b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.13.0.xsd
@@ -585,4 +585,4 @@
         <xs:attribute name="message" type="xs:string" use="optional"/>
     </xs:complexType>
 
-</xs:schema>
\ No newline at end of file
+</xs:schema>
diff --git a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.14.0.xsd b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.14.0.xsd
index 47fbfdc8..bffc7962 100644
--- a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.14.0.xsd
+++ b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.14.0.xsd
@@ -629,4 +629,4 @@
         <xs:attribute name="message" type="xs:string" use="optional"/>
     </xs:complexType>
 
-</xs:schema>
\ No newline at end of file
+</xs:schema>
diff --git a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.15.0.xsd b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.15.0.xsd
index 44d51030..e524351c 100644
--- a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.15.0.xsd
+++ b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.15.0.xsd
@@ -635,4 +635,4 @@
         <xs:attribute name="message" type="xs:string" use="optional"/>
     </xs:complexType>
 
-</xs:schema>
\ No newline at end of file
+</xs:schema>
diff --git a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.16.0.xsd b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.16.0.xsd
index 590ff899..555de28d 100644
--- a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.16.0.xsd
+++ b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.16.0.xsd
@@ -635,4 +635,4 @@
         <xs:attribute name="message" type="xs:string" use="optional"/>
     </xs:complexType>
 
-</xs:schema>
\ No newline at end of file
+</xs:schema>
diff --git a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.16.1.xsd b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.16.1.xsd
index f610574b..ac72ab88 100644
--- a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.16.1.xsd
+++ b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.16.1.xsd
@@ -637,4 +637,4 @@
         <xs:attribute name="message" type="xs:string" use="optional"/>
     </xs:complexType>
 
-</xs:schema>
\ No newline at end of file
+</xs:schema>
diff --git a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.2.0.xsd b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.2.0.xsd
index 5970c60e..03623e5e 100644
--- a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.2.0.xsd
+++ b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.2.0.xsd
@@ -196,4 +196,4 @@
         <xs:attribute name="message" type="xs:string" use="optional"/>
     </xs:complexType>
 
-</xs:schema>
\ No newline at end of file
+</xs:schema>
diff --git a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.3.0.xsd b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.3.0.xsd
index 1865afa2..9b9589c9 100644
--- a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.3.0.xsd
+++ b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.3.0.xsd
@@ -211,4 +211,4 @@
         <xs:attribute name="message" type="xs:string" use="optional"/>
     </xs:complexType>
 
-</xs:schema>
\ No newline at end of file
+</xs:schema>
diff --git a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.4.0.xsd b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.4.0.xsd
index 65c9ea58..d305b3ca 100644
--- a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.4.0.xsd
+++ b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.4.0.xsd
@@ -223,4 +223,4 @@
         <xs:attribute name="message" type="xs:string" use="optional"/>
     </xs:complexType>
 
-</xs:schema>
\ No newline at end of file
+</xs:schema>
diff --git a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.5.0.xsd b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.5.0.xsd
index 756c5782..69b3e96c 100644
--- a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.5.0.xsd
+++ b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.5.0.xsd
@@ -224,4 +224,4 @@
         <xs:attribute name="message" type="xs:string" use="optional"/>
     </xs:complexType>
 
-</xs:schema>
\ No newline at end of file
+</xs:schema>
diff --git a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.6.0.xsd b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.6.0.xsd
index 5f1f3980..fe7ec4d2 100644
--- a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.6.0.xsd
+++ b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.6.0.xsd
@@ -285,7 +285,7 @@
 
     <xs:complexType name="User">
         <xs:attribute name="username" type="xs:string" use="required"/>
-        <xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 --> 
+        <xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 -->
         <xs:attribute name="adminRole" type="xs:boolean" use="required"/>
         <xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
         <xs:attribute name="downloadRole" type="xs:boolean" use="required"/>
@@ -303,4 +303,4 @@
         <xs:attribute name="message" type="xs:string" use="optional"/>
     </xs:complexType>
 
-</xs:schema>
\ No newline at end of file
+</xs:schema>
diff --git a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.7.0.xsd b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.7.0.xsd
index 070690cb..2727589f 100644
--- a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.7.0.xsd
+++ b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.7.0.xsd
@@ -296,7 +296,7 @@
 
     <xs:complexType name="User">
         <xs:attribute name="username" type="xs:string" use="required"/>
-        <xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 --> 
+        <xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 -->
         <xs:attribute name="scrobblingEnabled" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
         <xs:attribute name="adminRole" type="xs:boolean" use="required"/>
         <xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
@@ -316,4 +316,4 @@
         <xs:attribute name="message" type="xs:string" use="optional"/>
     </xs:complexType>
 
-</xs:schema>
\ No newline at end of file
+</xs:schema>
diff --git a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.8.0.xsd b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.8.0.xsd
index ba404c51..41ef5a73 100644
--- a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.8.0.xsd
+++ b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.8.0.xsd
@@ -425,7 +425,7 @@
 
     <xs:complexType name="User">
         <xs:attribute name="username" type="xs:string" use="required"/>
-        <xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 --> 
+        <xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 -->
         <xs:attribute name="scrobblingEnabled" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
         <xs:attribute name="adminRole" type="xs:boolean" use="required"/>
         <xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
@@ -445,4 +445,4 @@
         <xs:attribute name="message" type="xs:string" use="optional"/>
     </xs:complexType>
 
-</xs:schema>
\ No newline at end of file
+</xs:schema>
diff --git a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.9.0.xsd b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.9.0.xsd
index 1a80d968..8fe9855a 100644
--- a/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.9.0.xsd
+++ b/sublime_music/adapters/subsonic/api_specs/subsonic-rest-api-1.9.0.xsd
@@ -465,7 +465,7 @@
 
     <xs:complexType name="User">
         <xs:attribute name="username" type="xs:string" use="required"/>
-        <xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 --> 
+        <xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 -->
         <xs:attribute name="scrobblingEnabled" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
         <xs:attribute name="adminRole" type="xs:boolean" use="required"/>
         <xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
@@ -485,4 +485,4 @@
         <xs:attribute name="message" type="xs:string" use="optional"/>
     </xs:complexType>
 
-</xs:schema>
\ No newline at end of file
+</xs:schema>
diff --git a/sublime_music/app.py b/sublime_music/app.py
index 1ff1ae19..c00b146d 100644
--- a/sublime_music/app.py
+++ b/sublime_music/app.py
@@ -24,15 +24,13 @@
     import gi
 
     gi.require_version("Notify", "0.7")
-    from gi.repository import Notify
+    from gi.repository import Notify  # mypy: ignore
 
     glib_notify_exists = True
 except Exception:
     # I really don't care what kind of exception it is, all that matters is the
     # import failed for some reason.
-    logging.warning(
-        "Unable to import Notify from GLib. Notifications will be disabled."
-    )
+    logging.warning("Unable to import Notify from GLib. Notifications will be disabled.")
     glib_notify_exists = False
 
 from .adapters import (
@@ -45,7 +43,7 @@
 )
 from .adapters.api_objects import Playlist, PlayQueue, Song
 from .config import AppConfiguration, ProviderConfiguration
-from .dbus import dbus_propagate, DBusManager
+from .dbus import DBusManager, dbus_propagate
 from .players import PlayerDeviceEvent, PlayerEvent, PlayerManager
 from .ui.configure_provider import ConfigureProviderDialog
 from .ui.main import MainWindow
@@ -65,13 +63,13 @@ def __init__(self, config_file: Path):
 
         self.connect("shutdown", self.on_app_shutdown)
 
-    player_manager: Optional[PlayerManager] = None
+    player_manager: PlayerManager
     exiting: bool = False
 
     def do_startup(self):
         Gtk.Application.do_startup(self)
 
-        def add_action(name: str, fn: Callable, parameter_type: str = None):
+        def add_action(name: str, fn: Callable, parameter_type: str | None = None):
             """Registers an action with the application."""
             if type(parameter_type) == str:
                 parameter_type = GLib.VariantType(parameter_type)
@@ -82,12 +80,8 @@ def add_action(name: str, fn: Callable, parameter_type: str = None):
         # Add action for menu items.
         add_action("add-new-music-provider", self.on_add_new_music_provider)
         add_action("edit-current-music-provider", self.on_edit_current_music_provider)
-        add_action(
-            "switch-music-provider", self.on_switch_music_provider, parameter_type="s"
-        )
-        add_action(
-            "remove-music-provider", self.on_remove_music_provider, parameter_type="s"
-        )
+        add_action("switch-music-provider", self.on_switch_music_provider, parameter_type="s")
+        add_action("remove-music-provider", self.on_remove_music_provider, parameter_type="s")
 
         # Add actions for player controls
         add_action("play-pause", self.on_play_pause)
@@ -149,9 +143,7 @@ def do_activate(self):
         css_provider.load_from_path(str(resolve_path("ui/app_styles.css")))
         context = Gtk.StyleContext()
         screen = Gdk.Screen.get_default()
-        context.add_provider_for_screen(
-            screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER
-        )
+        context.add_provider_for_screen(screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER)
 
         self.window.show_all()
         self.window.present()
@@ -191,11 +183,7 @@ def do_activate(self):
         self.should_scrobble_song = False
 
         def on_timepos_change(value: Optional[float]):
-            if (
-                self.loading_state
-                or not self.window
-                or not self.app_config.state.current_song
-            ):
+            if self.loading_state or not self.window or not self.app_config.state.current_song:
                 return
 
             if value is None:
@@ -213,11 +201,7 @@ def on_timepos_change(value: Optional[float]):
             if (self.last_play_queue_update + timedelta(15)).total_seconds() <= value:
                 self.save_play_queue()
 
-            if (
-                value > 5
-                and self.should_scrobble_song
-                and AdapterManager.can_scrobble_song()
-            ):
+            if value > 5 and self.should_scrobble_song and AdapterManager.can_scrobble_song():
                 AdapterManager.scrobble_song(self.app_config.state.current_song)
                 self.should_scrobble_song = False
 
@@ -266,25 +250,19 @@ def on_player_event(event: PlayerEvent):
                 )
 
             elif event.type == PlayerEvent.EventType.DISCONNECT:
-                assert self.player_manager
                 self.app_config.state.current_device = "this device"
-                self.player_manager.set_current_device_id(
-                    self.app_config.state.current_device
-                )
+                self.player_manager.set_current_device_id(self.app_config.state.current_device)
                 self.player_manager.set_volume(self.app_config.state.volume)
                 self.update_window()
 
         def player_device_change_callback(event: PlayerDeviceEvent):
-            assert self.player_manager
             state_device = self.app_config.state.current_device
 
             if event.delta == PlayerDeviceEvent.Delta.ADD:
                 # If the device added is the one that's supposed to be active, activate
                 # it and set the volume.
                 if event.id == state_device:
-                    self.player_manager.set_current_device_id(
-                        self.app_config.state.current_device
-                    )
+                    self.player_manager.set_current_device_id(self.app_config.state.current_device)
                     self.player_manager.set_volume(self.app_config.state.volume)
                     self.app_config.state.connected_device_name = event.name
                     self.app_config.state.connecting_to_device = False
@@ -308,9 +286,7 @@ def check_if_connected():
                 return
             self.app_config.state.current_device = "this device"
             self.app_config.state.connecting_to_device = False
-            self.player_manager.set_current_device_id(
-                self.app_config.state.current_device
-            )
+            self.player_manager.set_current_device_id(self.app_config.state.current_device)
             self.update_window()
 
         self.player_manager = PlayerManager(
@@ -365,9 +341,7 @@ def on_dbus_method_call(
         def seek_fn(offset: float):
             if not self.app_config.state.current_song:
                 return
-            new_seconds = self.app_config.state.song_progress + timedelta(
-                microseconds=offset
-            )
+            new_seconds = self.app_config.state.song_progress + timedelta(microseconds=offset)
 
             # This should not ever happen. The current_song should always have
             # a duration, but the Child object has `duration` optional because
@@ -392,9 +366,7 @@ def set_pos_fn(track_id: str, position: float = 0):
             # Find the (N-1)th time that the track id shows up in the list. (N
             # is the -*** suffix on the track id.)
             song_index = [
-                i
-                for i, x in enumerate(self.app_config.state.play_queue)
-                if x == track_id
+                i for i, x in enumerate(self.app_config.state.play_queue) if x == track_id
             ][int(occurrence) or 0]
 
             self.play_song(song_index)
@@ -418,26 +390,19 @@ def get_tracks_metadata(track_ids: List[str]) -> GLib.Variant:
             ]
 
             # Get rid of all of the tracks that were not requested.
-            metadatas = list(
-                filter(lambda m: m["mpris:trackid"] in track_ids, metadatas)
-            )
+            metadatas = list(filter(lambda m: m["mpris:trackid"] in track_ids, metadatas))
 
             assert len(metadatas) == len(track_ids)
 
             # Sort them so they get returned in the same order as they were
             # requested.
-            metadatas = sorted(
-                metadatas, key=lambda m: track_ids.index(m["mpris:trackid"])
-            )
+            metadatas = sorted(metadatas, key=lambda m: track_ids.index(m["mpris:trackid"]))
 
             # Turn them into dictionaries that can actually be serialized into
             # a GLib.Variant.
-            metadatas = map(
-                lambda m: {k: DBusManager.to_variant(v) for k, v in m.items()},
-                metadatas,
-            )
+            metadatas = [{k: DBusManager.to_variant(v) for k, v in m.items()} for m in metadatas]
 
-            return GLib.Variant("(aa{sv})", (list(metadatas),))
+            return GLib.Variant("(aa{sv})", (metadatas,))
 
         def activate_playlist(playlist_id: str):
             playlist_id = playlist_id.split("/")[-1]
@@ -490,12 +455,7 @@ def make_playlist_tuple(p: Playlist) -> GLib.Variant:
 
             return GLib.Variant(
                 "(a(oss))",
-                (
-                    [
-                        make_playlist_tuple(p)
-                        for p in playlists[index : (index + max_count)]
-                    ],
-                ),
+                ([make_playlist_tuple(p) for p in playlists[index : (index + max_count)]],),
             )
 
         def play():
@@ -606,6 +566,7 @@ def on_add_new_music_provider(self, *args):
         self.show_configure_servers_dialog()
 
     def on_edit_current_music_provider(self, *args):
+        assert self.app_config.provider
         self.show_configure_servers_dialog(self.app_config.provider.clone())
 
     def on_switch_music_provider(self, _, provider_id: GLib.Variant):
@@ -697,9 +658,9 @@ def on_prev_track(self, *args):
             if self.app_config.state.current_song_index == 0 and no_repeat:
                 song_index_to_play = 0
             else:
-                song_index_to_play = (
-                    self.app_config.state.current_song_index - 1
-                ) % len(self.app_config.state.play_queue)
+                song_index_to_play = (self.app_config.state.current_song_index - 1) % len(
+                    self.app_config.state.play_queue
+                )
         else:
             # Go back to the beginning of the song.
             song_index_to_play = self.app_config.state.current_song_index
@@ -728,8 +689,10 @@ def on_shuffle_press(self, *args):
         if self.app_config.state.shuffle_on:
             # Revert to the old play queue.
             old_play_queue_copy = self.app_config.state.old_play_queue
-            self.app_config.state.current_song_index = old_play_queue_copy.index(
-                self.app_config.state.current_song.id
+            self.app_config.state.current_song_index = (
+                old_play_queue_copy.index(self.app_config.state.current_song.id)
+                if self.app_config.state.current_song
+                else 0
             )
             self.app_config.state.play_queue = old_play_queue_copy
         else:
@@ -738,10 +701,17 @@ def on_shuffle_press(self, *args):
             mutable_play_queue = list(self.app_config.state.play_queue)
 
             # Remove the current song, then shuffle and put the song back.
-            song_id = self.app_config.state.current_song.id
+            song_id = (
+                self.app_config.state.current_song.id
+                if self.app_config.state.current_song
+                else None
+            )
             del mutable_play_queue[self.app_config.state.current_song_index]
             random.shuffle(mutable_play_queue)
-            self.app_config.state.play_queue = (song_id,) + tuple(mutable_play_queue)
+            if song_id:
+                self.app_config.state.play_queue = (song_id,) + tuple(mutable_play_queue)
+            else:
+                self.app_config.state.play_queue = tuple(mutable_play_queue)
             self.app_config.state.current_song_index = 0
 
         self.app_config.state.shuffle_on = not self.app_config.state.shuffle_on
@@ -838,9 +808,7 @@ def on_song_clicked(
         # If shuffle is enabled, then shuffle the playlist.
         if self.app_config.state.shuffle_on and not metadata.get("no_reshuffle"):
             song_id = song_queue[song_index]
-            song_queue_list = list(
-                song_queue[:song_index] + song_queue[song_index + 1 :]
-            )
+            song_queue_list = list(song_queue[:song_index] + song_queue[song_index + 1 :])
             random.shuffle(song_queue_list)
             song_queue = (song_id, *song_queue_list)
             song_index = 0
@@ -862,9 +830,7 @@ def on_songs_removed(self, win: Any, song_indexes_to_remove: List[int]):
         # Determine how many songs before the currently playing one were also
         # deleted.
         before_current = [
-            i
-            for i in song_indexes_to_remove
-            if i < self.app_config.state.current_song_index
+            i for i in song_indexes_to_remove if i < self.app_config.state.current_song_index
         ]
 
         if self.app_config.state.current_song_index in song_indexes_to_remove:
@@ -906,7 +872,6 @@ def on_song_scrub(self, _, scrub_value: float):
         self.save_play_queue()
 
     def on_device_update(self, _, device_id: str):
-        assert self.player_manager
         if device_id == self.app_config.state.current_device:
             return
         self.app_config.state.current_device = device_id
@@ -934,7 +899,6 @@ def on_mute_toggle(self, *args):
 
     @dbus_propagate()
     def on_volume_change(self, _, value: float):
-        assert self.player_manager
         self.app_config.state.volume = value
         self.player_manager.set_volume(self.app_config.state.volume)
         self.update_window()
@@ -1001,7 +965,7 @@ def on_app_shutdown(self, app: "SublimeMusicApp"):
     # ########## HELPER METHODS ########## #
     def show_configure_servers_dialog(
         self,
-        provider_config: Optional[ProviderConfiguration] = None,
+        provider_config: ProviderConfiguration | None = None,
     ):
         """Show the Connect to Server dialog."""
         dialog = ConfigureProviderDialog(self.window, provider_config)
@@ -1031,16 +995,13 @@ def update_window(self, force: bool = False):
             return
         logging.info(f"Updating window force={force}")
         GLib.idle_add(
-            lambda: self.window.update(
-                self.app_config, self.player_manager, force=force
-            )
+            lambda: self.window.update(self.app_config, self.player_manager, force=force)
         )
 
     def update_play_state_from_server(self, prompt_confirm: bool = False):
         # TODO (#129): need to make the play queue list loading for the duration here if
         # prompt_confirm is False.
         if not prompt_confirm and self.app_config.state.playing:
-            assert self.player_manager
             self.player_manager.pause()
             self.app_config.state.playing = False
             self.app_config.state.loading_play_queue = True
@@ -1057,7 +1018,6 @@ def do_update(f: Result[PlayQueue]):
             new_song_progress = play_queue.position
 
             def do_resume(clear_notification: bool):
-                assert self.player_manager
                 if was_playing := self.app_config.state.playing:
                     self.on_play_pause()
 
@@ -1079,9 +1039,7 @@ def do_resume(clear_notification: bool):
                 progress_diff = 15.0
                 if self.app_config.state.song_progress:
                     progress_diff = abs(
-                        (
-                            self.app_config.state.song_progress - new_song_progress
-                        ).total_seconds()
+                        (self.app_config.state.song_progress - new_song_progress).total_seconds()
                     )
 
                 if (
@@ -1124,8 +1082,8 @@ def play_song(
         self,
         song_index: int,
         reset: bool = False,
-        old_play_queue: Tuple[str, ...] = None,
-        play_queue: Tuple[str, ...] = None,
+        old_play_queue: Tuple[str, ...] | None = None,
+        play_queue: Tuple[str, ...] | None = None,
         playable_song_search_direction: int = 1,
     ):
         def do_reset():
@@ -1140,15 +1098,12 @@ def do_notify_next_song(next_song: Song):
                 if self.player_manager:
                     self.player_manager.next_media_cached(next_uri, next_song)
             except CacheMissError:
-                logging.debug(
-                    "Couldn't find the file for next song for gapless playback"
-                )
+                logging.debug("Couldn't find the file for next song for gapless playback")
 
         # Do this the old fashioned way so that we can have access to ``reset``
         # in the callback.
         @dbus_propagate(self)
         def do_play_song(order_token: int, song: Song):
-            assert self.player_manager
             if order_token != self.song_playing_order_token:
                 return
 
@@ -1164,10 +1119,7 @@ def do_play_song(order_token: int, song: Song):
                     uri = AdapterManager.get_song_stream_uri(song)
                 except Exception:
                     pass
-                if (
-                    not uri
-                    or urlparse(uri).scheme not in self.player_manager.supported_schemes
-                ):
+                if not uri or urlparse(uri).scheme not in self.player_manager.supported_schemes:
                     self.app_config.state.current_notification = UIState.UINotification(
                         markup=f"<b>Unable to play {song.title}.</b>",
                         icon="dialog-error",
@@ -1234,9 +1186,7 @@ def on_cover_art_download_complete(cover_art_filename: str):
                             )
                             song_notification.show()
 
-                        cover_art_result = AdapterManager.get_cover_art_uri(
-                            song.cover_art, "file"
-                        )
+                        cover_art_result = AdapterManager.get_cover_art_uri(song.cover_art, "file")
                         cover_art_result.add_done_callback(
                             lambda f: on_cover_art_download_complete(f.result())
                         )
@@ -1281,7 +1231,6 @@ def on_song_download_complete(song_id: str):
                     # Switch to the local media if the player can hotswap without lag.
                     # For example, MPV can is barely noticable whereas there's quite a
                     # delay with Chromecast.
-                    assert self.player_manager
                     if self.player_manager.can_start_playing_with_no_latency:
                         self.player_manager.play_media(
                             AdapterManager.get_song_file_uri(song),
@@ -1297,9 +1246,7 @@ def on_song_download_complete(song_id: str):
                         next_song_index is not None
                         and self.app_config.state.play_queue[next_song_index] == song_id
                     ):
-                        next_song_details_future = AdapterManager.get_song_details(
-                            song_id
-                        )
+                        next_song_details_future = AdapterManager.get_song_details(song_id)
 
                         next_song_details_future.add_done_callback(
                             lambda f: GLib.idle_add(do_notify_next_song, f.result()),
@@ -1320,9 +1267,7 @@ def on_song_download_complete(song_id: str):
                 song_ids = [song.id]
 
                 # Add the prefetch songs.
-                if (
-                    repeat_type := self.app_config.state.repeat_type
-                ) != RepeatType.REPEAT_SONG:
+                if (repeat_type := self.app_config.state.repeat_type) != RepeatType.REPEAT_SONG:
                     song_idx = self.app_config.state.play_queue.index(song.id)
                     is_repeat_queue = RepeatType.REPEAT_QUEUE == repeat_type
                     prefetch_idxs = []
@@ -1330,12 +1275,8 @@ def on_song_download_complete(song_id: str):
                         prefetch_idx: int = song_idx + 1 + i
                         play_queue_len: int = len(self.app_config.state.play_queue)
                         if is_repeat_queue or prefetch_idx < play_queue_len:
-                            prefetch_idxs.append(
-                                prefetch_idx % play_queue_len  # noqa: S001
-                            )
-                    song_ids.extend(
-                        [self.app_config.state.play_queue[i] for i in prefetch_idxs]
-                    )
+                            prefetch_idxs.append(prefetch_idx % play_queue_len)  # noqa: S001
+                    song_ids.extend([self.app_config.state.play_queue[i] for i in prefetch_idxs])
 
                 self.batch_download_jobs.add(
                     AdapterManager.batch_download_songs(
@@ -1372,9 +1313,7 @@ def on_song_download_complete(song_id: str):
         # If in offline mode, go to the first song in the play queue after the given
         # song that is actually playable.
         if self.app_config.offline_mode:
-            statuses = AdapterManager.get_cached_statuses(
-                self.app_config.state.play_queue
-            )
+            statuses = AdapterManager.get_cached_statuses(self.app_config.state.play_queue)
             playable_statuses = (
                 SongCacheStatus.CACHED,
                 SongCacheStatus.PERMANENTLY_CACHED,
@@ -1387,17 +1326,13 @@ def on_song_download_complete(song_id: str):
             elif self.app_config.state.repeat_type != RepeatType.REPEAT_SONG:
                 # See if any other songs in the queue are playable.
                 play_queue_len = len(self.app_config.state.play_queue)
-                cursor = (
-                    current_song_index + playable_song_search_direction
-                ) % play_queue_len
+                cursor = (current_song_index + playable_song_search_direction) % play_queue_len
                 for _ in range(play_queue_len):  # Don't infinite loop.
                     if self.app_config.state.repeat_type == RepeatType.NO_REPEAT:
                         if (
-                            playable_song_search_direction == 1
-                            and cursor < current_song_index
+                            playable_song_search_direction == 1 and cursor < current_song_index
                         ) or (
-                            playable_song_search_direction == -1
-                            and cursor > current_song_index
+                            playable_song_search_direction == -1 and cursor > current_song_index
                         ):
                             # We wrapped around to the end of the play queue without
                             # finding a song that can be played, and we aren't allowed
@@ -1459,7 +1394,7 @@ def go_online_clicked():
                 ),
             )
 
-    def save_play_queue(self, song_playing_order_token: int = None):
+    def save_play_queue(self, song_playing_order_token: int | None = None):
         if (
             len(self.app_config.state.play_queue) == 0
             or self.app_config.provider is None
diff --git a/sublime_music/config.py b/sublime_music/config.py
index 9e981c57..3ea43179 100644
--- a/sublime_music/config.py
+++ b/sublime_music/config.py
@@ -3,10 +3,10 @@
 import pickle
 from dataclasses import dataclass, field
 from pathlib import Path
-from typing import Any, Dict, Optional, Tuple, Type, Union
+from typing import Any, Dict, Optional, Type, Union
 
 import dataclasses_json
-from dataclasses_json import config, DataClassJsonMixin
+from dataclasses_json import DataClassJsonMixin, config
 
 from .adapters import ConfigurationStore
 from .ui.state import UIState
@@ -24,9 +24,7 @@ def encode_path(path: Path) -> str:
 
 
 dataclasses_json.cfg.global_config.encoders[Path] = encode_path
-dataclasses_json.cfg.global_config.encoders[
-    Optional[Path]  # type: ignore
-] = encode_path
+dataclasses_json.cfg.global_config.encoders[Optional[Path]] = encode_path  # type: ignore
 
 
 @dataclass
@@ -39,9 +37,7 @@ class ProviderConfiguration:
     caching_adapter_config: Optional[ConfigurationStore] = None
 
     def migrate(self):
-        self.ground_truth_adapter_type.migrate_configuration(
-            self.ground_truth_adapter_config
-        )
+        self.ground_truth_adapter_type.migrate_configuration(self.ground_truth_adapter_config)
         if self.caching_adapter_type:
             self.caching_adapter_type.migrate_configuration(self.caching_adapter_config)
 
@@ -52,11 +48,7 @@ def clone(self) -> "ProviderConfiguration":
             self.ground_truth_adapter_type,
             self.ground_truth_adapter_config.clone(),
             self.caching_adapter_type,
-            (
-                self.caching_adapter_config.clone()
-                if self.caching_adapter_config
-                else None
-            ),
+            (self.caching_adapter_config.clone() if self.caching_adapter_config else None),
         )
 
     def persist_secrets(self):
@@ -110,16 +102,12 @@ def find_adapter_type(type_name: str) -> Type:
         id_: ProviderConfiguration(
             config["id"],
             config["name"],
-            ground_truth_adapter_type=find_adapter_type(
-                config["ground_truth_adapter_type"]
-            ),
+            ground_truth_adapter_type=find_adapter_type(config["ground_truth_adapter_type"]),
             ground_truth_adapter_config=ConfigurationStore(
                 **config["ground_truth_adapter_config"]
             ),
             caching_adapter_type=(
-                find_adapter_type(cat)
-                if (cat := config.get("caching_adapter_type"))
-                else None
+                find_adapter_type(cat) if (cat := config.get("caching_adapter_type")) else None
             ),
             caching_adapter_config=(
                 ConfigurationStore(**(config.get("caching_adapter_config") or {}))
@@ -144,9 +132,7 @@ class AppConfiguration(DataClassJsonMixin):
     _loaded_provider_id: Optional[str] = field(default=None, init=False)
 
     # Players
-    player_config: Dict[str, Dict[str, Union[Type, Tuple[str, ...]]]] = field(
-        default_factory=dict
-    )
+    player_config: Dict[str, Dict[str, Any]] = field(default_factory=dict)
 
     # Global Settings
     song_play_notification: bool = True
@@ -214,6 +200,7 @@ def state(self) -> UIState:
         if self._loaded_provider_id != provider.id:
             self.load_state()
 
+        assert self._state
         return self._state
 
     def load_state(self):
@@ -242,6 +229,7 @@ def _state_file_location(self) -> Optional[Path]:
         return self.cache_location.joinpath(provider.id, "state.pickle")
 
     def save(self):
+        assert self.filename
         # Save the config as YAML.
         self.filename.parent.mkdir(parents=True, exist_ok=True)
         json = self.to_json(indent=2, sort_keys=True)
diff --git a/sublime_music/dbus/__init__.py b/sublime_music/dbus/__init__.py
index efc1f62f..9a29c789 100644
--- a/sublime_music/dbus/__init__.py
+++ b/sublime_music/dbus/__init__.py
@@ -1,3 +1,3 @@
-from .manager import dbus_propagate, DBusManager
+from .manager import DBusManager, dbus_propagate
 
 __all__ = ("dbus_propagate", "DBusManager")
diff --git a/sublime_music/dbus/manager.py b/sublime_music/dbus/manager.py
index 59ed031a..5698898d 100644
--- a/sublime_music/dbus/manager.py
+++ b/sublime_music/dbus/manager.py
@@ -50,9 +50,7 @@ def __init__(
             ],
             None,
         ],
-        on_set_property: Callable[
-            [Gio.DBusConnection, str, str, str, str, GLib.Variant], None
-        ],
+        on_set_property: Callable[[Gio.DBusConnection, str, str, str, str, GLib.Variant], None],
         get_config_and_player_manager: Callable[
             [], Tuple[AppConfiguration, Optional[PlayerManager]]
         ],
@@ -163,9 +161,7 @@ def to_variant(value: Any) -> GLib.Variant:
                 {k: DBusManager.to_variant(v) for k, v in value.items()},
             )
 
-        variant_type = {list: "as", str: "s", int: "i", float: "d", bool: "b"}.get(
-            type(value)
-        )
+        variant_type = {list: "as", str: "s", int: "i", float: "d", bool: "b"}.get(type(value))
         if not variant_type:
             return value
         return GLib.Variant(variant_type, value)
@@ -193,7 +189,7 @@ def replace(m: Match[str]) -> str:
 
         return DBusManager._escape_re.sub(replace, id)
 
-    def property_dict(self) -> Dict[str, Any]:
+    def property_dict(self) -> Dict[str, dict[str, Any]]:
         config, player_manager = self.get_config_and_player_manager()
         if config is None or player_manager is None:
             return {}
@@ -216,6 +212,13 @@ def property_dict(self) -> Dict[str, Any]:
         except Exception:
             pass
 
+        playback_status_map: dict[tuple[bool, bool], str] = {
+            (False, False): "Stopped",
+            (False, True): "Stopped",
+            (True, False): "Paused",
+            (True, True): "Playing",
+        }
+
         return {
             "org.mpris.MediaPlayer2": {
                 "CanQuit": True,
@@ -227,12 +230,7 @@ def property_dict(self) -> Dict[str, Any]:
                 "SupportedMimeTypes": [],
             },
             "org.mpris.MediaPlayer2.Player": {
-                "PlaybackStatus": {
-                    (False, False): "Stopped",
-                    (False, True): "Stopped",
-                    (True, False): "Paused",
-                    (True, True): "Playing",
-                }[player_manager.song_loaded, state.playing],
+                "PlaybackStatus": playback_status_map[player_manager.song_loaded, state.playing],
                 "LoopStatus": state.repeat_type.as_mpris_loop_status(),
                 "Rate": 1.0,
                 "Shuffle": state.shuffle_on,
@@ -271,9 +269,7 @@ def property_dict(self) -> Dict[str, Any]:
         }
 
     @functools.lru_cache(maxsize=10)
-    def get_active_playlist(
-        self, active_playlist_id: Optional[str]
-    ) -> Tuple[bool, GLib.Variant]:
+    def get_active_playlist(self, active_playlist_id: Optional[str]) -> Tuple[bool, GLib.Variant]:
         if not active_playlist_id or not AdapterManager.can_get_playlist_details():
             return (False, GLib.Variant("(oss)", ("/", "", "")))
 
@@ -305,13 +301,9 @@ def get_active_playlist(
             return (False, GLib.Variant("(oss)", ("/", "", "")))
 
     @functools.lru_cache(maxsize=10)
-    def get_mpris_metadata(
-        self, idx: int, play_queue: Tuple[str, ...]
-    ) -> Dict[str, Any]:
+    def get_mpris_metadata(self, idx: int, play_queue: Tuple[str, ...]) -> Dict[str, Any]:
         try:
-            song = AdapterManager.get_song_details(
-                play_queue[idx], allow_download=False
-            ).result()
+            song = AdapterManager.get_song_details(play_queue[idx], allow_download=False).result()
         except Exception:
             return {}
 
@@ -372,11 +364,14 @@ def property_diff(self):
         new_property_dict = self.property_dict()
         diff = DeepDiff(self.current_state, new_property_dict)
 
-        changes = defaultdict(dict)
+        changes: dict[str, dict[str, Any]] = defaultdict(dict)
 
         for path, change in diff.get("values_changed", {}).items():
-            interface, property_name = self.diff_parse_re.match(path).groups()
-            changes[interface][property_name] = change["new_value"]
+            if m := self.diff_parse_re.match(path):
+                interface, property_name = m.groups()
+                changes[interface][property_name] = change["new_value"]
+            else:
+                logging.warning(f"Couldn't parse path {path} for diff")
 
         if diff.get("dictionary_item_added"):
             changes = new_property_dict
@@ -390,10 +385,7 @@ def property_diff(self):
             # Special handling for when the position changes (a seek).
             # Technically, I'm sending this signal too often, but I don't think
             # it really matters.
-            if (
-                interface == "org.mpris.MediaPlayer2.Player"
-                and "Position" in changed_props
-            ):
+            if interface == "org.mpris.MediaPlayer2.Player" and "Position" in changed_props:
                 self.connection.emit_signal(
                     None,
                     "/org/mpris/MediaPlayer2",
@@ -416,10 +408,7 @@ def property_diff(self):
             #
             # So I think that any change is invasive enough that I should use
             # this signal.
-            if (
-                interface == "org.mpris.MediaPlayer2.TrackList"
-                and "Tracks" in changed_props
-            ):
+            if interface == "org.mpris.MediaPlayer2.TrackList" and "Tracks" in changed_props:
                 track_list = changed_props["Tracks"]
                 if len(track_list) > 0:
                     current_track = new_property_dict["org.mpris.MediaPlayer2.Player"][
@@ -442,10 +431,7 @@ def property_diff(self):
                     "(sa{sv}as)",
                     (
                         interface,
-                        {
-                            k: DBusManager.to_variant(v)
-                            for k, v in changed_props.items()
-                        },
+                        {k: DBusManager.to_variant(v) for k, v in changed_props.items()},
                         [],
                     ),
                 ),
diff --git a/sublime_music/dbus/mpris_specs/org.mpris.MediaPlayer2.Playlists.xml b/sublime_music/dbus/mpris_specs/org.mpris.MediaPlayer2.Playlists.xml
index e285ef22..e09b2606 100644
--- a/sublime_music/dbus/mpris_specs/org.mpris.MediaPlayer2.Playlists.xml
+++ b/sublime_music/dbus/mpris_specs/org.mpris.MediaPlayer2.Playlists.xml
@@ -247,4 +247,3 @@
   </interface>
 </node>
 <!-- vim:set sw=2 sts=2 et ft=xml: -->
-
diff --git a/sublime_music/players/base.py b/sublime_music/players/base.py
index 45d0243c..b8ac74af 100644
--- a/sublime_music/players/base.py
+++ b/sublime_music/players/base.py
@@ -150,6 +150,7 @@ def set_current_device_id(self, device_id: str):
         Switch to the given device ID.
         """
 
+    @abc.abstractmethod
     def reset(self):
         """
         Reset the player.
@@ -216,11 +217,13 @@ def play(self):
         Play the current media.
         """
 
+    @abc.abstractmethod
     def seek(self, position: timedelta):
         """
         :param position: seek to the given position in the song.
         """
 
+    @abc.abstractmethod
     def next_media_cached(self, uri: str, song: Song):
         """
         :param uri: the URI to prepare to play. The URI is guaranteed to be one of
diff --git a/sublime_music/players/chromecast.py b/sublime_music/players/chromecast.py
index 76716219..dfc59955 100644
--- a/sublime_music/players/chromecast.py
+++ b/sublime_music/players/chromecast.py
@@ -6,15 +6,15 @@
 import os
 import socket
 from datetime import timedelta
-from typing import Any, Callable, cast, Dict, Optional, Set, Tuple, Type, Union
+from typing import Any, Callable, Dict, Optional, Set, Tuple, Type, Union, cast
 from urllib.parse import urlparse
 from uuid import UUID
 
 from gi.repository import GLib
 
-from .base import Player, PlayerDeviceEvent, PlayerEvent
 from ..adapters import AdapterManager
 from ..adapters.api_objects import Song
+from .base import Player, PlayerDeviceEvent, PlayerEvent
 
 try:
     import pychromecast
@@ -193,11 +193,15 @@ def increment_time(order_token: int):
         self.time_increment_order_token += 1
         GLib.timeout_add(500, increment_time, self.time_increment_order_token)
 
+    def reset(self):
+        pass
+
     def shutdown(self):
         if self.server_process:
             self.server_process.terminate()
 
         try:
+            assert self._current_chromecast
             self._current_chromecast.quit_app()
         except Exception:
             pass
@@ -220,11 +224,12 @@ def index() -> str:
 
         @app.route("/s/<token>")
         def stream_song(token: str) -> bytes:
-            if token != self._serving_token.value.decode():
+            # typing doesn't support multiprocessing.Value very well
+            if token != self._serving_token.value.decode():  # type: ignore
                 raise bottle.HTTPError(status=401, body="Invalid token.")
 
             song = AdapterManager.get_song_details(
-                self._serving_song_id.value.decode()
+                self._serving_song_id.value.decode()  # type: ignore
             ).result()
             filename = AdapterManager.get_song_file_uri(song)
             with open(filename[7:], "rb") as fin:
@@ -239,15 +244,12 @@ def stream_song(token: str) -> bytes:
 
     @property
     def playing(self) -> bool:
-        if (
-            not self._current_chromecast
-            or not self._current_chromecast.media_controller
-        ):
+        if not self._current_chromecast or not self._current_chromecast.media_controller:
             return False
         return self._current_chromecast.media_controller.status.player_is_playing
 
     def get_volume(self) -> float:
-        if self._current_chromecast:
+        if self._current_chromecast and self._current_chromecast.status:
             # The volume is in the range [0, 1]. Multiply by 100 to get to [0, 100].
             return self._current_chromecast.status.volume_level * 100
         else:
@@ -273,8 +275,9 @@ def play_media(self, uri: str, progress: timedelta, song: Song):
         scheme = urlparse(uri).scheme
         if scheme == "file":
             token = base64.b16encode(os.urandom(8))
-            self._serving_token.value = token
-            self._serving_song_id.value = song.id.encode()
+            # typing doesn't support multiprocessing.Value very well
+            self._serving_token.value = token  # type: ignore
+            self._serving_song_id.value = song.id.encode()  # type: ignore
 
             # If this fails, then we are basically screwed, so don't care if it blows
             # up.
@@ -286,7 +289,7 @@ def play_media(self, uri: str, progress: timedelta, song: Song):
             s.close()
 
             uri = f"http://{host_ip}:{self.config.get(LAN_PORT_KEY)}/s/{token.decode()}"
-            logging.info("Serving {song.name} at {uri}")
+            logging.info(f"Serving {song.title} at {uri}")
 
         assert AdapterManager._instance
         networked_scheme_priority = ("https", "http")
@@ -341,3 +344,6 @@ def seek(self, position: timedelta):
 
     def _wait_for_playing(self):
         pass
+
+    def next_media_cached(self, *_):
+        pass
diff --git a/sublime_music/players/manager.py b/sublime_music/players/manager.py
index 639e37b1..d07b330b 100644
--- a/sublime_music/players/manager.py
+++ b/sublime_music/players/manager.py
@@ -2,10 +2,10 @@
 from datetime import timedelta
 from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, Union
 
+from ..adapters.api_objects import Song
 from .base import PlayerDeviceEvent, PlayerEvent
 from .chromecast import ChromecastPlayer  # noqa: F401
 from .mpv import MPVPlayer  # noqa: F401
-from ..adapters.api_objects import Song
 
 
 class PlayerManager:
@@ -13,17 +13,14 @@ class PlayerManager:
     available_player_types: List[Type] = [MPVPlayer, ChromecastPlayer]
 
     @staticmethod
-    def get_configuration_options() -> Dict[
-        str, Dict[str, Union[Type, Tuple[str, ...]]]
-    ]:
+    def get_configuration_options() -> Dict[str, Dict[str, Union[Type, Tuple[str, ...]]]]:
         """
         :returns: Dictionary of the name of the player -> option configs (see
             :class:`sublime_music.players.base.Player.get_configuration_options` for
             details).
         """
         return {
-            p.name: p.get_configuration_options()
-            for p in PlayerManager.available_player_types
+            p.name: p.get_configuration_options() for p in PlayerManager.available_player_types
         }
 
     # Initialization and Shutdown
diff --git a/sublime_music/players/mpv.py b/sublime_music/players/mpv.py
index 4ce826e0..579017de 100644
--- a/sublime_music/players/mpv.py
+++ b/sublime_music/players/mpv.py
@@ -1,11 +1,11 @@
 import threading
 from datetime import timedelta
-from typing import Callable, cast, Dict, Optional, Tuple, Type, Union
+from typing import Callable, Dict, Optional, Tuple, Type, Union, cast
 
 import mpv
 
-from .base import Player, PlayerDeviceEvent, PlayerEvent
 from ..adapters.api_objects import Song
+from .base import Player, PlayerDeviceEvent, PlayerEvent
 
 REPLAY_GAIN_KEY = "Replay Gain"
 GAPLESS_PLAYBACK_KEY = "Gapless Playback"
diff --git a/sublime_music/ui/albums.py b/sublime_music/ui/albums.py
index d35edaa1..ddc46a99 100644
--- a/sublime_music/ui/albums.py
+++ b/sublime_music/ui/albums.py
@@ -2,17 +2,11 @@
 import itertools
 import logging
 import math
-from typing import Any, Callable, cast, Iterable, List, Optional, Tuple
+from typing import Any, Callable, Iterable, List, Optional, Tuple, cast
 
 from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
 
-from ..adapters import (
-    AdapterManager,
-    AlbumSearchQuery,
-    api_objects as API,
-    CacheMissError,
-    Result,
-)
+from ..adapters import AdapterManager, AlbumSearchQuery, CacheMissError, Result, api_objects as API
 from ..config import AppConfiguration
 from ..ui import util
 from ..ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage
@@ -96,9 +90,7 @@ def __init__(self):
         )
         actionbar.pack_start(self.alphabetical_type_combo)
 
-        self.genre_combo, self.genre_combo_store = self.make_combobox(
-            (), self.on_genre_change
-        )
+        self.genre_combo, self.genre_combo_store = self.make_combobox((), self.on_genre_change)
         actionbar.pack_start(self.genre_combo)
 
         next_decade = (datetime.datetime.now().year // 10) * 10 + 10
@@ -138,9 +130,7 @@ def __init__(self):
         page_widget.add(Gtk.Label(label="of"))
         self.page_count_label = Gtk.Label(label="-")
         page_widget.add(self.page_count_label)
-        self.next_page = IconButton(
-            "go-next-symbolic", "Go to the next page", sensitive=False
-        )
+        self.next_page = IconButton("go-next-symbolic", "Go to the next page", sensitive=False)
         self.next_page.connect("clicked", self.on_next_page_clicked)
         page_widget.add(self.next_page)
         actionbar.set_center_widget(page_widget)
@@ -179,7 +169,7 @@ def __init__(self):
     def make_combobox(
         self,
         items: Iterable[Tuple[str, str, bool]],
-        on_change: Callable[["AlbumsPanel", Gtk.ComboBox], None],
+        on_change: Callable[[Gtk.ComboBox], None],
     ) -> Tuple[Gtk.ComboBox, Gtk.ListStore]:
         store = Gtk.ListStore(str, str, bool)
         for item in items:
@@ -198,7 +188,7 @@ def make_combobox(
 
     def populate_genre_combo(
         self,
-        app_config: AppConfiguration = None,
+        app_config: AppConfiguration | None = None,
         force: bool = False,
     ):
         if not AdapterManager.can_get_genres():
@@ -207,7 +197,7 @@ def populate_genre_combo(
 
         def get_genres_done(f: Result):
             try:
-                genre_names = map(lambda g: g.name, f.result() or [])
+                genre_names = (g.name for g in f.result() or [])
                 new_store = [(name, name, True) for name in sorted(genre_names)]
 
                 util.diff_song_store(self.genre_combo_store, new_store)
@@ -231,7 +221,7 @@ def get_genres_done(f: Result):
         except Exception:
             self.updating_query = False
 
-    def update(self, app_config: AppConfiguration = None, force: bool = False):
+    def update(self, app_config: AppConfiguration | None = None, force: bool = False):
         self.updating_query = True
 
         supported_type_strings = {
@@ -294,27 +284,23 @@ def show_if(sort_type: Iterable[AlbumSearchQuery.Type], *elements):
         )
 
         # (En|Dis)able the sort button
-        self.sort_toggle.set_sensitive(
-            self.current_query.type != AlbumSearchQuery.Type.RANDOM
-        )
+        self.sort_toggle.set_sensitive(self.current_query.type != AlbumSearchQuery.Type.RANDOM)
 
         if app_config:
             self.album_sort_direction = app_config.state.album_sort_direction
             self.sort_toggle.set_icon(f"view-sort-{self.album_sort_direction}-symbolic")
             self.sort_toggle.set_tooltip_text(
-                "Change sort order to "
-                + self._get_opposite_sort_dir(self.album_sort_direction)
+                "Change sort order to " + self._get_opposite_sort_dir(self.album_sort_direction)
             )
 
-            self.show_count_dropdown.set_active_id(
-                str(app_config.state.album_page_size)
-            )
+            self.show_count_dropdown.set_active_id(str(app_config.state.album_page_size))
 
         # Has to be last because it resets self.updating_query
         self.populate_genre_combo(app_config, force=force)
 
         # At this point, the current query should be totally updated.
-        self.grid_order_token = self.grid.update_params(app_config)
+        if app_config:
+            self.grid_order_token = self.grid.update_params(app_config)
         self.grid.update(self.grid_order_token, app_config, force=force)
 
     def _get_opposite_sort_dir(self, sort_dir: str) -> str:
@@ -330,9 +316,7 @@ def on_sort_toggle_clicked(self, _):
         self.emit(
             "refresh-window",
             {
-                "album_sort_direction": self._get_opposite_sort_dir(
-                    self.album_sort_direction
-                ),
+                "album_sort_direction": self._get_opposite_sort_dir(self.album_sort_direction),
                 "album_page": 0,
                 "selected_album_id": None,
             },
@@ -588,9 +572,7 @@ def create_flowbox(**kwargs) -> Gtk.FlowBox:
         grid_detail_grid_box.add(self.grid_top)
 
         self.detail_box_revealer = Gtk.Revealer(valign=Gtk.Align.END)
-        self.detail_box = Gtk.Box(
-            orientation=Gtk.Orientation.HORIZONTAL, name="artist-detail-box"
-        )
+        self.detail_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="artist-detail-box")
         self.detail_box.pack_start(Gtk.Box(), True, True, 0)
 
         self.detail_box_inner = Gtk.Box()
@@ -604,9 +586,7 @@ def create_flowbox(**kwargs) -> Gtk.FlowBox:
         self.grid_bottom.connect("child-activated", self.on_child_activated)
 
         self.list_store_bottom = Gio.ListStore()
-        self.grid_bottom.bind_model(
-            self.list_store_bottom, self._create_cover_art_widget
-        )
+        self.grid_bottom.bind_model(self.list_store_bottom, self._create_cover_art_widget)
 
         grid_detail_grid_box.add(self.grid_bottom)
 
@@ -622,7 +602,7 @@ def create_flowbox(**kwargs) -> Gtk.FlowBox:
         self.add_overlay(self.spinner)
 
     def update(
-        self, order_token: int, app_config: AppConfiguration = None, force: bool = False
+        self, order_token: int, app_config: AppConfiguration | None = None, force: bool = False
     ):
         if order_token < self.latest_applied_order_ratchet:
             return
@@ -719,8 +699,7 @@ def reload_store(f: Result[Iterable[API.Album]]):
             for c in self.error_container.get_children():
                 self.error_container.remove(c)
             if is_partial and (
-                len(albums) == 0
-                or self.current_query.type != AlbumSearchQuery.Type.RANDOM
+                len(albums) == 0 or self.current_query.type != AlbumSearchQuery.Type.RANDOM
             ):
                 load_error = LoadError(
                     "Album list",
@@ -758,9 +737,7 @@ def reload_store(f: Result[Iterable[API.Album]]):
                 albums_result.add_done_callback(reload_store)
             else:
                 self.spinner.show()
-                albums_result.add_done_callback(
-                    lambda f: GLib.idle_add(reload_store, f)
-                )
+                albums_result.add_done_callback(lambda f: GLib.idle_add(reload_store, f))
         else:
             selected_index = None
             for i, album in enumerate(self.current_models):
@@ -845,9 +822,7 @@ def on_artwork_downloaded(filename: Result[str]):
             artwork.set_from_file(filename.result())
             artwork.set_loading(False)
 
-        cover_art_filename_future = AdapterManager.get_cover_art_uri(
-            item.album.cover_art, "file"
-        )
+        cover_art_filename_future = AdapterManager.get_cover_art_uri(item.album.cover_art, "file")
         if cover_art_filename_future.data_is_available:
             on_artwork_downloaded(cover_art_filename_future)
         else:
@@ -862,17 +837,15 @@ def on_artwork_downloaded(filename: Result[str]):
     def reflow_grids(
         self,
         force_reload_from_master: bool = False,
-        selected_index: int = None,
-        models: List[_AlbumModel] = None,
+        selected_index: int | None = None,
+        models: List[_AlbumModel] | None = None,
     ):
         # Calculate the page that the currently_selected_index is in. If it's a
         # different page, then update the window.
         if selected_index is not None:
             page_of_selected_index = selected_index // self.page_size
             if page_of_selected_index != self.page:
-                self.emit(
-                    "refresh-window", {"album_page": page_of_selected_index}, False
-                )
+                self.emit("refresh-window", {"album_page": page_of_selected_index}, False)
                 return
         page_offset = self.page_size * self.page
 
@@ -925,9 +898,7 @@ def reflow_grids(
             if diff > 0:
                 if entries_before_fold - top_store_len > 0:
                     # Move entries from the bottom store.
-                    self.list_store_top.splice(
-                        top_store_len, 0, self.list_store_bottom[:diff]
-                    )
+                    self.list_store_top.splice(top_store_len, 0, self.list_store_bottom[:diff])
                     self.list_store_bottom.splice(0, min(diff, bottom_store_len), [])
                 else:
                     # Move entries to the bottom store.
diff --git a/sublime_music/ui/artists.py b/sublime_music/ui/artists.py
index 5214338c..8fd88bcb 100644
--- a/sublime_music/ui/artists.py
+++ b/sublime_music/ui/artists.py
@@ -1,18 +1,12 @@
 from datetime import timedelta
 from functools import partial
 from random import randint
-from typing import cast, List, Sequence
+from typing import List, Sequence, cast
 
 import bleach
-
 from gi.repository import Gio, GLib, GObject, Gtk, Pango
 
-from ..adapters import (
-    AdapterManager,
-    api_objects as API,
-    CacheMissError,
-    SongCacheStatus,
-)
+from ..adapters import AdapterManager, CacheMissError, SongCacheStatus, api_objects as API
 from ..config import AppConfiguration
 from ..ui import util
 from ..ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage
@@ -74,9 +68,7 @@ def __init__(self):
 
         list_actions = Gtk.ActionBar()
 
-        self.refresh_button = IconButton(
-            "view-refresh-symbolic", "Refresh list of artists"
-        )
+        self.refresh_button = IconButton("view-refresh-symbolic", "Refresh list of artists")
         self.refresh_button.connect("clicked", lambda *a: self.update(force=True))
         list_actions.pack_end(self.refresh_button)
 
@@ -135,7 +127,7 @@ def create_artist_row(model: _ArtistModel) -> Gtk.ListBoxRow:
     def update(
         self,
         artists: Sequence[API.Artist],
-        app_config: AppConfiguration = None,
+        app_config: AppConfiguration | None = None,
         is_partial: bool = False,
         **kwargs,
     ):
@@ -150,9 +142,7 @@ def update(
                 "Artist list",
                 "load artists",
                 has_data=len(artists) > 0,
-                offline_mode=(
-                    self._app_config.offline_mode if self._app_config else False
-                ),
+                offline_mode=(self._app_config.offline_mode if self._app_config else False),
             )
             self.error_container.pack_start(load_error, True, True, 0)
             self.error_container.show_all()
@@ -230,14 +220,10 @@ def __init__(self, *args, **kwargs):
         self.artist_indicator = self.make_label(name="artist-indicator")
         artist_details_box.add(self.artist_indicator)
 
-        self.artist_name = self.make_label(
-            name="artist-name", ellipsize=Pango.EllipsizeMode.END
-        )
+        self.artist_name = self.make_label(name="artist-name", ellipsize=Pango.EllipsizeMode.END)
         artist_details_box.add(self.artist_name)
 
-        self.artist_bio = self.make_label(
-            name="artist-bio", justify=Gtk.Justification.LEFT
-        )
+        self.artist_bio = self.make_label(name="artist-bio", justify=Gtk.Justification.LEFT)
         self.artist_bio.set_line_wrap(True)
         artist_details_box.add(self.artist_bio)
 
@@ -247,9 +233,7 @@ def __init__(self, *args, **kwargs):
         self.similar_artists_label = self.make_label(name="similar-artists")
         similar_artists_box.add(self.similar_artists_label)
 
-        self.similar_artists_button_box = Gtk.Box(
-            orientation=Gtk.Orientation.HORIZONTAL
-        )
+        self.similar_artists_button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
         similar_artists_box.add(self.similar_artists_button_box)
         self.similar_artists_scrolledwindow.add(similar_artists_box)
 
@@ -280,9 +264,7 @@ def __init__(self, *args, **kwargs):
 
         # Action buttons
         action_buttons_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
-        self.artist_action_buttons = Gtk.Box(
-            orientation=Gtk.Orientation.HORIZONTAL, spacing=10
-        )
+        self.artist_action_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
 
         self.download_all_button = IconButton(
             "folder-download-symbolic", "Download all songs by this artist"
@@ -294,16 +276,12 @@ def __init__(self, *args, **kwargs):
         self.refresh_button.connect("clicked", self.on_view_refresh_click)
         self.artist_action_buttons.add(self.refresh_button)
 
-        action_buttons_container.pack_start(
-            self.artist_action_buttons, False, False, 10
-        )
+        action_buttons_container.pack_start(self.artist_action_buttons, False, False, 10)
 
         action_buttons_container.pack_start(Gtk.Box(), True, True, 0)
 
         expand_button_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
-        self.expand_collapse_button = IconButton(
-            "pan-up-symbolic", "Expand playlist details"
-        )
+        self.expand_collapse_button = IconButton("pan-up-symbolic", "Expand playlist details")
         self.expand_collapse_button.connect("clicked", self.on_expand_collapse_click)
         expand_button_container.pack_end(self.expand_collapse_button, False, False, 0)
         action_buttons_container.add(expand_button_container)
@@ -352,7 +330,7 @@ def update_artist_view(
         artist: API.Artist,
         app_config: AppConfiguration,
         force: bool = False,
-        order_token: int = None,
+        order_token: int | None = None,
         is_partial: bool = False,
     ):
         if order_token != self.update_order_token:
@@ -478,7 +456,7 @@ def update_artist_artwork(
         cover_art_filename: str,
         app_config: AppConfiguration,
         force: bool = False,
-        order_token: int = None,
+        order_token: int | None = None,
         is_partial: bool = False,
     ):
         if order_token != self.update_order_token:
@@ -553,10 +531,8 @@ def set_all_loading(self, loading_state: bool):
             self.albums_list.spinner.hide()
             self.artist_artwork.set_loading(False)
 
-    def make_label(self, text: str = None, name: str = None, **params) -> Gtk.Label:
-        return Gtk.Label(
-            label=text, name=name, halign=Gtk.Align.START, xalign=0, **params
-        )
+    def make_label(self, text: str | None = None, name: str | None = None, **params) -> Gtk.Label:
+        return Gtk.Label(label=text, name=name, halign=Gtk.Align.START, xalign=0, **params)
 
     def format_stats(self, artist: API.Artist) -> str:
         album_count = artist.album_count or len(artist.albums or [])
@@ -572,6 +548,7 @@ def format_stats(self, artist: API.Artist) -> str:
         )
 
     def get_artist_song_ids(self) -> List[str]:
+        assert self.artist_id
         try:
             artist = AdapterManager.get_artist(self.artist_id).result()
         except CacheMissError as c:
@@ -619,9 +596,7 @@ def __init__(self):
 
         self.albums = []
 
-    def update(
-        self, artist: API.Artist, app_config: AppConfiguration, force: bool = False
-    ):
+    def update(self, artist: API.Artist, app_config: AppConfiguration, force: bool = False):
         def remove_all():
             for c in self.box.get_children():
                 self.box.remove(c)
@@ -631,9 +606,7 @@ def remove_all():
             self.spinner.hide()
             return
 
-        new_albums = sorted(
-            artist.albums or [], key=lambda a: (a.year or float("inf"), a.name)
-        )
+        new_albums = sorted(artist.albums or [], key=lambda a: (a.year or float("inf"), a.name))
 
         if self.albums == new_albums:
             # Just go through all of the colidren and update them.
diff --git a/sublime_music/ui/browse.py b/sublime_music/ui/browse.py
index 8b6d2383..5b762c58 100644
--- a/sublime_music/ui/browse.py
+++ b/sublime_music/ui/browse.py
@@ -1,11 +1,10 @@
 from functools import partial
-from typing import Any, cast, List, Optional, Tuple
+from typing import Any, List, Optional, Tuple, cast
 
 import bleach
-
 from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
 
-from ..adapters import AdapterManager, api_objects as API, CacheMissError, Result
+from ..adapters import AdapterManager, CacheMissError, Result, api_objects as API
 from ..config import AppConfiguration
 from ..ui import util
 from ..ui.common import IconButton, LoadError, SongListColumn
@@ -108,9 +107,7 @@ def calculate_path() -> Tuple[str, ...]:
 
         path_result: Result[Tuple[str, ...]] = Result(calculate_path)
         path_result.add_done_callback(
-            lambda f: GLib.idle_add(
-                partial(do_update, self.update_order_token), f.result()
-            )
+            lambda f: GLib.idle_add(partial(do_update, self.update_order_token), f.result())
         )
 
 
@@ -180,9 +177,7 @@ def update(
                 self.box.add(drilldown)
                 self.box.show_all()
 
-            self.box.get_children()[0].update(
-                tuple(child_id_stack), app_config, force=force
-            )
+            self.box.get_children()[0].update(tuple(child_id_stack), app_config, force=force)
 
 
 class MusicDirectoryList(Gtk.Box):
@@ -262,14 +257,10 @@ def __init__(self):
         self.directory_song_list.append_column(column)
 
         self.directory_song_list.append_column(SongListColumn("TITLE", 2, bold=True))
-        self.directory_song_list.append_column(
-            SongListColumn("DURATION", 3, align=1, width=40)
-        )
+        self.directory_song_list.append_column(SongListColumn("DURATION", 3, align=1, width=40))
 
         self.directory_song_list.connect("row-activated", self.on_song_activated)
-        self.directory_song_list.connect(
-            "button-press-event", self.on_song_button_press
-        )
+        self.directory_song_list.connect("button-press-event", self.on_song_button_press)
         scrollbox.add(self.directory_song_list)
 
         self.scroll_window.add(scrollbox)
@@ -277,10 +268,10 @@ def __init__(self):
 
     def update(
         self,
-        app_config: AppConfiguration = None,
+        app_config: AppConfiguration | None = None,
         force: bool = False,
-        directory_id: str = None,
-        selected_id: str = None,
+        directory_id: str | None = None,
+        selected_id: str | None = None,
     ):
         self.directory_id = directory_id or self.directory_id
         self.selected_id = selected_id or self.selected_id
@@ -311,9 +302,9 @@ def update(
     def update_store(
         self,
         directory: API.Directory,
-        app_config: AppConfiguration = None,
+        app_config: AppConfiguration | None = None,
         force: bool = False,
-        order_token: int = None,
+        order_token: int | None = None,
         is_partial: bool = False,
     ):
         if order_token != self.update_order_token:
@@ -373,9 +364,7 @@ def update_store(
                 else:
                     songs.append(cast(API.Song, el))
 
-            util.diff_model_store(
-                self.drilldown_directories_store, new_directories_store
-            )
+            util.diff_model_store(self.drilldown_directories_store, new_directories_store)
 
             def song_sort_key(song: API.Song) -> Tuple[Optional[int], Optional[int]]:
                 return (
@@ -389,25 +378,21 @@ def song_sort_key(song: API.Song) -> Tuple[Optional[int], Optional[int]]:
                 [
                     (
                         not self.offline_mode
-                        or status_icon
-                        in ("folder-download-symbolic", "view-pin-symbolic")
+                        or status_icon in ("folder-download-symbolic", "view-pin-symbolic")
                     ),
                     status_icon,
                     bleach.clean(song.title),
                     util.format_song_duration(song.duration),
                     song.id,
                 ]
-                for status_icon, song in zip(
-                    util.get_cached_status_icons(song_ids), songs
-                )
+                for status_icon, song in zip(util.get_cached_status_icons(song_ids), songs)
             ]
         else:
             new_songs_store = [
                 [
                     (
                         not self.offline_mode
-                        or status_icon
-                        in ("folder-download-symbolic", "view-pin-symbolic")
+                        or status_icon in ("folder-download-symbolic", "view-pin-symbolic")
                     ),
                     status_icon,
                     *song_model[2:],
diff --git a/sublime_music/ui/common/album_with_songs.py b/sublime_music/ui/common/album_with_songs.py
index e6c56a7c..65557ae8 100644
--- a/sublime_music/ui/common/album_with_songs.py
+++ b/sublime_music/ui/common/album_with_songs.py
@@ -1,9 +1,9 @@
 from random import randint
-from typing import Any, cast, List
+from typing import Any, List, cast
 
 from gi.repository import Gdk, GLib, GObject, Gtk, Pango
 
-from sublime_music.adapters import AdapterManager, api_objects as API, Result
+from sublime_music.adapters import AdapterManager, Result, api_objects as API
 from sublime_music.config import AppConfiguration
 from sublime_music.ui import util
 
@@ -162,9 +162,7 @@ def cover_art_future_done(f: Result):
 
         self.album_songs.connect("row-activated", self.on_song_activated)
         self.album_songs.connect("button-press-event", self.on_song_button_press)
-        self.album_songs.get_selection().connect(
-            "changed", self.on_song_selection_change
-        )
+        self.album_songs.get_selection().connect("changed", self.on_song_selection_change)
         album_details.add(self.album_songs)
 
         self.pack_end(album_details, True, True, 0)
@@ -257,7 +255,7 @@ def shuffle_btn_clicked(self, btn: Any):
     def deselect_all(self):
         self.album_songs.get_selection().unselect_all()
 
-    def update(self, app_config: AppConfiguration = None, force: bool = False):
+    def update(self, app_config: AppConfiguration | None = None, force: bool = False):
         if app_config:
             # Deselect everything and reset the error container if switching between
             # online and offline.
@@ -293,7 +291,7 @@ def update_album_songs(
         album: API.Album,
         app_config: AppConfiguration,
         force: bool = False,
-        order_token: int = None,
+        order_token: int | None = None,
         is_partial: bool = False,
     ):
         songs = album.songs or []
diff --git a/sublime_music/ui/common/icon_button.py b/sublime_music/ui/common/icon_button.py
index 418c15b0..2f892f64 100644
--- a/sublime_music/ui/common/icon_button.py
+++ b/sublime_music/ui/common/icon_button.py
@@ -10,7 +10,7 @@ def __init__(
         tooltip_text: str = "",
         relief: bool = False,
         icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON,
-        label: str = None,
+        label: str | None = None,
         **kwargs,
     ):
         Gtk.Button.__init__(self, **kwargs)
@@ -41,7 +41,7 @@ def __init__(
         tooltip_text: str = "",
         relief: bool = False,
         icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON,
-        label: str = None,
+        label: str | None = None,
         **kwargs,
     ):
         Gtk.ToggleButton.__init__(self, **kwargs)
@@ -77,7 +77,7 @@ def __init__(
         tooltip_text: str = "",
         relief: bool = True,
         icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON,
-        label: str = None,
+        label: str | None = None,
         popover: Any = None,
         **kwargs,
     ):
diff --git a/sublime_music/ui/common/load_error.py b/sublime_music/ui/common/load_error.py
index f59c2997..45c12c7e 100644
--- a/sublime_music/ui/common/load_error.py
+++ b/sublime_music/ui/common/load_error.py
@@ -2,16 +2,12 @@
 
 
 class LoadError(Gtk.Box):
-    def __init__(
-        self, entity_name: str, action: str, has_data: bool, offline_mode: bool
-    ):
+    def __init__(self, entity_name: str, action: str, has_data: bool, offline_mode: bool):
         Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
 
         self.pack_start(Gtk.Box(), True, True, 0)
 
-        inner_box = Gtk.Box(
-            orientation=Gtk.Orientation.HORIZONTAL, name="load-error-box"
-        )
+        inner_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="load-error-box")
 
         inner_box.pack_start(Gtk.Box(), True, True, 0)
 
diff --git a/sublime_music/ui/common/song_list_column.py b/sublime_music/ui/common/song_list_column.py
index e7cbdfe2..52dd6f38 100644
--- a/sublime_music/ui/common/song_list_column.py
+++ b/sublime_music/ui/common/song_list_column.py
@@ -8,7 +8,7 @@ def __init__(
         text_idx: int,
         bold: bool = False,
         align: float = 0,
-        width: int = None,
+        width: int | None = None,
     ):
         """Represents a column in a song list."""
         renderer = Gtk.CellRendererText(
diff --git a/sublime_music/ui/common/spinner_image.py b/sublime_music/ui/common/spinner_image.py
index 35c9658d..169d1dbe 100644
--- a/sublime_music/ui/common/spinner_image.py
+++ b/sublime_music/ui/common/spinner_image.py
@@ -7,9 +7,9 @@ class SpinnerImage(Gtk.Overlay):
     def __init__(
         self,
         loading: bool = True,
-        image_name: str = None,
-        spinner_name: str = None,
-        image_size: int = None,
+        image_name: str | None = None,
+        spinner_name: str | None = None,
+        image_size: int | None = None,
         **kwargs,
     ):
         """An image with a loading overlay."""
diff --git a/sublime_music/ui/configure_provider.py b/sublime_music/ui/configure_provider.py
index cf28be88..3c3fc421 100644
--- a/sublime_music/ui/configure_provider.py
+++ b/sublime_music/ui/configure_provider.py
@@ -26,7 +26,7 @@ class ConfigureProviderDialog(Gtk.Dialog):
     _current_index = -1
     stage = DialogStage.SELECT_ADAPTER
 
-    def set_title(self, editing: bool, provider_config: ProviderConfiguration = None):
+    def set_title(self, editing: bool, provider_config: ProviderConfiguration | None = None):
         if editing:
             assert provider_config is not None
             title = f"Edit {provider_config.name}"
@@ -116,10 +116,7 @@ def create_row(model: AdapterTypeModel) -> Gtk.ListBoxRow:
         if self.editing:
             assert self.provider_config
             for i, adapter_type in enumerate(self.adapter_type_store):
-                if (
-                    adapter_type.adapter_type
-                    == self.provider_config.ground_truth_adapter_type
-                ):
+                if adapter_type.adapter_type == self.provider_config.ground_truth_adapter_type:
                     row = self.adapter_options_list.get_row_at_index(i)
                     self.adapter_options_list.select_row(row)
                     break
@@ -203,9 +200,7 @@ def _on_next_add_clicked(self, *args):
     _adapter_config_is_valid = False
 
     def _update_add_button_sensitive(self):
-        self.next_add_button.set_sensitive(
-            self._name_is_valid and self._adapter_config_is_valid
-        )
+        self.next_add_button.set_sensitive(self._name_is_valid and self._adapter_config_is_valid)
 
     def _on_name_change(self, entry: Gtk.Entry):
         if entry.get_text():
diff --git a/sublime_music/ui/main.py b/sublime_music/ui/main.py
index ab858df1..fc2c6fa0 100644
--- a/sublime_music/ui/main.py
+++ b/sublime_music/ui/main.py
@@ -2,15 +2,9 @@
 from typing import Any, Callable, Dict, Optional, Set, Tuple
 
 import bleach
-
 from gi.repository import Gdk, GLib, GObject, Gtk, Pango
 
-from ..adapters import (
-    AdapterManager,
-    api_objects as API,
-    DownloadProgress,
-    Result,
-)
+from ..adapters import AdapterManager, DownloadProgress, Result, api_objects as API
 from ..config import AppConfiguration, ProviderConfiguration
 from ..players import PlayerManager
 from ..ui import albums, artists, browse, player_controls, playlists, util
@@ -69,9 +63,7 @@ def __init__(self, *args, **kwargs):
 
         notification_container.add(self.stack)
 
-        self.notification_revealer = Gtk.Revealer(
-            valign=Gtk.Align.END, halign=Gtk.Align.CENTER
-        )
+        self.notification_revealer = Gtk.Revealer(valign=Gtk.Align.END, halign=Gtk.Align.CENTER)
 
         notification_box = Gtk.Box(can_focus=False, valign="start", spacing=10)
         notification_box.get_style_context().add_class("app-notification")
@@ -95,12 +87,8 @@ def __init__(self, *args, **kwargs):
 
         # Player Controls
         self.player_controls = player_controls.PlayerControls()
-        self.player_controls.connect(
-            "song-clicked", lambda _, *a: self.emit("song-clicked", *a)
-        )
-        self.player_controls.connect(
-            "songs-removed", lambda _, *a: self.emit("songs-removed", *a)
-        )
+        self.player_controls.connect("song-clicked", lambda _, *a: self.emit("song-clicked", *a))
+        self.player_controls.connect("songs-removed", lambda _, *a: self.emit("songs-removed", *a))
         self.player_controls.connect(
             "refresh-window",
             lambda _, *args: self.emit("refresh-window", *args),
@@ -125,9 +113,7 @@ def update(
             self.current_notification_hash = h
 
             if notification.icon:
-                self.notification_icon.set_from_icon_name(
-                    notification.icon, Gtk.IconSize.DND
-                )
+                self.notification_icon.set_from_icon_name(notification.icon, Gtk.IconSize.DND)
             else:
                 self.notification_icon.set_from_icon_name(None, Gtk.IconSize.DND)
 
@@ -155,7 +141,7 @@ def update(
             self.connected_to_label.set_markup("<i>No Music Source Selected</i>")
             icon_basename = "list-add"
 
-        if AdapterManager.ground_truth_adapter_is_networked:
+        if AdapterManager.ground_truth_adapter_is_networked():
             status_label = ""
             if app_config.offline_mode:
                 status_label = "Offline"
@@ -166,9 +152,7 @@ def update(
 
             icon_status = status_label.split()[0].lower()
 
-            self.server_connection_menu_button.set_icon(
-                f"{icon_basename}-{icon_status}-symbolic"
-            )
+            self.server_connection_menu_button.set_icon(f"{icon_basename}-{icon_status}-symbolic")
             self.connection_status_icon.set_from_icon_name(
                 f"server-{icon_status}-symbolic",
                 Gtk.IconSize.BUTTON,
@@ -187,9 +171,7 @@ def update(
 
         # Switch Provider options
         other_providers = tuple(
-            v
-            for k, v in app_config.providers.items()
-            if k != app_config.current_provider_id
+            v for k, v in app_config.providers.items() if k != app_config.current_provider_id
         )
         if self.current_other_providers != other_providers:
             self.current_other_providers = other_providers
@@ -233,9 +215,7 @@ def emit_player_settings_change(
         for player_name, options in player_manager.get_configuration_options().items():
             self.player_settings_box.add(Gtk.Separator())
             self.player_settings_box.add(
-                self._create_label(
-                    f"{player_name} Settings", name="menu-settings-separator"
-                )
+                self._create_label(f"{player_name} Settings", name="menu-settings-separator")
             )
 
             for option_name, descriptor in options.items():
@@ -243,9 +223,7 @@ def emit_player_settings_change(
                 setting_box.add(option_name_label := Gtk.Label(label=option_name))
                 option_name_label.get_style_context().add_class("menu-label")
 
-                option_value = app_config.player_config.get(player_name, {}).get(
-                    option_name
-                )
+                option_value = app_config.player_config.get(player_name, {}).get(option_name)
 
                 if type(descriptor) == tuple:
                     option_store = Gtk.ListStore(str)
@@ -327,7 +305,7 @@ def on_cancel_button_click(*a):
                             emit_player_settings_change,
                             player_name,
                             option_name,
-                            lambda b: int(entry.get_text()),
+                            lambda _: int(entry.get_text()),
                         ),
                     )
                     cancel_button.connect("clicked", on_cancel_button_click)
@@ -347,9 +325,7 @@ def on_cancel_button_click(*a):
         self.allow_song_downloads_switch.set_active(allow_song_downloads)
         self.download_on_stream_switch.set_active(app_config.download_on_stream)
         self.prefetch_songs_entry.set_value(app_config.prefetch_amount)
-        self.max_concurrent_downloads_entry.set_value(
-            app_config.concurrent_download_limit
-        )
+        self.max_concurrent_downloads_entry.set_value(app_config.concurrent_download_limit)
         self.download_on_stream_switch.set_sensitive(allow_song_downloads)
         self.prefetch_songs_entry.set_sensitive(allow_song_downloads)
         self.max_concurrent_downloads_entry.set_sensitive(allow_song_downloads)
@@ -403,17 +379,13 @@ def update_song_download_progress(self, song_id: str, progress: DownloadProgress
             if song_id in self._failed_downloads:
                 self._failed_downloads.remove(song_id)
 
-            self._current_download_boxes[song_id].update_progress(
-                progress.progress_fraction
-            )
+            self._current_download_boxes[song_id].update_progress(progress.progress_fraction)
 
         # Show or hide the "failed count" indicator.
         failed_download_count = len(self._failed_downloads)
         if failed_download_count > 0:
             if not self._failed_downloads_box:
-                self._failed_downloads_box = Gtk.Box(
-                    orientation=Gtk.Orientation.HORIZONTAL
-                )
+                self._failed_downloads_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
 
                 self._failed_downloads_label = Gtk.Label(
                     label="",
@@ -428,9 +400,7 @@ def update_song_download_progress(self, song_id: str, progress: DownloadProgress
                 retry_all_button.connect("clicked", self._on_retry_all_clicked)
                 self._failed_downloads_box.pack_end(retry_all_button, False, False, 0)
 
-                self.current_downloads_box.pack_start(
-                    self._failed_downloads_box, False, False, 5
-                )
+                self.current_downloads_box.pack_start(self._failed_downloads_box, False, False, 5)
             songs = util.pluralize("song", failed_download_count)
             self._failed_downloads_label.set_text(
                 f"{failed_download_count} {songs} failed to download"
@@ -449,13 +419,9 @@ def update_song_download_progress(self, song_id: str, progress: DownloadProgress
                     halign=Gtk.Align.START,
                     name="current-downloads-list-pending-count",
                 )
-                self.current_downloads_box.pack_end(
-                    self._pending_downloads_label, False, False, 5
-                )
+                self.current_downloads_box.pack_end(self._pending_downloads_label, False, False, 5)
             songs = util.pluralize("song", pending_download_count)
-            self._pending_downloads_label.set_text(
-                f"+{pending_download_count} pending {songs}"
-            )
+            self._pending_downloads_label.set_text(f"+{pending_download_count} pending {songs}")
         else:
             if self._pending_downloads_label:
                 self.current_downloads_box.remove(self._pending_downloads_label)
@@ -464,9 +430,7 @@ def update_song_download_progress(self, song_id: str, progress: DownloadProgress
         # Show or hide the placeholder depending on whether or not there's anything to
         # show.
         current_downloads = (
-            len(self._current_download_boxes)
-            + pending_download_count
-            + failed_download_count
+            len(self._current_download_boxes) + pending_download_count + failed_download_count
         )
         if current_downloads == 0:
             if not self._current_downloads_placeholder:
@@ -526,9 +490,7 @@ def _create_headerbar(self, stack: Gtk.Stack) -> Gtk.HeaderBar:
         # Search
         self.search_entry = Gtk.SearchEntry(placeholder_text="Search everything...")
         self.search_entry.connect("focus-in-event", self._on_search_entry_focus)
-        self.search_entry.connect(
-            "button-press-event", self._on_search_entry_button_press
-        )
+        self.search_entry.connect("button-press-event", self._on_search_entry_button_press)
         self.search_entry.connect("focus-out-event", self._on_search_entry_loose_focus)
         self.search_entry.connect("changed", self._on_search_entry_changed)
         self.search_entry.connect("stop-search", self._on_search_entry_stop_search)
@@ -575,9 +537,7 @@ def _create_headerbar(self, stack: Gtk.Stack) -> Gtk.HeaderBar:
         self.server_connection_menu_button.connect(
             "clicked", self._on_server_connection_menu_clicked
         )
-        self.server_connection_popover.set_relative_to(
-            self.server_connection_menu_button
-        )
+        self.server_connection_popover.set_relative_to(self.server_connection_menu_button)
         button_box.add(self.server_connection_menu_button)
 
         header.pack_end(button_box)
@@ -616,9 +576,9 @@ def on_active_change(toggle: Gtk.Switch, _):
     def _create_model_button(
         self,
         text: str,
-        clicked_fn: Callable = None,
-        action_name: str = None,
-        action_value: GLib.Variant = None,
+        clicked_fn: Callable | None = None,
+        action_name: str | None = None,
+        action_value: GLib.Variant | None = None,
         **kwargs,
     ) -> Gtk.ModelButton:
         model_button = Gtk.ModelButton(text=text, **kwargs)
@@ -631,9 +591,7 @@ def _create_model_button(
             model_button.set_action_target_value(action_value)
         return model_button
 
-    def _create_switch_provider_button(
-        self, provider: ProviderConfiguration
-    ) -> Gtk.Box:
+    def _create_switch_provider_button(self, provider: ProviderConfiguration) -> Gtk.Box:
         box = Gtk.Box()
         provider_name_button = self._create_model_button(
             provider.name,
@@ -715,9 +673,7 @@ def _create_downloads_popover(self) -> Gtk.PopoverMenu:
         clear_cache_options = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
 
         # Back button
-        clear_cache_options.add(
-            Gtk.ModelButton(inverted=True, centered=True, menu_name="main")
-        )
+        clear_cache_options.add(Gtk.ModelButton(inverted=True, centered=True, menu_name="main"))
 
         # Clear Song File Cache
         menu_items = [
@@ -757,9 +713,7 @@ def _create_server_connection_popover(self) -> Gtk.PopoverMenu:
         self.connection_status_icon.set_name("online-status-icon")
         self.connected_status_box.add(self.connection_status_icon)
 
-        self.connection_status_label = Gtk.Label(
-            label="Connected", name="connection-status-label"
-        )
+        self.connection_status_label = Gtk.Label(label="Connected", name="connection-status-label")
         self.connected_status_box.add(self.connection_status_label)
 
         self.connected_status_box.pack_start(Gtk.Box(), True, True, 0)
@@ -824,33 +778,23 @@ def _create_main_menu(self) -> Gtk.PopoverMenu:
         # DOWNLOAD SETTINGS
         # ==============================================================================
         vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
-        vbox.add(
-            self._create_label("Download Settings", name="menu-settings-separator")
-        )
+        vbox.add(self._create_label("Download Settings", name="menu-settings-separator"))
 
         # Allow Song Downloads
         (
             allow_song_downloads,
             self.allow_song_downloads_switch,
-        ) = self._create_toggle_menu_button(
-            "Allow Song Downloads", "allow_song_downloads"
-        )
+        ) = self._create_toggle_menu_button("Allow Song Downloads", "allow_song_downloads")
         vbox.add(allow_song_downloads)
 
         # Download on Stream
-        (
-            download_on_stream,
-            self.download_on_stream_switch,
-        ) = self._create_toggle_menu_button(
+        (download_on_stream, self.download_on_stream_switch,) = self._create_toggle_menu_button(
             "When Streaming, Also Download Song", "download_on_stream"
         )
         vbox.add(download_on_stream)
 
         # Prefetch Songs
-        (
-            prefetch_songs_box,
-            self.prefetch_songs_entry,
-        ) = self._create_spin_button_menu_item(
+        (prefetch_songs_box, self.prefetch_songs_entry,) = self._create_spin_button_menu_item(
             "Number of Songs to Prefetch", 0, 10, 1, "prefetch_amount"
         )
         vbox.add(prefetch_songs_box)
@@ -937,9 +881,7 @@ def _on_button_release(self, win: Any, event: Gdk.EventButton) -> bool:
 
         return False
 
-    def _prompt_confirm_clear_cache(
-        self, title: str, detail_text: str
-    ) -> Gtk.ResponseType:
+    def _prompt_confirm_clear_cache(self, title: str, detail_text: str) -> Gtk.ResponseType:
         confirm_dialog = Gtk.MessageDialog(
             transient_for=self.get_toplevel(),
             message_type=Gtk.MessageType.WARNING,
@@ -1183,9 +1125,7 @@ def __init__(self, song_id: str):
 
         self.song = AdapterManager.get_song_details(song_id).result()
 
-        image = SpinnerImage(
-            image_size=30, image_name="current-downloads-cover-art-image"
-        )
+        image = SpinnerImage(image_size=30, image_name="current-downloads-cover-art-image")
         self.add(image)
 
         artist = self.song.artist.name if self.song.artist else None
@@ -1203,12 +1143,8 @@ def __init__(self, song_id: str):
         self.download_progress = Gtk.ProgressBar(show_text=True)
         self.add(self.download_progress)
 
-        self.cancel_button = IconButton(
-            "process-stop-symbolic", tooltip_text="Cancel download"
-        )
-        self.cancel_button.connect(
-            "clicked", lambda *a: self.emit("cancel-clicked", self.song.id)
-        )
+        self.cancel_button = IconButton("process-stop-symbolic", tooltip_text="Cancel download")
+        self.cancel_button.connect("clicked", lambda *a: self.emit("cancel-clicked", self.song.id))
         self.add(self.cancel_button)
 
         def image_callback(f: Result):
diff --git a/sublime_music/ui/player_controls.py b/sublime_music/ui/player_controls.py
index 33f0b753..e1ea2686 100644
--- a/sublime_music/ui/player_controls.py
+++ b/sublime_music/ui/player_controls.py
@@ -5,16 +5,15 @@
 from typing import Any, Callable, Dict, Optional, Set, Tuple
 
 import bleach
-
 from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango
 
-from . import util
-from .common import IconButton, IconToggleButton, SpinnerImage
-from .state import RepeatType
 from ..adapters import AdapterManager, Result, SongCacheStatus
 from ..adapters.api_objects import Song
 from ..config import AppConfiguration
 from ..util import resolve_path
+from . import util
+from .common import IconButton, IconToggleButton, SpinnerImage
+from .state import RepeatType
 
 
 class PlayerControls(Gtk.ActionBar):
@@ -71,24 +70,16 @@ def update(self, app_config: AppConfiguration, force: bool = False):
         self.update_device_list(app_config)
 
         duration = (
-            app_config.state.current_song.duration
-            if app_config.state.current_song
-            else None
+            app_config.state.current_song.duration if app_config.state.current_song else None
         )
         song_stream_cache_progress = (
-            app_config.state.song_stream_cache_progress
-            if app_config.state.current_song
-            else None
-        )
-        self.update_scrubber(
-            app_config.state.song_progress, duration, song_stream_cache_progress
+            app_config.state.song_stream_cache_progress if app_config.state.current_song else None
         )
+        self.update_scrubber(app_config.state.song_progress, duration, song_stream_cache_progress)
 
         icon = "pause" if app_config.state.playing else "start"
         self.play_button.set_icon(f"media-playback-{icon}-symbolic")
-        self.play_button.set_tooltip_text(
-            "Pause" if app_config.state.playing else "Play"
-        )
+        self.play_button.set_tooltip_text("Pause" if app_config.state.playing else "Play")
 
         has_current_song = app_config.state.current_song is not None
         has_next_song = False
@@ -156,9 +147,7 @@ def cycle_connecting(connecting_to_device_token: int):
         self.volume_mute_toggle.set_icon(f"audio-volume-{icon_name}-symbolic")
 
         self.editing = True
-        self.volume_slider.set_value(
-            0 if app_config.state.is_muted else app_config.state.volume
-        )
+        self.volume_slider.set_value(0 if app_config.state.is_muted else app_config.state.volume)
         self.editing = False
 
         # Update the current song information.
@@ -170,9 +159,7 @@ def cycle_connecting(connecting_to_device_token: int):
                 order_token=self.cover_art_update_order_token,
             )
 
-            self.song_title.set_markup(
-                bleach.clean(app_config.state.current_song.title)
-            )
+            self.song_title.set_markup(bleach.clean(app_config.state.current_song.title))
             # TODO (#71): use walrus once MYPY gets its act together
             album = app_config.state.current_song.album
             artist = app_config.state.current_song.artist
@@ -221,9 +208,7 @@ def cycle_connecting(connecting_to_device_token: int):
             self.popover_label.set_markup("<b>Play Queue</b>")
         else:
             song_label = util.pluralize("song", play_queue_len)
-            self.popover_label.set_markup(
-                f"<b>Play Queue:</b> {play_queue_len} {song_label}"
-            )
+            self.popover_label.set_markup(f"<b>Play Queue:</b> {play_queue_len} {song_label}")
 
         # TODO (#207) this is super freaking stupid inefficient.
         # IDEAS: batch it, don't get the queue until requested
@@ -263,9 +248,7 @@ def get_cover_art_filename_or_create_future(
             cover_art_result = AdapterManager.get_cover_art_uri(cover_art_id, "file")
             if not cover_art_result.data_is_available:
                 cover_art_result.add_done_callback(
-                    make_idle_index_capturing_function(
-                        idx, order_token, on_cover_art_future_done
-                    )
+                    make_idle_index_capturing_function(idx, order_token, on_cover_art_future_done)
                 )
                 return None
 
@@ -352,7 +335,7 @@ def update_cover_art(
         cover_art_filename: str,
         app_config: AppConfiguration,
         force: bool = False,
-        order_token: int = None,
+        order_token: int | None = None,
         is_partial: bool = False,
     ):
         if order_token != self.cover_art_update_order_token:
@@ -426,21 +409,15 @@ def update_device_list(self, app_config: AppConfiguration):
             return
 
         self._current_player_id = app_config.state.current_device
-        self._current_available_players = copy.deepcopy(
-            app_config.state.available_players
-        )
+        self._current_available_players = copy.deepcopy(app_config.state.available_players)
         for c in self.device_list.get_children():
             self.device_list.remove(c)
 
-        for i, (player_type, players) in enumerate(
-            app_config.state.available_players.items()
-        ):
+        for i, (player_type, players) in enumerate(app_config.state.available_players.items()):
             if len(players) == 0:
                 continue
             if i > 0:
-                self.device_list.add(
-                    Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
-                )
+                self.device_list.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
             self.device_list.add(
                 Gtk.Label(
                     label=f"{player_type.name} Devices",
@@ -450,11 +427,7 @@ def update_device_list(self, app_config: AppConfiguration):
             )
 
             for player_id, player_name in sorted(players, key=lambda p: p[1]):
-                icon = (
-                    "audio-volume-high-symbolic"
-                    if player_id == self.current_device
-                    else None
-                )
+                icon = "audio-volume-high-symbolic" if player_id == self.current_device else None
                 button = IconButton(icon, label=player_name)
                 button.get_style_context().add_class("menu-button")
                 button.connect(
@@ -493,9 +466,7 @@ def on_download_state_change(song_id: str):
 
             song_ids = [self.play_queue_store[p][-1] for p in paths]
 
-            remove_text = (
-                "Remove " + util.pluralize("song", len(song_ids)) + " from queue"
-            )
+            remove_text = "Remove " + util.pluralize("song", len(song_ids)) + " from queue"
 
             def on_remove_songs_click(_: Any):
                 self.emit("songs-removed", [p.get_indices()[0] for p in paths])
@@ -592,9 +563,7 @@ def create_playback_controls(self) -> Gtk.Box:
         self.song_scrubber.set_name("song-scrubber")
         self.song_scrubber.set_draw_value(False)
         self.song_scrubber.set_restrict_to_fill_level(False)
-        self.song_scrubber.connect(
-            "change-value", lambda s, t, v: self.emit("song-scrub", v)
-        )
+        self.song_scrubber.connect("change-value", lambda s, t, v: self.emit("song-scrub", v))
         scrubber_box.pack_start(self.song_scrubber, True, True, 0)
 
         self.song_duration_label = Gtk.Label(label="-:--")
@@ -801,9 +770,7 @@ def filename_to_pixbuf(
         self.play_queue_list.append_column(column)
 
         self.play_queue_list.connect("row-activated", self.on_song_activated)
-        self.play_queue_list.connect(
-            "button-press-event", self.on_play_queue_button_press
-        )
+        self.play_queue_list.connect("button-press-event", self.on_play_queue_button_press)
 
         # Set up drag-and-drop on the song list for editing the order of the
         # playlist.
@@ -825,9 +792,7 @@ def filename_to_pixbuf(
         self.play_queue_popover.add(play_queue_popover_box)
 
         # Volume mute toggle
-        self.volume_mute_toggle = IconButton(
-            "audio-volume-high-symbolic", "Toggle mute"
-        )
+        self.volume_mute_toggle = IconButton("audio-volume-high-symbolic", "Toggle mute")
         self.volume_mute_toggle.set_action_name("app.mute-toggle")
         box.pack_start(self.volume_mute_toggle, False, True, 0)
 
diff --git a/sublime_music/ui/playlists.py b/sublime_music/ui/playlists.py
index 125e48e9..6f2f41dd 100644
--- a/sublime_music/ui/playlists.py
+++ b/sublime_music/ui/playlists.py
@@ -1,20 +1,15 @@
 import math
 from functools import lru_cache, partial
 from random import randint
-from typing import Any, cast, Dict, List, Tuple
+from typing import Any, Dict, List, Tuple, cast
 
-from thefuzz import fuzz
 from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
+from thefuzz import fuzz
 
 from ..adapters import AdapterManager, api_objects as API
 from ..config import AppConfiguration
 from ..ui import util
-from ..ui.common import (
-    IconButton,
-    LoadError,
-    SongListColumn,
-    SpinnerImage,
-)
+from ..ui.common import IconButton, LoadError, SongListColumn, SpinnerImage
 
 
 class EditPlaylistDialog(Gtk.Dialog):
@@ -31,9 +26,7 @@ def __init__(self, parent: Any, playlist: API.Playlist):
 
         self.edit_button = Gtk.Button(label="Edit")
         self.edit_button.get_style_context().add_class("suggested-action")
-        self.edit_button.connect(
-            "clicked", lambda *a: self.response(Gtk.ResponseType.APPLY)
-        )
+        self.edit_button.connect("clicked", lambda *a: self.response(Gtk.ResponseType.APPLY))
         self.header.pack_end(self.edit_button)
 
         self.set_titlebar(self.header)
@@ -113,9 +106,10 @@ def __init__(self, *args, **kwargs):
         )
         self.pack2(self.playlist_detail_panel, True, False)
 
-    def update(self, app_config: AppConfiguration = None, force: bool = False):
+    def update(self, app_config: AppConfiguration | None = None, force: bool = False):
         self.playlist_list.update(app_config=app_config, force=force)
-        self.playlist_detail_panel.update(app_config=app_config, force=force)
+        if app_config:
+            self.playlist_detail_panel.update(app_config, force=force)
 
 
 class PlaylistList(Gtk.Box):
@@ -147,9 +141,7 @@ def __init__(self):
         self.new_playlist_button.connect("clicked", self.on_new_playlist_clicked)
         playlist_list_actions.pack_start(self.new_playlist_button)
 
-        self.list_refresh_button = IconButton(
-            "view-refresh-symbolic", "Refresh list of playlists"
-        )
+        self.list_refresh_button = IconButton("view-refresh-symbolic", "Refresh list of playlists")
         self.list_refresh_button.connect("clicked", self.on_list_refresh_click)
         playlist_list_actions.pack_end(self.list_refresh_button)
 
@@ -223,7 +215,7 @@ def create_playlist_row(model: PlaylistList.PlaylistModel) -> Gtk.ListBoxRow:
         list_scroll_window.add(self.list)
         self.pack_start(list_scroll_window, True, True, 0)
 
-    def update(self, app_config: AppConfiguration = None, force: bool = False):
+    def update(self, app_config: AppConfiguration | None = None, force: bool = False):
         if app_config:
             self.offline_mode = app_config.offline_mode
             self.new_playlist_button.set_sensitive(not app_config.offline_mode)
@@ -239,9 +231,9 @@ def update(self, app_config: AppConfiguration = None, force: bool = False):
     def update_list(
         self,
         playlists: List[API.Playlist],
-        app_config: AppConfiguration = None,
+        app_config: AppConfiguration | None = None,
         force: bool = False,
-        order_token: int = None,
+        order_token: int | None = None,
         is_partial: bool = False,
     ):
         for c in self.error_container.get_children():
@@ -304,9 +296,7 @@ def on_playlist_created(_):
 
         self.loading_indicator.show()
         playlist_ceate_future = AdapterManager.create_playlist(name=playlist_name)
-        playlist_ceate_future.add_done_callback(
-            lambda f: GLib.idle_add(on_playlist_created, f)
-        )
+        playlist_ceate_future.add_done_callback(lambda f: GLib.idle_add(on_playlist_created, f))
 
 
 class PlaylistDetailPanel(Gtk.Overlay):
@@ -387,9 +377,7 @@ def __init__(self):
 
         # Action buttons & expand/collapse button
         action_buttons_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
-        self.playlist_action_buttons = Gtk.Box(
-            orientation=Gtk.Orientation.HORIZONTAL, spacing=10
-        )
+        self.playlist_action_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
 
         self.download_all_button = IconButton(
             "folder-download-symbolic", "Download all songs in the playlist"
@@ -403,22 +391,16 @@ def __init__(self):
         self.playlist_edit_button.connect("clicked", self.on_playlist_edit_button_click)
         self.playlist_action_buttons.add(self.playlist_edit_button)
 
-        self.view_refresh_button = IconButton(
-            "view-refresh-symbolic", "Refresh playlist info"
-        )
+        self.view_refresh_button = IconButton("view-refresh-symbolic", "Refresh playlist info")
         self.view_refresh_button.connect("clicked", self.on_view_refresh_click)
         self.playlist_action_buttons.add(self.view_refresh_button)
 
-        action_buttons_container.pack_start(
-            self.playlist_action_buttons, False, False, 10
-        )
+        action_buttons_container.pack_start(self.playlist_action_buttons, False, False, 10)
 
         action_buttons_container.pack_start(Gtk.Box(), True, True, 0)
 
         expand_button_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
-        self.expand_collapse_button = IconButton(
-            "pan-up-symbolic", "Expand playlist details"
-        )
+        self.expand_collapse_button = IconButton("pan-up-symbolic", "Expand playlist details")
         self.expand_collapse_button.connect("clicked", self.on_expand_collapse_click)
         expand_button_container.pack_end(self.expand_collapse_button, False, False, 0)
         action_buttons_container.add(expand_button_container)
@@ -478,18 +460,14 @@ def playlist_song_list_search_fn(
         self.playlist_songs.append_column(SongListColumn("TITLE", 2, bold=True))
         self.playlist_songs.append_column(SongListColumn("ALBUM", 3))
         self.playlist_songs.append_column(SongListColumn("ARTIST", 4))
-        self.playlist_songs.append_column(
-            SongListColumn("DURATION", 5, align=1, width=40)
-        )
+        self.playlist_songs.append_column(SongListColumn("DURATION", 5, align=1, width=40))
 
         self.playlist_songs.connect("row-activated", self.on_song_activated)
         self.playlist_songs.connect("button-press-event", self.on_song_button_press)
 
         # Set up drag-and-drop on the song list for editing the order of the
         # playlist.
-        self.playlist_song_store.connect(
-            "row-inserted", self.on_playlist_model_row_move
-        )
+        self.playlist_song_store.connect("row-inserted", self.on_playlist_model_row_move)
         self.playlist_song_store.connect("row-deleted", self.on_playlist_model_row_move)
 
         self.playlist_song_scroll_window.add(self.playlist_songs)
@@ -540,9 +518,9 @@ def update(self, app_config: AppConfiguration, force: bool = False):
     def update_playlist_view(
         self,
         playlist: API.Playlist,
-        app_config: AppConfiguration = None,
+        app_config: AppConfiguration | None = None,
         force: bool = False,
-        order_token: int = None,
+        order_token: int | None = None,
         is_partial: bool = False,
     ):
         if self.update_playlist_view_order_token != order_token:
@@ -689,7 +667,7 @@ def update_playlist_artwork(
         cover_art_filename: str,
         app_config: AppConfiguration,
         force: bool = False,
-        order_token: int = None,
+        order_token: int | None = None,
         is_partial: bool = False,
     ):
         if self.update_playlist_view_order_token != order_token:
@@ -857,9 +835,7 @@ def on_remove_songs_click(_):
                     order_token=self.update_playlist_view_order_token,
                 )
 
-            remove_text = (
-                "Remove " + util.pluralize("song", len(song_ids)) + " from playlist"
-            )
+            remove_text = "Remove " + util.pluralize("song", len(song_ids)) + " from playlist"
             util.show_song_popover(
                 song_ids,
                 event.x,
@@ -869,15 +845,12 @@ def on_remove_songs_click(_):
                 on_download_state_change=on_download_state_change,
                 on_remove_downloads_click=(
                     lambda: (
-                        self.offline_mode
-                        and self.playlist_songs.get_selection().unselect_all()
+                        self.offline_mode and self.playlist_songs.get_selection().unselect_all()
                     )
                 ),
                 extra_menu_items=[
                     (
-                        Gtk.ModelButton(
-                            text=remove_text, sensitive=not self.offline_mode
-                        ),
+                        Gtk.ModelButton(text=remove_text, sensitive=not self.offline_mode),
                         on_remove_songs_click,
                     )
                 ],
@@ -914,7 +887,7 @@ def hide_loading_all(self):
         self.playlist_artwork.set_loading(False)
         self.playlist_view_loading_box.hide()
 
-    def make_label(self, text: str = None, name: str = None, **params) -> Gtk.Label:
+    def make_label(self, text: str | None = None, name: str | None = None, **params) -> Gtk.Label:
         return Gtk.Label(
             label=text,
             name=name,
diff --git a/sublime_music/ui/state.py b/sublime_music/ui/state.py
index 76acecc7..c563a500 100644
--- a/sublime_music/ui/state.py
+++ b/sublime_music/ui/state.py
@@ -45,9 +45,7 @@ class UIState:
     @dataclass(unsafe_hash=True)
     class UINotification:
         markup: str
-        actions: Tuple[Tuple[str, Callable[[], None]], ...] = field(
-            default_factory=tuple
-        )
+        actions: Tuple[Tuple[str, Callable[[], None]], ...] = field(default_factory=tuple)
         icon: Optional[str] = None
 
     version: int = 1
@@ -88,11 +86,12 @@ def __init__(self):
             self.name = "Rock"
 
     current_album_search_query: AlbumSearchQuery = field(
-        default_factory = lambda: AlbumSearchQuery(
+        default_factory=lambda: AlbumSearchQuery(
             AlbumSearchQuery.Type.RANDOM,
             genre=UIState._DefaultGenre(),
             year_range=this_decade(),
-       ))
+        )
+    )
 
     active_playlist_id: Optional[str] = None
 
@@ -113,9 +112,7 @@ def __setstate__(self, state: Dict[str, Any]):
     def __init_available_players__(self):
         from sublime_music.players import PlayerManager
 
-        self.available_players = {
-            pt: set() for pt in PlayerManager.available_player_types
-        }
+        self.available_players = {pt: set() for pt in PlayerManager.available_player_types}
 
     def migrate(self):
         pass
@@ -132,9 +129,7 @@ def current_song(self) -> Optional[Song]:
         current_song_id = self.play_queue[self.current_song_index]
 
         if not self._current_song or self._current_song.id != current_song_id:
-            self._current_song = AdapterManager.get_song_details(
-                current_song_id
-            ).result()
+            self._current_song = AdapterManager.get_song_details(current_song_id).result()
 
         return self._current_song
 
diff --git a/sublime_music/ui/util.py b/sublime_music/ui/util.py
index 156b5b70..4c6b697a 100644
--- a/sublime_music/ui/util.py
+++ b/sublime_music/ui/util.py
@@ -1,17 +1,7 @@
 import functools
 import re
 from datetime import timedelta
-from typing import (
-    Any,
-    Callable,
-    cast,
-    Iterable,
-    List,
-    Match,
-    Optional,
-    Tuple,
-    Union,
-)
+from typing import Any, Callable, Iterable, List, Match, Optional, Tuple, Union, cast
 
 from deepdiff import DeepDiff
 from gi.repository import Gdk, GLib, Gtk
@@ -47,7 +37,7 @@ def format_song_duration(duration_secs: Union[int, timedelta, None]) -> str:
     return f"{duration_secs // 60}:{duration_secs % 60:02}"
 
 
-def pluralize(string: str, number: int, pluralized_form: str = None) -> str:
+def pluralize(string: str, number: int, pluralized_form: str | None = None) -> str:
     """
     Pluralize the given string given the count as a number.
 
@@ -183,7 +173,7 @@ def show_song_popover(
     on_remove_downloads_click: Callable[[], Any] = lambda: None,
     on_playlist_state_change: Callable[[], None] = lambda: None,
     show_remove_from_playlist_button: bool = False,
-    extra_menu_items: List[Tuple[Gtk.ModelButton, Any]] = None,
+    extra_menu_items: List[Tuple[Gtk.ModelButton, Any]] | None = None,
 ):
     def on_download_songs_click(_: Any):
         AdapterManager.batch_download_songs(
@@ -265,23 +255,17 @@ def on_get_song_details_done(songs: List[Song]):
                 artists.add(id_)
 
         if len(albums) == 1 and list(albums)[0] is not None:
-            go_to_album_button.set_action_target_value(
-                GLib.Variant("s", list(albums)[0])
-            )
+            go_to_album_button.set_action_target_value(GLib.Variant("s", list(albums)[0]))
             go_to_album_button.set_action_name("app.go-to-album")
         if len(artists) == 1 and list(artists)[0] is not None:
-            go_to_artist_button.set_action_target_value(
-                GLib.Variant("s", list(artists)[0])
-            )
+            go_to_artist_button.set_action_target_value(GLib.Variant("s", list(artists)[0]))
             go_to_artist_button.set_action_name("app.go-to-artist")
         if len(parents) == 1 and list(parents)[0] is not None:
             browse_to_song.set_action_target_value(GLib.Variant("s", list(parents)[0]))
             browse_to_song.set_action_name("app.browse-to")
 
     def batch_get_song_details() -> List[Song]:
-        return [
-            AdapterManager.get_song_details(song_id).result() for song_id in song_ids
-        ]
+        return [AdapterManager.get_song_details(song_id).result() for song_id in song_ids]
 
     get_song_details_result: Result[List[Song]] = Result(batch_get_song_details)
     get_song_details_result.add_done_callback(
@@ -325,9 +309,7 @@ def batch_get_song_details() -> List[Song]:
 
     if not offline_mode:
         # Back button
-        playlists_vbox.add(
-            Gtk.ModelButton(inverted=True, centered=True, menu_name="main")
-        )
+        playlists_vbox.add(Gtk.ModelButton(inverted=True, centered=True, menu_name="main"))
 
         # Loading indicator
         loading_indicator = Gtk.Spinner(name="menu-item-spinner")
@@ -364,8 +346,8 @@ def on_get_playlists_done(f: Result[List[Playlist]]):
 
 def async_callback(
     future_fn: Callable[..., Result],
-    before_download: Callable[[Any], None] = None,
-    on_failure: Callable[[Any, Exception], None] = None,
+    before_download: Callable[[Any], None] | None = None,
+    on_failure: Callable[[Any, Exception], None] | None = None,
 ) -> Callable[[Callable], Callable]:
     """
     Defines the ``async_callback`` decorator.
@@ -382,9 +364,9 @@ def decorator(callback_fn: Callable) -> Callable:
         def wrapper(
             self: Any,
             *args,
-            app_config: AppConfiguration = None,
+            app_config: AppConfiguration | None = None,
             force: bool = False,
-            order_token: int = None,
+            order_token: int | None = None,
             **kwargs,
         ):
             def on_before_download():
@@ -434,9 +416,7 @@ def future_callback(is_immediate: bool, f: Result):
                 force=force,
                 **kwargs,
             )
-            result.add_done_callback(
-                functools.partial(future_callback, result.data_is_available)
-            )
+            result.add_done_callback(functools.partial(future_callback, result.data_is_available))
 
         return wrapper
 


Test patch:
diff --git a/tests/adapter_tests/adapter_manager_tests.py b/tests/adapter_tests/adapter_manager_tests.py
index b4ba330f..fc96aab3 100644
--- a/tests/adapter_tests/adapter_manager_tests.py
+++ b/tests/adapter_tests/adapter_manager_tests.py
@@ -3,14 +3,9 @@
 
 import pytest
 
-from sublime_music.adapters import (
-    AdapterManager,
-    ConfigurationStore,
-    Result,
-    SearchResult,
-)
+from sublime_music.adapters import AdapterManager, ConfigurationStore, Result, SearchResult
 from sublime_music.adapters.filesystem import FilesystemAdapter
-from sublime_music.adapters.subsonic import api_objects as SubsonicAPI, SubsonicAdapter
+from sublime_music.adapters.subsonic import SubsonicAdapter, api_objects as SubsonicAPI
 from sublime_music.config import AppConfiguration, ProviderConfiguration
 
 
@@ -68,7 +63,7 @@ def resolve_later() -> int:
         sleep(0.1)
         return 42
 
-    result = Result(resolve_later)
+    result: Result[int] = Result(resolve_later)
     assert not result.data_is_available
     assert result.result() == 42
     assert result.data_is_available
@@ -87,12 +82,12 @@ def check_done_callback(f: Result):
         assert f.result() == 42
         check_done = True
 
-    result = Result(resolve_later)
+    result: Result[int] = Result(resolve_later)
     result.add_done_callback(check_done_callback)
 
     # Should take much less than 1 seconds to complete. If the assertion fails, then the
     # check_done_callback failed.
-    t = 0
+    t = 0.0
     while not check_done:
         assert t < 1
         t += 0.1
@@ -121,7 +116,7 @@ def on_cancel():
         nonlocal cancel_called
         cancel_called = True
 
-    result = Result(resolve_later, on_cancel=on_cancel)
+    result: Result[int] = Result(resolve_later, on_cancel=on_cancel)
     result.cancel()
     assert cancel_called
     assert not result.data_is_available
diff --git a/tests/adapter_tests/filesystem_adapter_tests.py b/tests/adapter_tests/filesystem_adapter_tests.py
index c77d22bc..1efbba28 100644
--- a/tests/adapter_tests/filesystem_adapter_tests.py
+++ b/tests/adapter_tests/filesystem_adapter_tests.py
@@ -3,19 +3,19 @@
 from dataclasses import asdict
 from datetime import timedelta
 from pathlib import Path
-from typing import Any, cast, Generator, Iterable, Tuple
+from typing import Any, Generator, Iterable, Tuple, cast
 
 import pytest
-
 from peewee import SelectQuery
 
 from sublime_music.adapters import (
     AlbumSearchQuery,
-    api_objects as SublimeAPI,
     CacheMissError,
     SongCacheStatus,
+    api_objects as SublimeAPI,
 )
 from sublime_music.adapters.filesystem import FilesystemAdapter
+from sublime_music.adapters.filesystem.models import Directory
 from sublime_music.adapters.subsonic import api_objects as SubsonicAPI
 
 MOCK_DATA_FILES = Path(__file__).parent.joinpath("mock_data")
@@ -103,7 +103,8 @@ def mock_data_files(
 
 
 def verify_songs(
-    actual_songs: Iterable[SublimeAPI.Song], expected_songs: Iterable[SubsonicAPI.Song]
+    actual_songs: Iterable[SublimeAPI.Directory | SublimeAPI.Song],
+    expected_songs: Iterable[SubsonicAPI.Song],
 ):
     actual_songs, expected_songs = (list(actual_songs), list(expected_songs))
     assert len(actual_songs) == len(expected_songs)
@@ -111,9 +112,9 @@ def verify_songs(
         for k, v in asdict(song).items():
             if k in ("_genre", "_album", "_artist", "album_id", "artist_id"):
                 continue
-            print(k, "->", v)  # noqa: T001
+            print(k, "->", v)  # noqa: T201
 
-            actual_value = getattr(actual, k, None)
+            actual_value = getattr(actual, k)
 
             if k == "album":
                 assert ("a1", "foo") == (actual_value.id, actual_value.name)
@@ -228,12 +229,8 @@ def test_caching_get_playlist_details(cache_adapter: FilesystemAdapter):
         KEYS.PLAYLISTS,
         None,
         [
-            SubsonicAPI.Playlist(
-                "1", "foo", song_count=3, duration=timedelta(seconds=41.2)
-            ),
-            SubsonicAPI.Playlist(
-                "3", "test3", song_count=3, duration=timedelta(seconds=30)
-            ),
+            SubsonicAPI.Playlist("1", "foo", song_count=3, duration=timedelta(seconds=41.2)),
+            SubsonicAPI.Playlist("3", "test3", song_count=3, duration=timedelta(seconds=30)),
         ],
     )
 
@@ -297,9 +294,7 @@ def test_cache_cover_art(cache_adapter: FilesystemAdapter):
 
     # After ingesting the data, reading from the cache should give the exact same file.
     cache_adapter.ingest_new_data(KEYS.COVER_ART_FILE, "pl_test1", MOCK_ALBUM_ART)
-    with open(
-        cache_adapter.get_cover_art_uri("pl_test1", "file", size=300), "wb+"
-    ) as cached:
+    with open(cache_adapter.get_cover_art_uri("pl_test1", "file", size=300), "wb+") as cached:
         with open(MOCK_ALBUM_ART, "wb+") as expected:
             assert cached.read() == expected.read()
 
@@ -392,9 +387,7 @@ def test_invalidate_song_file(cache_adapter: FilesystemAdapter):
 def test_malformed_song_path(cache_adapter: FilesystemAdapter):
     cache_adapter.ingest_new_data(KEYS.SONG, "1", MOCK_SUBSONIC_SONGS[1])
     cache_adapter.ingest_new_data(KEYS.SONG, "2", MOCK_SUBSONIC_SONGS[0])
-    cache_adapter.ingest_new_data(
-        KEYS.SONG_FILE, "1", ("/malformed/path", MOCK_SONG_FILE, None)
-    )
+    cache_adapter.ingest_new_data(KEYS.SONG_FILE, "1", ("/malformed/path", MOCK_SONG_FILE, None))
     cache_adapter.ingest_new_data(
         KEYS.SONG_FILE, "2", ("fine/path/song2.mp3", MOCK_SONG_FILE2, None)
     )
@@ -414,14 +407,10 @@ def test_get_cached_statuses(cache_adapter: FilesystemAdapter):
     assert cache_adapter.get_cached_statuses(["1"]) == {"1": SongCacheStatus.CACHED}
 
     cache_adapter.ingest_new_data(KEYS.SONG_FILE_PERMANENT, "1", None)
-    assert cache_adapter.get_cached_statuses(["1"]) == {
-        "1": SongCacheStatus.PERMANENTLY_CACHED
-    }
+    assert cache_adapter.get_cached_statuses(["1"]) == {"1": SongCacheStatus.PERMANENTLY_CACHED}
 
     cache_adapter.invalidate_data(KEYS.SONG_FILE, "1")
-    assert cache_adapter.get_cached_statuses(["1"]) == {
-        "1": SongCacheStatus.CACHED_STALE
-    }
+    assert cache_adapter.get_cached_statuses(["1"]) == {"1": SongCacheStatus.CACHED_STALE}
 
     cache_adapter.delete_data(KEYS.SONG_FILE, "1")
     assert cache_adapter.get_cached_statuses(["1"]) == {"1": SongCacheStatus.NOT_CACHED}
@@ -610,6 +599,7 @@ def test_caching_get_song_details_missing_data(cache_adapter: FilesystemAdapter)
         "invalid:62cdb7020ff920e5aa642c3d4066950dd1f01f4d",
         "bar",
     )
+    assert song.artist
     assert (song.artist.id, song.artist.name) == (
         "invalid:0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33",
         "foo",
@@ -623,9 +613,7 @@ def test_caching_get_song_details_missing_data(cache_adapter: FilesystemAdapter)
     # shouldn't show up in any results.
     try:
         list(
-            cache_adapter.get_albums(
-                AlbumSearchQuery(AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME)
-            )
+            cache_adapter.get_albums(AlbumSearchQuery(AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME))
         )
     except CacheMissError as e:
         assert e.partial_data is not None
@@ -691,9 +679,7 @@ def test_caching_get_artists(cache_adapter: FilesystemAdapter):
         KEYS.ARTISTS,
         None,
         [
-            SubsonicAPI.ArtistAndArtistInfo(
-                id="1", name="test1", album_count=3, albums=[]
-            ),
+            SubsonicAPI.ArtistAndArtistInfo(id="1", name="test1", album_count=3, albums=[]),
             SubsonicAPI.ArtistAndArtistInfo(id="2", name="test2", album_count=4),
         ],
     )
@@ -754,9 +740,7 @@ def test_caching_get_artist(cache_adapter: FilesystemAdapter):
             ],
             biography="this is a bio",
             music_brainz_id="mbid",
-            albums=[
-                SubsonicAPI.Album(id="1", name="Foo", _artist="Bar", artist_id="1")
-            ],
+            albums=[SubsonicAPI.Album(id="1", name="Foo", _artist="Bar", artist_id="1")],
         ),
     )
 
@@ -774,9 +758,7 @@ def test_caching_get_artist(cache_adapter: FilesystemAdapter):
         SubsonicAPI.ArtistAndArtistInfo(id="C", name="D"),
     ]
     assert artist.albums and len(artist.albums) == 1
-    assert cast(SelectQuery, artist.albums).dicts() == [
-        SubsonicAPI.Album(id="1", name="Foo")
-    ]
+    assert cast(SelectQuery, artist.albums).dicts() == [SubsonicAPI.Album(id="1", name="Foo")]
 
     # Simulate "force refreshing" the artist details being retrieved from Subsonic.
     cache_adapter.ingest_new_data(
@@ -784,7 +766,7 @@ def test_caching_get_artist(cache_adapter: FilesystemAdapter):
         "1",
         SubsonicAPI.ArtistAndArtistInfo(
             id="1",
-            name="Foo",
+            name="Baz",
             album_count=2,
             artist_image_url="image2",
             similar_artists=[
@@ -795,7 +777,7 @@ def test_caching_get_artist(cache_adapter: FilesystemAdapter):
             music_brainz_id="mbid2",
             albums=[
                 SubsonicAPI.Album(id="1", name="Foo", artist_id="1"),
-                SubsonicAPI.Album(id="2", name="Bar", artist_id="1"),
+                SubsonicAPI.Album(id="2", name="OHEA", artist_id="1"),
             ],
         ),
     )
@@ -808,16 +790,22 @@ def test_caching_get_artist(cache_adapter: FilesystemAdapter):
         artist.artist_image_url,
         artist.biography,
         artist.music_brainz_id,
-    ) == ("1", "Foo", 2, "image2", "this is a bio2", "mbid2")
+    ) == ("1", "Baz", 2, "image2", "this is a bio2", "mbid2")
     assert artist.similar_artists == [
         SubsonicAPI.ArtistAndArtistInfo(id="A", name="B"),
         SubsonicAPI.ArtistAndArtistInfo(id="E", name="F"),
     ]
-    assert artist.albums and len(artist.albums) == 2
-    assert cast(SelectQuery, artist.albums).dicts() == [
+    assert artist.albums is not None
+    expected = [
         SubsonicAPI.Album(id="1", name="Foo"),
-        SubsonicAPI.Album(id="2", name="Bar"),
+        SubsonicAPI.Album(id="2", name="OHEA"),
     ]
+    count = 0
+    for album, exp in zip(artist.albums, expected):
+        assert album.id == exp.id
+        assert album.name == exp.name
+        count += 1
+    assert count == 2
 
 
 def test_caching_get_album(cache_adapter: FilesystemAdapter):
@@ -986,6 +974,8 @@ def test_get_music_directory(cache_adapter: FilesystemAdapter):
 
     dir_child, *song_children = directory.children
     verify_songs(song_children, MOCK_SUBSONIC_SONGS[:2])
+    assert isinstance(dir_child, Directory)
+    dir_child = cast(Directory, dir_child)
     assert dir_child.id == "542"
     assert dir_child.parent_id
     assert dir_child.name == "Crash My Party"
@@ -1023,9 +1013,7 @@ def test_search(cache_adapter: FilesystemAdapter):
         "artists",
         [
             SubsonicAPI.ArtistAndArtistInfo(id="artist1", name="foo", cover_art="car1"),
-            SubsonicAPI.ArtistAndArtistInfo(
-                id="artist2", name="better boo", cover_art="car2"
-            ),
+            SubsonicAPI.ArtistAndArtistInfo(id="artist2", name="better boo", cover_art="car2"),
         ],
     )
     search_result.add_results(
@@ -1050,8 +1038,9 @@ def test_search(cache_adapter: FilesystemAdapter):
     cache_adapter.ingest_new_data(KEYS.SEARCH_RESULTS, None, search_result)
 
     search_result = cache_adapter.search("foo")
-    assert [
-        (s.title, s.artist.name if s.artist else None) for s in search_result.songs
-    ] == [("foo of all foo", "artist4"), ("amazing boo", "artist3")]
+    assert [(s.title, s.artist.name if s.artist else None) for s in search_result.songs] == [
+        ("foo of all foo", "artist4"),
+        ("amazing boo", "artist3"),
+    ]
     assert [a.name for a in search_result.artists] == ["foo", "better boo"]
     assert [a.name for a in search_result.albums] == ["Foo", "Boo"]
diff --git a/tests/adapter_tests/mock_data/get_music_directory-subdir.json b/tests/adapter_tests/mock_data/get_music_directory-subdir.json
index b9efbcad..36ae6e28 100644
--- a/tests/adapter_tests/mock_data/get_music_directory-subdir.json
+++ b/tests/adapter_tests/mock_data/get_music_directory-subdir.json
@@ -21,4 +21,4 @@
          } ]
       }
    }
-}
\ No newline at end of file
+}
diff --git a/tests/adapter_tests/mock_data/get_play_queue-airsonic.json b/tests/adapter_tests/mock_data/get_play_queue-airsonic.json
index 56f9f327..e457b252 100644
--- a/tests/adapter_tests/mock_data/get_play_queue-airsonic.json
+++ b/tests/adapter_tests/mock_data/get_play_queue-airsonic.json
@@ -135,4 +135,4 @@
          } ]
       }
    }
-}
\ No newline at end of file
+}
diff --git a/tests/adapter_tests/subsonic_adapter_tests.py b/tests/adapter_tests/subsonic_adapter_tests.py
index 7bc969f8..7fdd9c9b 100644
--- a/tests/adapter_tests/subsonic_adapter_tests.py
+++ b/tests/adapter_tests/subsonic_adapter_tests.py
@@ -10,7 +10,7 @@
 from dateutil.tz import tzutc
 
 from sublime_music.adapters import ConfigurationStore
-from sublime_music.adapters.subsonic import api_objects as SubsonicAPI, SubsonicAdapter
+from sublime_music.adapters.subsonic import SubsonicAdapter, api_objects as SubsonicAPI
 
 MOCK_DATA_FILES = Path(__file__).parent.joinpath("mock_data")
 
@@ -51,9 +51,7 @@ def salt_auth_adapter(tmp_path: Path):
     adapter.shutdown()
 
 
-def mock_data_files(
-    request_name: str, mode: str = "r"
-) -> Generator[Tuple[Path, Any], None, None]:
+def mock_data_files(request_name: str, mode: str = "r") -> Generator[Tuple[Path, Any], None, None]:
     """
     Yields all of the files, and each of the elements of in the file (separated by a
     line of ='s), for all files in the mock_data directory that start with
@@ -75,7 +73,7 @@ def mock_data_files(
                     aggregate.append(line)
 
                 parts.append("\n".join(aggregate))
-                print(file)  # noqa: T001
+                print(file)  # noqa: T201
                 num_files += 1
                 yield file, iter(parts)
 
@@ -84,9 +82,7 @@ def mock_data_files(
 
 
 def mock_json(**obj: Any) -> str:
-    return json.dumps(
-        {"subsonic-response": {"status": "ok", "version": "1.15.0", **obj}}
-    )
+    return json.dumps({"subsonic-response": {"status": "ok", "version": "1.15.0", **obj}})
 
 
 def camel_to_snake(name: str) -> str:
@@ -94,12 +90,6 @@ def camel_to_snake(name: str) -> str:
     return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower()
 
 
-def test_config_form():
-    # Just make sure that the functions work. That's half of the battle.
-    config_store = ConfigurationStore()
-    SubsonicAdapter.get_configuration_form(config_store)
-
-
 def test_plain_auth_logic(adapter: SubsonicAdapter):
     expected = {
         "u": "test",
@@ -164,7 +154,8 @@ def test_ping_status(adapter: SubsonicAdapter):
 
     # Simulate valid ping
     adapter._set_mock_data(mock_json())
-    adapter._last_ping_timestamp.value = 0.0
+    # typing doesn't support multiprocessing.Value very well
+    adapter._last_ping_timestamp.value = 0.0  # type: ignore
     adapter._set_ping_status()
     assert adapter.ping_status
 
@@ -465,9 +456,7 @@ def test_get_play_queue(adapter: SubsonicAdapter):
         assert play_queue.current_index and play_queue.current_index == 1
         assert play_queue.position == timedelta(milliseconds=98914)
         assert play_queue.username == "sumner"
-        assert play_queue.changed == datetime(
-            2020, 5, 12, 5, 16, 32, 114000, tzinfo=timezone.utc
-        )
+        assert play_queue.changed == datetime(2020, 5, 12, 5, 16, 32, 114000, tzinfo=timezone.utc)
         assert play_queue.songs and len(play_queue.songs) == 5
 
         song = play_queue.songs[0]
diff --git a/tests/common_ui_tests.py b/tests/common_ui_tests.py
deleted file mode 100755
index c7abd6f8..00000000
--- a/tests/common_ui_tests.py
+++ /dev/null
@@ -1,68 +0,0 @@
-from pathlib import Path
-
-import gi
-
-gi.require_version("Gtk", "3.0")
-from gi.repository import Gtk  # noqa: F401
-
-from sublime_music.ui import common
-
-
-def test_icon_buttons():
-    common.IconButton("cloud-offline")
-    common.IconToggleButton("cloud-offline")
-    common.IconMenuButton("cloud-offline")
-
-
-def test_load_error():
-    test_cases = [
-        (
-            (True, True),
-            "cloud-offline",
-            "Song list may be incomplete.\nGo online to load song list.",
-        ),
-        ((True, False), "network-error", "Error attempting to load song list."),
-        ((False, True), "cloud-offline", "Go online to load song list."),
-        ((False, False), "network-error", "Error attempting to load song list."),
-    ]
-    for (has_data, offline_mode), icon_name, label_text in test_cases:
-
-        load_error = common.LoadError(
-            "Song list", "load song list", has_data=has_data, offline_mode=offline_mode
-        )
-        assert load_error.image.get_icon_name().icon_name == f"{icon_name}-symbolic"
-        assert load_error.label.get_text() == label_text
-
-
-def test_song_list_column():
-    common.SongListColumn("H", 1, bold=True, align=1.0, width=30)
-
-
-def test_spinner_image():
-    initial_size = 300
-    image = common.SpinnerImage(
-        loading=False,
-        image_name="test",
-        spinner_name="ohea",
-        image_size=initial_size,
-    )
-    image.set_from_file(None)
-    assert image.image.get_pixbuf() is None
-
-    image.set_from_file("")
-    assert image.image.get_pixbuf() is None
-
-    image.set_from_file(
-        str(Path(__file__).parent.joinpath("mock_data", "album-art.png"))
-    )
-    assert (pixbuf := image.image.get_pixbuf()) is not None
-    assert pixbuf.get_width() == pixbuf.get_height() == initial_size
-
-    smaller_size = 70
-    image.set_image_size(smaller_size)
-    assert (pixbuf := image.image.get_pixbuf()) is not None
-    assert pixbuf.get_width() == pixbuf.get_height() == smaller_size
-
-    # Just make sure these don't raise exceptions.
-    image.set_loading(True)
-    image.set_loading(False)
diff --git a/tests/player_tests/mpv_tests.py b/tests/player_tests/mpv_tests.py
index 4e27f53e..5397623d 100644
--- a/tests/player_tests/mpv_tests.py
+++ b/tests/player_tests/mpv_tests.py
@@ -1,31 +1,31 @@
 from datetime import timedelta
 from pathlib import Path
 
+from sublime_music.adapters.api_objects import Song
+from sublime_music.players.mpv import MPVPlayer
+
 # from time import sleep
 
-from sublime_music.players.mpv import MPVPlayer
 
 MPVPlayer._is_mock = True
 
 
 def test_init():
-    empty_fn = lambda *a, **k: None
+    empty_fn = lambda *_, **__: None
     MPVPlayer(empty_fn, empty_fn, empty_fn, empty_fn, {"Replay Gain": "Disabled"})
 
 
 def is_close(expected: float, value: float, delta: float = 0.5) -> bool:
-    print(f"EXPECTED: {expected}, VALUE: {value}")  # noqa: T001
+    print(f"EXPECTED: {expected}, VALUE: {value}")  # noqa: T201
     return abs(value - expected) < delta
 
 
 def test_play():
-    empty_fn = lambda *a, **k: None
-    mpv_player = MPVPlayer(
-        empty_fn, empty_fn, empty_fn, empty_fn, {"Replay Gain": "Disabled"}
-    )
+    empty_fn = lambda *_, **__: None
+    mpv_player = MPVPlayer(empty_fn, empty_fn, empty_fn, empty_fn, {"Replay Gain": "Disabled"})
 
     song_path = Path(__file__).parent.joinpath("mock_data/test-song.mp3")
-    mpv_player.play_media(str(song_path), timedelta(seconds=10), None)
+    mpv_player.play_media(str(song_path), timedelta(seconds=10), Song())
 
     # Test Mute and volume
     # ==================================================================================