Merge branch 'master' into fix/docker-image-size-180

This commit is contained in:
Mungai Njoroge
2024-03-26 15:18:55 +03:00
committed by GitHub
46 changed files with 1449 additions and 820 deletions
+13
View File
@@ -0,0 +1,13 @@
# TODO
A list of things to do. Feel free to grab one of this and work on it.
## Clean up old playlist images
When playlists are deleted or their images updated, the old
images are not deleted.
Write a function to:
- read all images in the playlist image directory and checks unlinked images by comparing the list with the .image property of each property
- delete unlinked images
The function can run on app start up, before periodic checks
+3 -25
View File
@@ -1,29 +1,7 @@
# What's New? # What's New?
- New opt-in alternate (no sidebar) layout - Hovering on recent favorite item will show how long ago it was ♥ed
- Added search bar to the top bar (all layouts) - Recently added playlist returns a max of 100 tracks, but without a cutoff period
- Move browse options to homepage bottom
- Move to a stronger WSGI server ([waitress](https://docs.pylonsproject.org/projects/waitress/en/stable/))
- Proper ARM64 and other platforms support
- A Proper timezone fix. Thanks to @tcsenpai on #170
- Hovering over a recently played/added item on the homepage will reveal how long ago
- Recently added items will not have a cutoff
- The exhaustive list of web client stuff can be found on [commit 4211ccc](https://github.com/swing-opensource/swingmusic-client/commit/4211ccc685e3d33dbf008cbb6c77542baf0130dc) in the client repo.
# Bug fixes & Enhancements
- Lyrics plugin now works when Swing Music is auto started (tested on Ubuntu)
- Track not being removed from queue
- Playlist list page moving out of bounds
- Save queue as playlist not working
- Keyboard shortcuts not working in first try
- Fix recently added items not filling row
- Fix recently added items order
# Development # Development
- API documentation on /openapi
- WIP code base documentation to `.github/docs`. Contributions are welcome!
- Bump watchdog to v4
> [!TIP]
> Plans for a mobile are underway. The development will be led by @EricGacoki
+3 -1
View File
@@ -79,7 +79,9 @@ yarn dev
You can see the client at http://localhost:5173. You can see the client at http://localhost:5173.
> The client is hardcoded to hook into the server on port `1980` (to allow the another server instance to be running on the default port). You can follow the instructions above to set up the server in that port, or you can change the port in `swingmusic-client/config.ts`. Don't forget to change it back when in the PR. > The client is hardcoded to hook into the server on port `1980` (to allow the another server instance to be running on the default port).
>
> You can follow the instructions above to set up the server in that port, or you can change the port in `swingmusic-client/config.ts`. Don't forget to change it back when in the PR.
## Where can I go for help? ## Where can I go for help?
+19 -5
View File
@@ -62,15 +62,29 @@ jobs:
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: "3.10.11" python-version: "3.10.11"
- name: Install Poetry - name: Create virtualenv
run: | run: |
pip install poetry python -m venv .venv
- name: Activate virtualenv (linux)
if: matrix.os == 'ubuntu-20.04'
run: |
source .venv/bin/activate
- name: Activate virtualenv (windows)
if: matrix.os == 'windows-2019'
run: |
.venv\Scripts\Activate
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m poetry install pip install -r requirements.txt
# - name: Install Poetry
# run: |
# pip install poetry
# - name: Install dependencies
# run: |
# python -m poetry install
- name: Build server - name: Build server
run: | run: |
python -m poetry run python manage.py --build python manage.py --build
env: env:
SWINGMUSIC_APP_VERSION: ${{ inputs.tag }} SWINGMUSIC_APP_VERSION: ${{ inputs.tag }}
- name: Verify Linux build success - name: Verify Linux build success
@@ -169,6 +183,6 @@ jobs:
tags: ghcr.io/${{github.repository}}:${{format('v{0}', inputs.tag)}}, ${{env.LATEST_TAG}} tags: ghcr.io/${{github.repository}}:${{format('v{0}', inputs.tag)}}, ${{env.LATEST_TAG}}
labels: org.opencontainers.image.title=Docker labels: org.opencontainers.image.title=Docker
build-args: | build-args: |
client_tag=v${{inputs.tag}} app_version=${{inputs.tag}}
env: env:
LATEST_TAG: ${{ inputs.is_latest == 'true' && format('ghcr.io/{0}:latest', github.repository) || '' }} LATEST_TAG: ${{ inputs.is_latest == 'true' && format('ghcr.io/{0}:latest', github.repository) || '' }}
+1 -1
View File
@@ -1,7 +1,7 @@
# local env files # local env files
.env.local .env.local
.env.*.local .env.*.local
venv
# Editor directories and files # Editor directories and files
.idea .idea
+10 -7
View File
@@ -28,12 +28,15 @@ VOLUME /music
VOLUME /config VOLUME /config
RUN python -m venv /venv
# RUN poetry config virtualenvs.create false
RUN . /venv/bin/activate
# RUN poetry install
RUN pip install -r requirements.txt
RUN apt-get update && apt-get install -y ffmpeg libavcodec-extra gcc-aarch64-linux-gnu RUN apt-get update && apt-get install -y ffmpeg libavcodec-extra gcc-aarch64-linux-gnu
RUN pip install poetry # ENTRYPOINT ["poetry", "run", "python", "manage.py", "--host", "0.0.0.0", "--config", "/config"]
ENTRYPOINT ["python", "manage.py", "--host", "0.0.0.0", "--config", "/config"]
RUN poetry config virtualenvs.create false
RUN poetry install
ENTRYPOINT ["poetry", "run", "python", "manage.py", "--host", "0.0.0.0", "--config", "/config"]
+10 -3
View File
@@ -19,7 +19,15 @@ Swing Music is a beautiful, self-hosted music player for your local audio files.
Swing Music is available as pre-compiled binaries for Windows and Linux. Just download the latest release from the [downloads page](https://swingmusic.vercel.app/downloads) and launch it. Swing Music is available as pre-compiled binaries for Windows and Linux. Just download the latest release from the [downloads page](https://swingmusic.vercel.app/downloads) and launch it.
For Linux, you need to make the file executable first. [FFmpeg](https://ffmpeg.org/) is needed for the audio silence skip feature, so you need to install it first. On windows, you can follows [this tutorial](https://phoenixnap.com/kb/ffmpeg-windows) to install FFmpeg.
On Linux, you can install FFmpeg using:
```sh
sudo apt-get install ffmpeg libavcodec-extra -y
```
Then make the file executable first.
```bash ```bash
chmod a+x ./swingmusic chmod a+x ./swingmusic
@@ -82,8 +90,7 @@ services:
Swing Music is looking for contributors. If you're interested, please join us at the [Swing Music Community](https://t.me/+9n61PFcgKhozZDE0) group on Telegram. For more information, take a look at https://github.com/swing-opensource/swingmusic/issues/186. Swing Music is looking for contributors. If you're interested, please join us at the [Swing Music Community](https://t.me/+9n61PFcgKhozZDE0) group on Telegram. For more information, take a look at https://github.com/swing-opensource/swingmusic/issues/186.
[Contributing Guidelines](.github/contributing.md). [**CONTRIBUTING GUIDELINES**](.github/contributing.md).
### License ### License
+45 -19
View File
@@ -2,10 +2,14 @@
This module combines all API blueprints into a single Flask app instance. This module combines all API blueprints into a single Flask app instance.
""" """
from flask import Flask import datetime
from flask_compress import Compress
from flask_cors import CORS from flask_cors import CORS
from flask_compress import Compress
from flask_openapi3 import Info
from flask_openapi3 import OpenAPI
from app.settings import Keys
from .plugins import lyrics as lyrics_plugin from .plugins import lyrics as lyrics_plugin
from app.api import ( from app.api import (
album, album,
@@ -25,12 +29,34 @@ from app.api import (
getall, getall,
) )
# TODO: Move this description to a separate file
open_api_description = f"""
The REST API exposed by your Swing Music server
### Definition of terms:
#### 1. `limit`: The number of items to return.
In endpoints that request multiple lists of items, this represents the number of items to return for each list.
---
[MIT License](https://github.com/swing-opensource/swingmusic?tab=MIT-1-ov-file#MIT-1-ov-file) | Copyright (c) {datetime.datetime.now().year} [Mungai Njoroge](https://mungai.vercel.app)
"""
def create_api(): def create_api():
""" """
Creates the Flask instance, registers modules and registers all the API blueprints. Creates the Flask instance, registers modules and registers all the API blueprints.
""" """
app = Flask(__name__) api_info = Info(
title=f"Swing Music",
version=f"v{Keys.SWINGMUSIC_APP_VERSION}",
description=open_api_description,
)
app = OpenAPI(__name__, info=api_info, doc_prefix="/docs")
CORS(app, origins="*") CORS(app, origins="*")
Compress(app) Compress(app)
@@ -39,29 +65,29 @@ def create_api():
] ]
with app.app_context(): with app.app_context():
app.register_blueprint(album.api) app.register_api(album.api)
app.register_blueprint(artist.api) app.register_api(artist.api)
app.register_blueprint(send_file.api) app.register_api(send_file.api)
app.register_blueprint(search.api) app.register_api(search.api)
app.register_blueprint(folder.api) app.register_api(folder.api)
app.register_blueprint(playlist.api) app.register_api(playlist.api)
app.register_blueprint(favorites.api) app.register_api(favorites.api)
app.register_blueprint(imgserver.api) app.register_api(imgserver.api)
app.register_blueprint(settings.api) app.register_api(settings.api)
app.register_blueprint(colors.api) app.register_api(colors.api)
app.register_blueprint(lyrics.api) app.register_api(lyrics.api)
# Plugins # Plugins
app.register_blueprint(plugins.api) app.register_api(plugins.api)
app.register_blueprint(lyrics_plugin.api) app.register_api(lyrics_plugin.api)
# Logger # Logger
app.register_blueprint(logger.api_bp) app.register_api(logger.api)
# Home # Home
app.register_blueprint(home.api_bp) app.register_api(home.api)
# Flask Restful # Flask Restful
app.register_blueprint(getall.api_bp) app.register_api(getall.api)
return app return app
+79 -62
View File
@@ -4,41 +4,39 @@ Contains all the album routes.
import random import random
from flask import Blueprint, request from pydantic import Field
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from app.api.apischemas import AlbumHashSchema, AlbumLimitSchema, ArtistHashSchema
from app.db.sqlite.albumcolors import SQLiteAlbumMethods as adb from app.settings import Defaults
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.db.sqlite.lastfm.similar_artists import SQLiteLastFMSimilarArtists as lastfmdb
from app.lib.albumslib import sort_by_track_no
from app.models import FavType, Track from app.models import FavType, Track
from app.serializers.album import serialize_for_card
from app.serializers.track import serialize_track
from app.store.albums import AlbumStore from app.store.albums import AlbumStore
from app.store.tracks import TrackStore from app.store.tracks import TrackStore
from app.utils.hashing import create_hash from app.utils.hashing import create_hash
from app.lib.albumslib import sort_by_track_no
from app.serializers.album import serialize_for_card
from app.serializers.track import serialize_track
from app.db.sqlite.albumcolors import SQLiteAlbumMethods as adb
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.db.sqlite.lastfm.similar_artists import SQLiteLastFMSimilarArtists as lastfmdb
get_albums_by_albumartist = adb.get_albums_by_albumartist get_albums_by_albumartist = adb.get_albums_by_albumartist
check_is_fav = favdb.check_is_favorite check_is_fav = favdb.check_is_favorite
api = Blueprint("album", __name__, url_prefix="") bp_tag = Tag(name="Album", description="Single album")
api = APIBlueprint("album", __name__, url_prefix="/album", abp_tags=[bp_tag])
@api.route("/album", methods=["POST"]) # NOTE: Don't use "/" as it will cause redirects (failure)
def get_album_tracks_and_info(): @api.post("")
def get_album_tracks_and_info(body: AlbumHashSchema):
""" """
Returns all the tracks in the given album Get album and tracks
Returns album info and tracks for the given albumhash.
""" """
albumhash = body.albumhash
data = request.get_json()
error_msg = {"msg": "No hash provided"}
if data is None:
return error_msg, 400
try:
albumhash: str = data["albumhash"]
except KeyError:
return error_msg, 400
error_msg = {"error": "Album not created yet."} error_msg = {"error": "Album not created yet."}
album = AlbumStore.get_album_by_hash(albumhash) album = AlbumStore.get_album_by_hash(albumhash)
@@ -82,27 +80,43 @@ def get_album_tracks_and_info():
} }
@api.route("/album/<albumhash>/tracks", methods=["GET"]) @api.get("/<albumhash>/tracks")
def get_album_tracks(albumhash: str): def get_album_tracks(path: AlbumHashSchema):
""" """
Get album tracks
Returns all the tracks in the given album, sorted by disc and track number. Returns all the tracks in the given album, sorted by disc and track number.
NOTE: No album info is returned.
""" """
tracks = TrackStore.get_tracks_by_albumhash(albumhash) tracks = TrackStore.get_tracks_by_albumhash(path.albumhash)
tracks = sort_by_track_no(tracks) tracks = sort_by_track_no(tracks)
return {"tracks": tracks} return tracks
@api.route("/album/from-artist", methods=["POST"]) class GetMoreFromArtistsBody(AlbumLimitSchema):
def get_artist_albums(): albumartists: str = Field(
data = request.get_json() description="The artist hashes to get more albums from",
example=Defaults.API_ARTISTHASH,
)
if data is None: base_title: str = Field(
return {"msg": "No albumartist provided"} description="The base title of the album to exclude from the results.",
example=Defaults.API_ALBUMNAME,
default=None,
)
albumartists: str = data["albumartists"]
limit: int = data.get("limit") @api.post("/from-artist")
base_title: str = data.get("base_title") def get_more_from_artist(body: GetMoreFromArtistsBody):
"""
Get more from artist
Returns more albums from the given artist hashes.
"""
albumartists = body.albumartists
limit = body.limit
base_title = body.base_title
albumartists: list[str] = albumartists.split(",") albumartists: list[str] = albumartists.split(",")
@@ -125,23 +139,30 @@ def get_artist_albums():
if len(a["albums"]) > 0 if len(a["albums"]) > 0
] ]
return {"data": albums} return albums
@api.route("/album/versions", methods=["POST"]) class GetAlbumVersionsBody(ArtistHashSchema):
def get_album_versions(): og_album_title: str = Field(
description="The original album title (album.og_title)",
example=Defaults.API_ALBUMNAME,
)
base_title: str = Field(
description="The base title of the album to exclude from the results.",
example=Defaults.API_ALBUMNAME,
)
@api.post("/other-versions")
def get_album_versions(body: GetAlbumVersionsBody):
""" """
Get other versions
Returns other versions of the given album. Returns other versions of the given album.
""" """
og_album_title = body.og_album_title
data = request.get_json() base_title = body.base_title
artisthash = body.artisthash
if data is None:
return {"msg": "No albumartist provided"}
og_album_title: str = data["og_album_title"]
base_title: str = data["base_title"]
artisthash: str = data["artisthash"]
albums = AlbumStore.get_albums_by_artisthash(artisthash) albums = AlbumStore.get_albums_by_artisthash(artisthash)
@@ -156,26 +177,22 @@ def get_album_versions():
tracks = TrackStore.get_tracks_by_albumhash(a.albumhash) tracks = TrackStore.get_tracks_by_albumhash(a.albumhash)
a.get_date_from_tracks(tracks) a.get_date_from_tracks(tracks)
return {"data": albums} return albums
@api.route("/album/similar", methods=["GET"]) class GetSimilarAlbumsQuery(ArtistHashSchema, AlbumLimitSchema):
def get_similar_albums(): pass
@api.get("/similar")
def get_similar_albums(query: GetSimilarAlbumsQuery):
""" """
Get similar albums
Returns similar albums to the given album. Returns similar albums to the given album.
""" """
data = request.args artisthash = query.artisthash
limit = query.limit
if data is None:
return {"msg": "No artisthash provided"}
artisthash: str = data["artisthash"]
limit: int = data.get("limit")
if limit is None:
limit = 6
limit = int(limit)
similar_artists = lastfmdb.get_similar_artists_for(artisthash) similar_artists = lastfmdb.get_similar_artists_for(artisthash)
@@ -197,4 +214,4 @@ def get_similar_albums():
except ValueError: except ValueError:
pass pass
return {"albums": [serialize_for_card(a) for a in albums[:limit]]} return [serialize_for_card(a) for a in albums[:limit]]
+95
View File
@@ -0,0 +1,95 @@
"""
Reusable Pydantic basic schemas for the API
"""
from pydantic import BaseModel, Field
from app.settings import Defaults
class AlbumHashSchema(BaseModel):
"""
Extending this class will give you a model with the `albumhash` field
"""
albumhash: str = Field(
description="The album hash",
example=Defaults.API_ALBUMHASH,
min_length=Defaults.HASH_LENGTH,
max_length=Defaults.HASH_LENGTH,
)
class ArtistHashSchema(BaseModel):
"""
Extending this class will give you a model with the `artisthash` field
"""
artisthash: str = Field(
description="The artist hash",
example=Defaults.API_ARTISTHASH,
min_length=Defaults.HASH_LENGTH,
max_length=Defaults.HASH_LENGTH,
)
class TrackHashSchema(BaseModel):
"""
Extending this class will give you a model with the `trackhash` field
"""
trackhash: str = Field(
description="The track hash",
example=Defaults.API_TRACKHASH,
min_length=Defaults.HASH_LENGTH,
max_length=Defaults.HASH_LENGTH,
)
class GenericLimitSchema(BaseModel):
"""
Extending this class will give you a model with the `limit` field
"""
limit: int = Field(
description="The number of items to return",
example=Defaults.API_CARD_LIMIT,
default=Defaults.API_CARD_LIMIT,
)
# INFO: The following 3 classes are duplicated to specify the type of items
class TrackLimitSchema(BaseModel):
"""
Extending this class will give you a model with the `limit` field
"""
limit: int = Field(
description="The number of tracks to return",
example=Defaults.API_CARD_LIMIT,
default=Defaults.API_CARD_LIMIT,
)
class AlbumLimitSchema(BaseModel):
"""
Extending this class will give you a model with the `limit` field
"""
limit: int = Field(
description="The number of albums to return",
example=Defaults.API_CARD_LIMIT,
default=Defaults.API_CARD_LIMIT,
)
class ArtistLimitSchema(BaseModel):
"""
Extending this class will give you a model with the `limit` field
"""
limit: int = Field(
description="The number of artists to return",
example=Defaults.API_CARD_LIMIT,
default=Defaults.API_CARD_LIMIT,
)
+38 -35
View File
@@ -1,11 +1,14 @@
""" """
Contains all the artist(s) routes. Contains all the artist(s) routes.
""" """
import math import math
import random import random
from datetime import datetime from datetime import datetime
from flask import Blueprint, request from flask_openapi3 import APIBlueprint, Tag
from pydantic import Field
from app.api.apischemas import AlbumLimitSchema, ArtistHashSchema, ArtistLimitSchema, TrackLimitSchema
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.db.sqlite.lastfm.similar_artists import SQLiteLastFMSimilarArtists as fmdb from app.db.sqlite.lastfm.similar_artists import SQLiteLastFMSimilarArtists as fmdb
@@ -16,20 +19,19 @@ from app.store.albums import AlbumStore
from app.store.artists import ArtistStore from app.store.artists import ArtistStore
from app.store.tracks import TrackStore from app.store.tracks import TrackStore
api = Blueprint("artist", __name__, url_prefix="/") bp_tag = Tag(name="Artist", description="Single artist")
api = APIBlueprint("artist", __name__, url_prefix="/artist", abp_tags=[bp_tag])
@api.route("/artist/<artisthash>", methods=["GET"]) @api.get("/<string:artisthash>")
def get_artist(artisthash: str): def get_artist(path: ArtistHashSchema, query: TrackLimitSchema):
""" """
Get artist data. Get artist
Returns artist data, tracks and genres for the given artisthash.
""" """
limit = request.args.get("limit") artisthash = path.artisthash
limit = query.limit
if limit is None:
limit = 6
limit = int(limit)
artist = ArtistStore.get_artist_by_hash(artisthash) artist = ArtistStore.get_artist_by_hash(artisthash)
@@ -79,19 +81,23 @@ def get_artist(artisthash: str):
} }
@api.route("/artist/<artisthash>/albums", methods=["GET"]) class GetArtistAlbumsQuery(AlbumLimitSchema):
def get_artist_albums(artisthash: str): all: bool = Field(
limit = request.args.get("limit") description="Whether to ignore limit and return all albums", default=False
)
if limit is None:
limit = 6
return_all = request.args.get("all") @api.get("/<artisthash>/albums")
def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery):
"""
Get artist albums.
"""
return_all = query.all
artisthash = path.artisthash
limit = int(limit) limit = query.limit
all_albums = AlbumStore.get_albums_by_artisthash(artisthash) all_albums = AlbumStore.get_albums_by_artisthash(artisthash)
# start: check for missing albums. ie. compilations and features # start: check for missing albums. ie. compilations and features
all_tracks = TrackStore.get_tracks_by_artisthash(artisthash) all_tracks = TrackStore.get_tracks_by_artisthash(artisthash)
@@ -156,7 +162,7 @@ def get_artist_albums(artisthash: str):
if artist is None: if artist is None:
return {"error": "Artist not found"}, 404 return {"error": "Artist not found"}, 404
if return_all is not None and return_all == "true": if return_all:
limit = len(all_albums) limit = len(all_albums)
singles_and_eps = singles + eps singles_and_eps = singles + eps
@@ -170,29 +176,26 @@ def get_artist_albums(artisthash: str):
} }
@api.route("/artist/<artisthash>/tracks", methods=["GET"]) @api.get("/<artisthash>/tracks")
def get_all_artist_tracks(artisthash: str): def get_all_artist_tracks(path: ArtistHashSchema):
""" """
Get artist tracks
Returns all artists by a given artist. Returns all artists by a given artist.
""" """
tracks = TrackStore.get_tracks_by_artisthash(artisthash) tracks = TrackStore.get_tracks_by_artisthash(path.artisthash)
return {"tracks": serialize_tracks(tracks)} return serialize_tracks(tracks)
@api.route("/artist/<artisthash>/similar", methods=["GET"]) @api.get("/<artisthash>/similar")
def get_similar_artists(artisthash: str): def get_similar_artists(path: ArtistHashSchema, query: ArtistLimitSchema):
""" """
Returns similar artists. Get similar artists.
""" """
limit = request.args.get("limit") limit = query.limit
if limit is None: artist = ArtistStore.get_artist_by_hash(path.artisthash)
limit = 6
limit = int(limit)
artist = ArtistStore.get_artist_by_hash(artisthash)
if artist is None: if artist is None:
return {"error": "Artist not found"}, 404 return {"error": "Artist not found"}, 404
@@ -207,7 +210,7 @@ def get_similar_artists(artisthash: str):
if len(similar) > limit: if len(similar) > limit:
similar = random.sample(similar, limit) similar = random.sample(similar, limit)
return {"artists": similar[:limit]} return similar[:limit]
# TODO: Rewrite this file using generators where possible # TODO: Rewrite this file using generators where possible
+11 -5
View File
@@ -1,12 +1,18 @@
from flask import Blueprint from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from app.api.apischemas import AlbumHashSchema
from app.store.albums import AlbumStore as Store from app.store.albums import AlbumStore as Store
api = Blueprint("colors", __name__, url_prefix="/colors") bp_tag = Tag(name="Colors", description="Get item colors")
api = APIBlueprint("colors", __name__, url_prefix="/colors", abp_tags=[bp_tag])
@api.route("/album/<albumhash>") @api.get("/album/<albumhash>")
def get_album_color(albumhash: str): def get_album_color(path: AlbumHashSchema):
album = Store.get_album_by_hash(albumhash) """
Get album color
"""
album = Store.get_album_by_hash(path.albumhash)
msg = {"color": ""} msg = {"color": ""}
+98 -88
View File
@@ -1,36 +1,51 @@
from flask import Blueprint, request from typing import List, TypeVar
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
from app.api.apischemas import GenericLimitSchema
from app.models import FavType from app.models import FavType
from app.serializers.album import serialize_for_card, serialize_for_card_many from app.settings import Defaults
from app.serializers.artist import serialize_for_card as serialize_artist from app.utils.bisection import use_bisection
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.serializers.track import serialize_track, serialize_tracks from app.serializers.track import serialize_track, serialize_tracks
from app.utils.bisection import UseBisection from app.serializers.artist import serialize_for_card as serialize_artist
from app.serializers.album import serialize_for_card, serialize_for_card_many
from app.store.artists import ArtistStore
from app.store.albums import AlbumStore from app.store.albums import AlbumStore
from app.store.tracks import TrackStore from app.store.tracks import TrackStore
from app.store.artists import ArtistStore
from app.utils.dates import timestamp_to_time_passed
bp_tag = Tag(name="Favorites", description="Your favorite items")
api = APIBlueprint("favorites", __name__, url_prefix="/favorites", abp_tags=[bp_tag])
api = Blueprint("favorite", __name__, url_prefix="/") T = TypeVar("T")
def remove_none(items: list): def remove_none(items: List[T]) -> List[T]:
return [i for i in items if i is not None] return [i for i in items if i is not None]
@api.route("/favorite/add", methods=["POST"]) class FavoritesAddBody(BaseModel):
def add_favorite(): hash: str = Field(
description="The hash of the item",
min_length=Defaults.HASH_LENGTH,
max_length=Defaults.HASH_LENGTH,
example=Defaults.API_ALBUMHASH,
)
type: str = Field(description="The type of the item", example=FavType.album)
@api.post("/add")
def add_favorite(body: FavoritesAddBody):
""" """
Adds a favorite to the database. Adds a favorite to the database.
""" """
data = request.get_json() itemhash = body.hash
itemtype = body.type
if data is None:
return {"error": "No data provided"}, 400
itemhash = data.get("hash")
itemtype = data.get("type")
favdb.insert_one_favorite(itemtype, itemhash) favdb.insert_one_favorite(itemtype, itemhash)
@@ -40,18 +55,13 @@ def add_favorite():
return {"msg": "Added to favorites"} return {"msg": "Added to favorites"}
@api.route("/favorite/remove", methods=["POST"]) @api.post("/remove")
def remove_favorite(): def remove_favorite(body: FavoritesAddBody):
""" """
Removes a favorite from the database. Removes a favorite from the database.
""" """
data = request.get_json() itemhash = body.hash
itemtype = body.type
if data is None:
return {"error": "No data provided"}, 400
itemhash = data.get("hash")
itemtype = data.get("type")
favdb.delete_favorite(itemtype, itemhash) favdb.delete_favorite(itemtype, itemhash)
@@ -61,22 +71,19 @@ def remove_favorite():
return {"msg": "Removed from favorites"} return {"msg": "Removed from favorites"}
@api.route("/albums/favorite") @api.get("/albums")
def get_favorite_albums(): def get_favorite_albums(query: GenericLimitSchema):
limit = request.args.get("limit") """
Get favorite albums
if limit is None: """
limit = 6 limit = query.limit
limit = int(limit)
albums = favdb.get_fav_albums() albums = favdb.get_fav_albums()
albumhashes = [a[1] for a in albums] albumhashes = [a[1] for a in albums]
albumhashes.reverse() albumhashes.reverse()
src_albums = sorted(AlbumStore.albums, key=lambda x: x.albumhash) src_albums = sorted(AlbumStore.albums, key=lambda x: x.albumhash)
fav_albums = UseBisection(src_albums, "albumhash", albumhashes)() fav_albums = use_bisection(src_albums, "albumhash", albumhashes)
fav_albums = remove_none(fav_albums) fav_albums = remove_none(fav_albums)
if limit == 0: if limit == 0:
@@ -85,21 +92,18 @@ def get_favorite_albums():
return {"albums": serialize_for_card_many(fav_albums[:limit])} return {"albums": serialize_for_card_many(fav_albums[:limit])}
@api.route("/tracks/favorite") @api.get("/tracks")
def get_favorite_tracks(): def get_favorite_tracks(query: GenericLimitSchema):
limit = request.args.get("limit") """
Get favorite tracks
if limit is None: """
limit = 6 limit = query.limit
limit = int(limit)
tracks = favdb.get_fav_tracks() tracks = favdb.get_fav_tracks()
trackhashes = [t[1] for t in tracks] trackhashes = [t[1] for t in tracks]
trackhashes.reverse() trackhashes.reverse()
src_tracks = sorted(TrackStore.tracks, key=lambda x: x.trackhash) src_tracks = sorted(TrackStore.tracks, key=lambda x: x.trackhash)
tracks = UseBisection(src_tracks, "trackhash", trackhashes)() tracks = use_bisection(src_tracks, "trackhash", trackhashes)
tracks = remove_none(tracks) tracks = remove_none(tracks)
if limit == 0: if limit == 0:
@@ -108,22 +112,19 @@ def get_favorite_tracks():
return {"tracks": serialize_tracks(tracks[:limit])} return {"tracks": serialize_tracks(tracks[:limit])}
@api.route("/artists/favorite") @api.get("/artists")
def get_favorite_artists(): def get_favorite_artists(query: GenericLimitSchema):
limit = request.args.get("limit") """
Get favorite artists
if limit is None: """
limit = 6 limit = query.limit
limit = int(limit)
artists = favdb.get_fav_artists() artists = favdb.get_fav_artists()
artisthashes = [a[1] for a in artists] artisthashes = [a[1] for a in artists]
artisthashes.reverse() artisthashes.reverse()
src_artists = sorted(ArtistStore.artists, key=lambda x: x.artisthash) src_artists = sorted(ArtistStore.artists, key=lambda x: x.artisthash)
artists = UseBisection(src_artists, "artisthash", artisthashes)() artists = use_bisection(src_artists, "artisthash", artisthashes)
artists = remove_none(artists) artists = remove_none(artists)
if limit == 0: if limit == 0:
@@ -132,27 +133,38 @@ def get_favorite_artists():
return {"artists": artists[:limit]} return {"artists": artists[:limit]}
@api.route("/favorites") class GetAllFavoritesQuery(BaseModel):
def get_all_favorites(): """
Extending this class will give you a model with the `limit` field
"""
track_limit: int = Field(
description="The number of tracks to return",
example=Defaults.API_CARD_LIMIT,
default=Defaults.API_CARD_LIMIT,
)
album_limit: int = Field(
description="The number of albums to return",
example=Defaults.API_CARD_LIMIT,
default=Defaults.API_CARD_LIMIT,
)
artist_limit: int = Field(
description="The number of artists to return",
example=Defaults.API_CARD_LIMIT,
default=Defaults.API_CARD_LIMIT,
)
@api.get("")
def get_all_favorites(query: GetAllFavoritesQuery):
""" """
Returns all the favorites in the database. Returns all the favorites in the database.
""" """
track_limit = request.args.get("track_limit") track_limit = query.track_limit
album_limit = request.args.get("album_limit") album_limit = query.album_limit
artist_limit = request.args.get("artist_limit") artist_limit = query.artist_limit
if track_limit is None:
track_limit = 6
if album_limit is None:
album_limit = 6
if artist_limit is None:
artist_limit = 6
track_limit = int(track_limit)
album_limit = int(album_limit)
artist_limit = int(artist_limit)
# largest is x2 to accound for broken hashes if any # largest is x2 to accound for broken hashes if any
largest = max(track_limit, album_limit, artist_limit) largest = max(track_limit, album_limit, artist_limit)
@@ -169,6 +181,7 @@ def get_all_favorites():
artist_master_hash = set(a.artisthash for a in ArtistStore.artists) artist_master_hash = set(a.artisthash for a in ArtistStore.artists)
for fav in favs: for fav in favs:
# INFO: hash is [1], type is [2], timestamp is [3]
hash = fav[1] hash = fav[1]
if fav[2] == FavType.track: if fav[2] == FavType.track:
tracks.append(hash) if hash in track_master_hash else None tracks.append(hash) if hash in track_master_hash else None
@@ -189,9 +202,9 @@ def get_all_favorites():
src_albums = sorted(AlbumStore.albums, key=lambda x: x.albumhash) src_albums = sorted(AlbumStore.albums, key=lambda x: x.albumhash)
src_artists = sorted(ArtistStore.artists, key=lambda x: x.artisthash) src_artists = sorted(ArtistStore.artists, key=lambda x: x.artisthash)
tracks = UseBisection(src_tracks, "trackhash", tracks, limit=track_limit)() tracks = use_bisection(src_tracks, "trackhash", tracks, limit=track_limit)
albums = UseBisection(src_albums, "albumhash", albums, limit=album_limit)() albums = use_bisection(src_albums, "albumhash", albums, limit=album_limit)
artists = UseBisection(src_artists, "artisthash", artists, limit=artist_limit)() artists = use_bisection(src_artists, "artisthash", artists, limit=artist_limit)
tracks = remove_none(tracks) tracks = remove_none(tracks)
albums = remove_none(albums) albums = remove_none(albums)
@@ -201,6 +214,7 @@ def get_all_favorites():
# first_n = favs # first_n = favs
for fav in favs: for fav in favs:
# INFO: hash is [1], type is [2], timestamp is [3]
if len(recents) >= largest: if len(recents) >= largest:
break break
@@ -212,6 +226,7 @@ def get_all_favorites():
album = serialize_for_card(album) album = serialize_for_card(album)
album["help_text"] = "album" album["help_text"] = "album"
album["time"] = timestamp_to_time_passed(fav[3])
recents.append( recents.append(
{ {
@@ -228,6 +243,7 @@ def get_all_favorites():
artist = serialize_artist(artist) artist = serialize_artist(artist)
artist["help_text"] = "artist" artist["help_text"] = "artist"
artist["time"] = timestamp_to_time_passed(fav[3])
recents.append( recents.append(
{ {
@@ -244,6 +260,7 @@ def get_all_favorites():
track = serialize_track(track) track = serialize_track(track)
track["help_text"] = "track" track["help_text"] = "track"
track["time"] = timestamp_to_time_passed(fav[3])
recents.append({"type": "track", "item": track}) recents.append({"type": "track", "item": track})
@@ -256,20 +273,13 @@ def get_all_favorites():
} }
@api.route("/favorites/check") @api.get("/check")
def check_favorite(): def check_favorite(query: FavoritesAddBody):
""" """
Checks if a favorite exists in the database. Checks if a favorite exists in the database.
""" """
itemhash = request.args.get("hash") itemhash = query.hash
itemtype = request.args.get("type") itemtype = query.type
if itemhash is None:
return {"error": "No hash provided"}, 400
if itemtype is None:
return {"error": "No type provided"}, 400
exists = favdb.check_is_favorite(itemhash, itemtype) exists = favdb.check_is_favorite(itemhash, itemtype)
return {"is_favorite": exists} return {"is_favorite": exists}
+62 -37
View File
@@ -1,11 +1,14 @@
""" """
Contains all the folder routes. Contains all the folder routes.
""" """
import os import os
from pathlib import Path from pathlib import Path
import psutil import psutil
from flask import Blueprint, request from pydantic import BaseModel, Field
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from showinfm import show_in_file_manager from showinfm import show_in_file_manager
from app import settings from app import settings
@@ -15,24 +18,24 @@ from app.serializers.track import serialize_track
from app.store.tracks import TrackStore as store from app.store.tracks import TrackStore as store
from app.utils.wintools import is_windows, win_replace_slash from app.utils.wintools import is_windows, win_replace_slash
api = Blueprint("folder", __name__, url_prefix="") tag = Tag(name="Folders", description="Get folders and tracks in a directory")
api = APIBlueprint("folder", __name__, url_prefix="/folder", abp_tags=[tag])
@api.route("/folder", methods=["POST"]) class FolderTree(BaseModel):
def get_folder_tree(): folder: str = Field("$home", description="The folder to things from")
tracks_only: bool = Field(False, description="Whether to only get tracks")
@api.post("")
def get_folder_tree(body: FolderTree):
""" """
Get folder
Returns a list of all the folders and tracks in the given folder. Returns a list of all the folders and tracks in the given folder.
""" """
data = request.get_json() req_dir = body.folder
req_dir = "$home" tracks_only = body.tracks_only
tracks_only = False
if data is not None:
try:
req_dir: str = data["folder"]
tracks_only: bool = data["tracks_only"]
except KeyError:
req_dir = "$home"
root_dirs = db.get_root_dirs() root_dirs = db.get_root_dirs()
root_dirs.sort() root_dirs.sort()
@@ -44,6 +47,9 @@ def get_folder_tree():
pass pass
if req_dir == "$home": if req_dir == "$home":
if len(root_dirs) == 1:
req_dir = root_dirs[0]
else:
folders = get_folders(root_dirs) folders = get_folders(root_dirs)
return { return {
@@ -92,18 +98,23 @@ def get_all_drives(is_win: bool = False):
return drives return drives
@api.route("/folder/dir-browser", methods=["POST"]) class DirBrowserBody(BaseModel):
def list_folders(): folder: str = Field(
""" "$root",
Returns a list of all the folders in the given folder. description="The folder to list directories from",
""" )
data = request.get_json()
is_win = is_windows()
try:
req_dir: str = data["folder"] @api.post("/dir-browser")
except KeyError: def list_folders(body: DirBrowserBody):
req_dir = "$root" """
List folders
Returns a list of all the folders in the given folder.
Used when selecting root dirs.
"""
req_dir = body.folder
is_win = is_windows()
if req_dir == "$root": if req_dir == "$root":
return { return {
@@ -131,26 +142,40 @@ def list_folders():
} }
@api.route("/folder/show-in-files") class FolderOpenInFileManagerQuery(BaseModel):
def open_in_file_manager(): path: str = Field(
path = request.args.get("path") description="The path to open in the file manager",
)
if path is None:
return {"error": "No path provided."}, 400
show_in_file_manager(path) @api.get("/show-in-files")
def open_in_file_manager(query: FolderOpenInFileManagerQuery):
"""
Open in file manager
Opens the given path in the file manager on the host machine.
"""
show_in_file_manager(query.path)
return {"success": True} return {"success": True}
@api.route("/folder/tracks/all") class GetTracksInPathQuery(BaseModel):
def get_tracks_in_path(): path: str = Field(
path = request.args.get("path") description="The path to get tracks from",
)
if path is None:
return {"error": "No path provided."}, 400
tracks = store.get_tracks_in_path(path) @api.get("/tracks/all")
def get_tracks_in_path(query: GetTracksInPathQuery):
"""
Get tracks in path
Gets all (or a max of 300) tracks from the given path and its subdirectories.
Used when adding tracks to the queue.
"""
tracks = store.get_tracks_in_path(query.path)
tracks = sorted(tracks, key=lambda i: i.last_mod) tracks = sorted(tracks, key=lambda i: i.last_mod)
tracks = (serialize_track(t) for t in tracks if Path(t.filepath).exists()) tracks = (serialize_track(t) for t in tracks if Path(t.filepath).exists())
+120 -5
View File
@@ -1,10 +1,125 @@
from flask import Blueprint from flask import Blueprint
from flask_restful import Api
from .resources import Albums from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
api_bp = Blueprint("getall", __name__, url_prefix="/getall") from datetime import datetime
api = Api(api_bp) from app.api.apischemas import GenericLimitSchema
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
from app.serializers.album import serialize_for_card as serialize_album
from app.serializers.artist import serialize_for_card as serialize_artist
from app.utils import format_number
from app.utils.dates import (
create_new_date,
date_string_to_time_passed,
seconds_to_time_string,
)
bp_tag = Tag(name="Get all", description="List all items")
api = APIBlueprint("getall", __name__, url_prefix="/getall", abp_tags=[bp_tag])
api.add_resource(Albums, "/<itemtype>") class GetAllItemsBody(GenericLimitSchema):
start: int = Field(
description="The start index of the items to return",
example=0,
default=0,
)
sortby: str = Field(
description="The key to sort items by",
example="created_date",
default="created_date",
)
reverse: int = Field(
description="Reverse the sort",
example=1,
default=1,
)
class GetAllItemsPath(BaseModel):
itemtype: str = Field(
description="The type of items to return (albums | artists)",
example="albums",
default="albums",
)
@api.get("/<itemtype>")
def get_all_items(path: GetAllItemsPath, query: GetAllItemsBody):
"""
Get all items
Used to show all albums or artists in the library
"""
is_albums = path.itemtype == "albums"
is_artists = path.itemtype == "artists"
items = AlbumStore.albums
if is_artists:
items = ArtistStore.artists
start = query.start
limit = query.limit
sort = query.sortby
reverse = query.reverse == 1
# if sort == "":
# sort = "created_date"
sort_is_count = sort == "count"
sort_is_duration = sort == "duration"
sort_is_create_date = sort == "created_date"
sort_is_date = is_albums and sort == "date"
sort_is_artist = is_albums and sort == "albumartists"
sort_is_artist_trackcount = is_artists and sort == "trackcount"
sort_is_artist_albumcount = is_artists and sort == "albumcount"
lambda_sort = lambda x: getattr(x, sort)
if sort_is_artist:
lambda_sort = lambda x: getattr(x, sort)[0].name
sorted_items = sorted(items, key=lambda_sort, reverse=reverse)
items = sorted_items[start : start + limit]
album_list = []
for item in items:
item_dict = serialize_album(item) if is_albums else serialize_artist(item)
if sort_is_date:
item_dict["help_text"] = item.date
if sort_is_create_date:
date = create_new_date(datetime.fromtimestamp(item.created_date))
timeago = date_string_to_time_passed(date)
item_dict["help_text"] = timeago
if sort_is_count:
item_dict["help_text"] = (
f"{format_number(item.count)} track{'' if item.count == 1 else 's'}"
)
if sort_is_duration:
item_dict["help_text"] = seconds_to_time_string(item.duration)
if sort_is_artist_trackcount:
item_dict["help_text"] = (
f"{format_number(item.trackcount)} track{'' if item.trackcount == 1 else 's'}"
)
if sort_is_artist_albumcount:
item_dict["help_text"] = (
f"{format_number(item.albumcount)} album{'' if item.albumcount == 1 else 's'}"
)
album_list.append(item_dict)
return {"items": album_list, "total": len(sorted_items)}
-93
View File
@@ -1,93 +0,0 @@
from flask_restful import Resource, reqparse
from datetime import datetime
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
from app.serializers.album import serialize_for_card as serialize_album
from app.serializers.artist import serialize_for_card as serialize_artist
from app.utils import format_number
from app.utils.dates import (
create_new_date,
date_string_to_time_passed,
seconds_to_time_string,
)
parser = reqparse.RequestParser()
parser.add_argument("start", type=int, default=0, location="args")
parser.add_argument("limit", type=int, default=20, location="args")
parser.add_argument("sortby", type=str, default="created_date", location="args")
parser.add_argument("reverse", type=str, default="1", location="args")
class Albums(Resource):
def get(self, itemtype: str):
is_albums = itemtype == "albums"
is_artists = itemtype == "artists"
items = AlbumStore.albums
if is_artists:
items = ArtistStore.artists
args = parser.parse_args()
start = args["start"]
limit = args["limit"]
sort = args["sortby"]
reverse = args["reverse"] == "1"
if sort == "":
sort = "created_date"
sort_is_count = sort == "count"
sort_is_duration = sort == "duration"
sort_is_create_date = sort == "created_date"
sort_is_date = is_albums and sort == "date"
sort_is_artist = is_albums and sort == "albumartists"
sort_is_artist_trackcount = is_artists and sort == "trackcount"
sort_is_artist_albumcount = is_artists and sort == "albumcount"
lambda_sort = lambda x: getattr(x, sort)
if sort_is_artist:
lambda_sort = lambda x: getattr(x, sort)[0].name
sorted_items = sorted(items, key=lambda_sort, reverse=reverse)
items = sorted_items[start : start + limit]
album_list = []
for item in items:
item_dict = serialize_album(item) if is_albums else serialize_artist(item)
if sort_is_date:
item_dict["help_text"] = item.date
if sort_is_create_date:
date = create_new_date(datetime.fromtimestamp(item.created_date))
timeago = date_string_to_time_passed(date)
item_dict["help_text"] = timeago
if sort_is_count:
item_dict[
"help_text"
] = f"{format_number(item.count)} track{'' if item.count == 1 else 's'}"
if sort_is_duration:
item_dict["help_text"] = seconds_to_time_string(item.duration)
if sort_is_artist_trackcount:
item_dict[
"help_text"
] = f"{format_number(item.trackcount)} track{'' if item.trackcount == 1 else 's'}"
if sort_is_artist_albumcount:
item_dict[
"help_text"
] = f"{format_number(item.albumcount)} album{'' if item.albumcount == 1 else 's'}"
album_list.append(item_dict)
return {"items": album_list, "total": len(sorted_items)}
+21 -7
View File
@@ -1,11 +1,25 @@
from flask import Blueprint from flask_openapi3 import Tag
from flask_restful import Api from flask_openapi3 import APIBlueprint
from .recents import RecentlyAdded, RecentlyPlayed from app.api.apischemas import GenericLimitSchema
from app.lib.home.recentlyadded import get_recent_items
from app.lib.home.recentlyplayed import get_recently_played
api_bp = Blueprint("home", __name__, url_prefix="/home") bp_tag = Tag(name="Home", description="Homepage items")
api = Api(api_bp) api = APIBlueprint("home", __name__, url_prefix="/home", abp_tags=[bp_tag])
api.add_resource(RecentlyAdded, "/recents/added") @api.get("/recents/added")
api.add_resource(RecentlyPlayed, "/recents/played") def get_recently_added(query: GenericLimitSchema):
"""
Get recently added
"""
return {"items": get_recent_items(query.limit)}
@api.get("/recents/played")
def get_recent_plays(query: GenericLimitSchema):
"""
Get recently played
"""
return {"items": get_recently_played(query.limit)}
-24
View File
@@ -1,24 +0,0 @@
from flask_restful import Resource, reqparse
from app.lib.home.recentlyadded import get_recent_items
from app.lib.home.recentlyplayed import get_recently_played
parser = reqparse.RequestParser()
parser.add_argument("limit", type=int, required=False, default=7, location="args")
class RecentlyAdded(Resource):
def get(self):
args = parser.parse_args()
limit = args["limit"]
return {"items": get_recent_items(limit)}
class RecentlyPlayed(Resource):
def get(self):
args = parser.parse_args()
limit = args["limit"]
return {"items": get_recently_played(limit)}
+76 -42
View File
@@ -1,88 +1,122 @@
from pathlib import Path from pathlib import Path
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
from flask import send_from_directory
from flask import Blueprint, send_from_directory from app.settings import Defaults, Paths
from app.settings import Paths bp_tag = Tag(
name="Images", description="Image filenames are constructured as '{itemhash}.webp'"
api = Blueprint("imgserver", __name__, url_prefix="/img") )
api = APIBlueprint("imgserver", __name__, url_prefix="/img", abp_tags=[bp_tag])
@api.route("/")
def hello():
return "<h1>Image Server</h1>"
def send_fallback_img(filename: str = "default.webp"): def send_fallback_img(filename: str = "default.webp"):
path = Paths.get_assets_path() folder = Paths.get_assets_path()
img = Path(path) / filename img = Path(folder) / filename
if not img.exists(): if not img.exists():
return "", 404 return "", 404
return send_from_directory(path, filename) return send_from_directory(folder, filename)
@api.route("/t/o/<imgpath>") class ImagePath(BaseModel):
def send_original_thumbnail(imgpath: str): imgpath: str = Field(
path = Paths.get_original_thumb_path() description="The image filename",
fpath = Path(path) / imgpath example=Defaults.API_ALBUMHASH + ".webp",
)
@api.get("/t/o/<imgpath>")
def send_original_thumbnail(path: ImagePath):
"""
Get original thumbnail
"""
folder = Paths.get_original_thumb_path()
fpath = Path(folder) / path.imgpath
if fpath.exists(): if fpath.exists():
return send_from_directory(path, imgpath) return send_from_directory(folder, path.imgpath)
return send_fallback_img() return send_fallback_img()
@api.route("/t/<imgpath>") @api.get("/t/<imgpath>")
def send_lg_thumbnail(imgpath: str): def send_lg_thumbnail(path: ImagePath):
path = Paths.get_lg_thumb_path() """
fpath = Path(path) / imgpath Get large thumbnail (500 x 500)
"""
folder = Paths.get_lg_thumb_path()
fpath = Path(folder) / path.imgpath
if fpath.exists(): if fpath.exists():
return send_from_directory(path, imgpath) return send_from_directory(folder, path.imgpath)
return send_fallback_img() return send_fallback_img()
@api.route("/t/s/<imgpath>") @api.get("/t/s/<imgpath>")
def send_sm_thumbnail(imgpath: str): def send_sm_thumbnail(path: ImagePath):
path = Paths.get_sm_thumb_path() """
fpath = Path(path) / imgpath Get small thumbnail (64 x 64)
"""
folder = Paths.get_sm_thumb_path()
fpath = Path(folder) / path.imgpath
if fpath.exists(): if fpath.exists():
return send_from_directory(path, imgpath) return send_from_directory(folder, path.imgpath)
return send_fallback_img() return send_fallback_img()
@api.route("/a/<imgpath>") @api.get("/a/<imgpath>")
def send_lg_artist_image(imgpath: str): def send_lg_artist_image(path: ImagePath):
path = Paths.get_artist_img_lg_path() """
fpath = Path(path) / imgpath Get large artist image (500 x 500)
"""
folder = Paths.get_artist_img_lg_path()
fpath = Path(folder) / path.imgpath
if fpath.exists(): if fpath.exists():
return send_from_directory(path, imgpath) return send_from_directory(folder, path.imgpath)
return send_fallback_img("artist.webp") return send_fallback_img("artist.webp")
@api.route("/a/s/<imgpath>") @api.get("/a/s/<imgpath>")
def send_sm_artist_image(imgpath: str): def send_sm_artist_image(path: ImagePath):
path = Paths.get_artist_img_sm_path() """
fpath = Path(path) / imgpath Get small artist image (64 x 64)
"""
folder = Paths.get_artist_img_sm_path()
fpath = Path(folder) / path.imgpath
if fpath.exists(): if fpath.exists():
return send_from_directory(path, imgpath) return send_from_directory(folder, path.imgpath)
return send_fallback_img("artist.webp") return send_fallback_img("artist.webp")
@api.route("/p/<imgpath>") class PlaylistImagePath(BaseModel):
def send_playlist_image(imgpath: str): imgpath: str = Field(
path = Paths.get_playlist_img_path() description="The image path",
fpath = Path(path) / imgpath example="1.webp",
)
@api.get("/p/<imgpath>")
def send_playlist_image(path: PlaylistImagePath):
"""
Get playlist image
Images are constructed as '{playlist_id}.webp'
"""
folder = Paths.get_playlist_img_path()
fpath = Path(folder) / path.imgpath
if fpath.exists(): if fpath.exists():
return send_from_directory(path, imgpath) return send_from_directory(folder, path.imgpath)
return send_fallback_img("playlist.svg") return send_fallback_img("playlist.svg")
+33 -6
View File
@@ -1,11 +1,38 @@
from flask import Blueprint from flask_openapi3 import Tag
from flask_restful import Api from flask_openapi3 import APIBlueprint
from pydantic import Field
from app.api.apischemas import TrackHashSchema
from app.api.logger.tracks import LogTrack from app.db.sqlite.logger.tracks import SQLiteTrackLogger as db
from app.settings import Defaults
bp_tag = Tag(name="Logger", description="Log item plays")
api = APIBlueprint("logger", __name__, url_prefix="/logger", abp_tags=[bp_tag])
api_bp = Blueprint("logger", __name__, url_prefix="/logger") class LogTrackBody(TrackHashSchema):
api = Api(api_bp) timestamp: int = Field(description="The timestamp of the track", example=1622217600)
duration: int = Field(
description="The duration of the track in seconds", example=300
)
source: str = Field(
description="The play source of the track",
example=f"al:{Defaults.API_ALBUMHASH}",
)
api.add_resource(LogTrack, "/track/log") @api.post("/track/log")
def log_track(body: LogTrackBody):
"""
Log a track play to the database.
"""
trackhash = body.trackhash
timestamp = body.timestamp
duration = body.duration
source = body.source
last_row = db.insert_track(
trackhash=trackhash, timestamp=timestamp, duration=duration, source=source
)
return {"last_row": last_row}
-19
View File
@@ -1,19 +0,0 @@
from flask_restful import Resource, reqparse
from app.db.sqlite.logger.tracks import SQLiteTrackLogger as db
parser = reqparse.RequestParser()
parser.add_argument("trackhash", type=str, required=True)
parser.add_argument("timestamp", type=int, required=True)
parser.add_argument("duration", type=int, required=True)
parser.add_argument("source", type=str, required=True)
class LogTrack(Resource):
def post(self):
args = parser.parse_args(strict=True)
last_row = db.insert_track(
args["trackhash"], args["duration"], args["source"], args["timestamp"]
)
return {"last_row": last_row}
+24 -21
View File
@@ -1,5 +1,8 @@
from flask import Blueprint, request from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import Field
from app.api.apischemas import TrackHashSchema
from app.lib.lyrics import ( from app.lib.lyrics import (
get_lyrics, get_lyrics,
check_lyrics_file, check_lyrics_file,
@@ -7,21 +10,24 @@ from app.lib.lyrics import (
get_lyrics_from_tags, get_lyrics_from_tags,
) )
api = Blueprint("lyrics", __name__, url_prefix="") bp_tag = Tag(name="Lyrics", description="Get lyrics")
api = APIBlueprint("lyrics", __name__, url_prefix="/lyrics", abp_tags=[bp_tag])
@api.route("/lyrics", methods=["POST"]) class SendLyricsBody(TrackHashSchema):
def send_lyrics(): filepath: str = Field(
description="The path to the file",
example="/path/to/file.mp3",
)
@api.post("")
def send_lyrics(body: SendLyricsBody):
""" """
Returns the lyrics for a track Returns the lyrics for a track
""" """
data = request.get_json() filepath = body.filepath
trackhash = body.trackhash
filepath = data.get("filepath", None)
trackhash = data.get("trackhash", None)
if filepath is None or trackhash is None:
return {"error": "No filepath or trackhash provided"}, 400
is_synced = True is_synced = True
lyrics, copyright = get_lyrics(filepath) lyrics, copyright = get_lyrics(filepath)
@@ -38,15 +44,13 @@ def send_lyrics():
return {"lyrics": lyrics, "synced": is_synced, "copyright": copyright}, 200 return {"lyrics": lyrics, "synced": is_synced, "copyright": copyright}, 200
@api.route("/lyrics/check", methods=["POST"]) @api.post("/check")
def check_lyrics(): def check_lyrics(body: SendLyricsBody):
data = request.get_json() """
Checks if lyrics exist for a track
filepath = data.get("filepath", None) """
trackhash = data.get("trackhash", None) filepath = body.filepath
trackhash = body.trackhash
if filepath is None or trackhash is None:
return {"error": "No filepath or trackhash provided"}, 400
exists = check_lyrics_file(filepath, trackhash) exists = check_lyrics_file(filepath, trackhash)
@@ -56,4 +60,3 @@ def check_lyrics():
exists = get_lyrics_from_tags(filepath, just_check=True) exists = get_lyrics_from_tags(filepath, just_check=True)
return {"exists": exists}, 200 return {"exists": exists}, 200
+117 -110
View File
@@ -1,12 +1,15 @@
""" """
All playlist-related routes. All playlist-related routes.
""" """
import json import json
from datetime import datetime from datetime import datetime
import pathlib import pathlib
from flask import Blueprint, request
from PIL import UnidentifiedImageError, Image from PIL import UnidentifiedImageError, Image
from pydantic import BaseModel, Field
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint, FileStorage
from app import models from app import models
from app.db.sqlite.playlists import SQLitePlaylistMethods from app.db.sqlite.playlists import SQLitePlaylistMethods
@@ -18,23 +21,25 @@ from app.utils.dates import create_new_date, date_string_to_time_passed
from app.utils.remove_duplicates import remove_duplicates from app.utils.remove_duplicates import remove_duplicates
from app.settings import Paths from app.settings import Paths
api = Blueprint("playlist", __name__, url_prefix="/") tag = Tag(name="Playlists", description="Get and manage playlists")
api = APIBlueprint("playlists", __name__, url_prefix="/playlists", abp_tags=[tag])
PL = SQLitePlaylistMethods PL = SQLitePlaylistMethods
class SendAllPlaylistsQuery(BaseModel):
no_images: bool = Field(False, description="Whether to include images")
@api.route("/playlists", methods=["GET"])
def send_all_playlists(): @api.get("")
def send_all_playlists(query: SendAllPlaylistsQuery):
""" """
Gets all the playlists. Gets all the playlists.
""" """
no_images = request.args.get("no_images", False)
playlists = PL.get_all_playlists() playlists = PL.get_all_playlists()
playlists = list(playlists) playlists = list(playlists)
for playlist in playlists: for playlist in playlists:
if not no_images: if not query.no_images:
playlist.images = playlistlib.get_first_4_images( playlist.images = playlistlib.get_first_4_images(
trackhashes=playlist.trackhashes trackhashes=playlist.trackhashes
) )
@@ -69,22 +74,23 @@ def insert_playlist(name: str, image: str = None):
return PL.insert_one_playlist(playlist) return PL.insert_one_playlist(playlist)
@api.route("/playlist/new", methods=["POST"]) class CreatePlaylistBody(BaseModel):
def create_playlist(): name: str = Field(..., description="The name of the playlist")
@api.post("/new")
def create_playlist(body: CreatePlaylistBody):
""" """
New playlist
Creates a new playlist. Accepts POST method with a JSON body. Creates a new playlist. Accepts POST method with a JSON body.
""" """
data = request.get_json() existing_playlist_count = PL.count_playlist_by_name(body.name)
if data is None:
return {"error": "Playlist name not provided"}, 400
existing_playlist_count = PL.count_playlist_by_name(data["name"])
if existing_playlist_count > 0: if existing_playlist_count > 0:
return {"error": "Playlist already exists"}, 409 return {"error": "Playlist already exists"}, 409
playlist = insert_playlist(data["name"]) playlist = insert_playlist(body.name)
if playlist is None: if playlist is None:
return {"error": "Playlist could not be created"}, 500 return {"error": "Playlist could not be created"}, 500
@@ -119,25 +125,30 @@ def get_artist_trackhashes(artisthash: str):
return [t.trackhash for t in tracks] return [t.trackhash for t in tracks]
@api.route("/playlist/<playlist_id>/add", methods=["POST"]) class PlaylistIDPath(BaseModel):
def add_item_to_playlist(playlist_id: str): # INFO: playlistid string examples: "recentlyadded"
playlistid: int | str = Field(..., description="The ID of the playlist")
class AddItemToPlaylistBody(BaseModel):
itemtype: str = Field(
default="tracks",
description="The type of item to add",
examples=["tracks", "folder", "album", "artist"],
)
itemhash: str = Field(..., description="The hash of the item to add")
@api.post("/<playlistid>/add")
def add_item_to_playlist(path: PlaylistIDPath, body: AddItemToPlaylistBody):
""" """
Takes a playlist ID and a track hash, and adds the track to the playlist Add to playlist.
If itemtype is not "tracks", itemhash is expected to be a folder, album or artist hash.
""" """
data = request.get_json() itemtype = body.itemtype
itemhash = body.itemhash
if data is None: playlist_id = path.playlistid
return {"error": "Track hash not provided"}, 400
try:
itemtype = data["itemtype"]
except KeyError:
itemtype = None
try:
itemhash: str = data["itemhash"]
except KeyError:
itemhash = None
if itemtype == "tracks": if itemtype == "tracks":
trackhashes = itemhash.split(",") trackhashes = itemhash.split(",")
@@ -158,13 +169,17 @@ def add_item_to_playlist(playlist_id: str):
return {"msg": "Done"}, 200 return {"msg": "Done"}, 200
@api.route("/playlist/<playlistid>") class GetPlaylistQuery(BaseModel):
def get_playlist(playlistid: str): no_tracks: bool = Field(False, description="Whether to include tracks")
@api.get("/<playlistid>")
def get_playlist(path: PlaylistIDPath, query: GetPlaylistQuery):
""" """
Gets a playlist by id, and if it exists, it gets all the tracks in the playlist and returns them. Get playlist by id
""" """
no_tracks = request.args.get("no_tracks", "false") no_tracks = query.no_tracks
no_tracks = no_tracks == "true" playlistid = path.playlistid
is_recently_added = playlistid == "recentlyadded" is_recently_added = playlistid == "recentlyadded"
@@ -201,31 +216,40 @@ def get_playlist(playlistid: str):
return {"info": playlist, "tracks": tracks if not no_tracks else []} return {"info": playlist, "tracks": tracks if not no_tracks else []}
@api.route("/playlist/<playlistid>/update", methods=["PUT"]) class UpdatePlaylistForm(BaseModel):
def update_playlist_info(playlistid: str): image: FileStorage = Field(None, description="The image file")
if playlistid is None: name: str = Field(..., description="The name of the playlist")
return {"error": "Playlist ID not provided"}, 400 settings: str = Field(
...,
description="The settings of the playlist",
example='{"has_gif": false, "banner_pos": 50, "square_img": false, "pinned": false}',
)
db_playlist = PL.get_playlist_by_id(int(playlistid))
@api.put("/<playlistid>/update", methods=["PUT"])
def update_playlist_info(path: PlaylistIDPath, form: UpdatePlaylistForm):
"""
Update playlist
"""
playlistid = path.playlistid
db_playlist = PL.get_playlist_by_id(playlistid)
if db_playlist is None: if db_playlist is None:
return {"error": "Playlist not found"}, 404 return {"error": "Playlist not found"}, 404
image = None image = form.image
if "image" in request.files: if form.image:
image = request.files["image"] image = form.image
data = request.form settings = json.loads(form.settings)
settings = json.loads(data.get("settings"))
settings["has_gif"] = False settings["has_gif"] = False
playlist = { playlist = {
"id": int(playlistid), "id": playlistid,
"image": db_playlist.image, "image": db_playlist.image,
"last_updated": create_new_date(), "last_updated": create_new_date(),
"name": str(data.get("name")).strip(), "name": str(form.name).strip(),
"settings": settings, "settings": settings,
"trackhashes": json.dumps([]), "trackhashes": json.dumps([]),
} }
@@ -247,7 +271,7 @@ def update_playlist_info(playlistid: str):
p_tuple = (*playlist.values(),) p_tuple = (*playlist.values(),)
PL.update_playlist(int(playlistid), playlist) PL.update_playlist(playlistid, playlist)
playlist = models.Playlist(*p_tuple) playlist = models.Playlist(*p_tuple)
playlist.last_updated = date_string_to_time_passed(playlist.last_updated) playlist.last_updated = date_string_to_time_passed(playlist.last_updated)
@@ -257,12 +281,12 @@ def update_playlist_info(playlistid: str):
} }
@api.route("/playlist/<playlistid>/pin_unpin", methods=["GET"]) @api.post("/<playlistid>/pin_unpin")
def pin_unpin_playlist(playlistid: str): def pin_unpin_playlist(path: PlaylistIDPath):
""" """
Pins or unpins a playlist. Pin playlist.
""" """
playlist = PL.get_playlist_by_id(int(playlistid)) playlist = PL.get_playlist_by_id(path.playlistid)
if playlist is None: if playlist is None:
return {"error": "Playlist not found"}, 404 return {"error": "Playlist not found"}, 404
@@ -274,23 +298,22 @@ def pin_unpin_playlist(playlistid: str):
except KeyError: except KeyError:
settings["pinned"] = True settings["pinned"] = True
PL.update_settings(int(playlistid), settings) PL.update_settings(path.playlistid, settings)
return {"msg": "Done"}, 200 return {"msg": "Done"}, 200
@api.route("/playlist/<playlistid>/remove-img", methods=["GET"]) @api.delete("/<playlistid>/remove-img")
def remove_playlist_image(playlistid: str): def remove_playlist_image(path: PlaylistIDPath):
""" """
Removes the playlist image. Clear playlist image.
""" """
pid = int(playlistid) playlist = PL.get_playlist_by_id(path.playlistid)
playlist = PL.get_playlist_by_id(pid)
if playlist is None: if playlist is None:
return {"error": "Playlist not found"}, 404 return {"error": "Playlist not found"}, 404
PL.remove_banner(pid) PL.remove_banner(path.playlistid)
playlist.image = None playlist.image = None
playlist.thumb = None playlist.thumb = None
@@ -303,78 +326,62 @@ def remove_playlist_image(playlistid: str):
return {"playlist": playlist}, 200 return {"playlist": playlist}, 200
@api.route("/playlist/delete", methods=["POST"]) @api.delete("/<playlistid>/delete", methods=["DELETE"])
def remove_playlist(): def remove_playlist(path: PlaylistIDPath):
""" """
Deletes a playlist by ID. Delete playlist
""" """
message = {"error": "Playlist ID not provided"} PL.delete_playlist(path.playlistid)
data = request.get_json()
if data is None:
return message, 400
try:
pid = data["pid"]
except KeyError:
return message, 400
PL.delete_playlist(pid)
return {"msg": "Done"}, 200 return {"msg": "Done"}, 200
@api.route("/playlist/<pid>/remove-tracks", methods=["POST"]) class RemoveTracksFromPlaylistBody(BaseModel):
def remove_tracks_from_playlist(pid: int): tracks: list[dict] = Field(..., description="A list of trackhashes to remove")
data = request.get_json()
if data is None:
return {"error": "Track index not provided"}, 400
@api.post("/<playlistid>/remove-tracks")
def remove_tracks_from_playlist(
path: PlaylistIDPath, body: RemoveTracksFromPlaylistBody
):
"""
Remove track from playlist
"""
# A track looks like this:
# { # {
# trackhash: str; # trackhash: str;
# index: int; # index: int;
# } # }
tracks = data["tracks"] PL.remove_tracks_from_playlist(path.playlistid, body.tracks)
PL.remove_tracks_from_playlist(pid, tracks)
return {"msg": "Done"}, 200 return {"msg": "Done"}, 200
def playlist_exists(name: str) -> bool: def playlist_name_exists(name: str) -> bool:
return PL.count_playlist_by_name(name) > 0 return PL.count_playlist_by_name(name) > 0
@api.route("/playlist/save-item", methods=["POST"]) class SavePlaylistAsItemBody(BaseModel):
def save_item_as_playlist(): itemtype: str = Field(..., description="The type of item", example="tracks")
data = request.get_json() playlist_name: str = Field(..., description="The name of the playlist")
msg = {"error": "'itemtype', 'playlist_name' and 'itemhash' not provided"}, 400 itemhash: str = Field(..., description="The hash of the item to save")
if data is None:
return msg
try: @api.post("/save-item")
playlist_name = data["playlist_name"] def save_item_as_playlist(body: SavePlaylistAsItemBody):
except KeyError: """
playlist_name = None Save as playlist
if playlist_exists(playlist_name): Saves a track, album, artist or folder as a playlist
"""
itemtype = body.itemtype
playlist_name = body.playlist_name
itemhash = body.itemhash
if playlist_name_exists(playlist_name):
return {"error": "Playlist already exists"}, 409 return {"error": "Playlist already exists"}, 409
try:
itemtype = data["itemtype"]
except KeyError:
itemtype = None
try:
itemhash: str = data["itemhash"]
except KeyError:
itemhash = None
if itemtype is None or playlist_name is None or itemhash is None:
return msg
if itemtype == "tracks": if itemtype == "tracks":
trackhashes = itemhash.split(",") trackhashes = itemhash.split(",")
elif itemtype == "folder": elif itemtype == "folder":
+38 -15
View File
@@ -1,37 +1,60 @@
from flask import Blueprint, request from flask import Blueprint, request
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
from app.db.sqlite.plugins import PluginsMethods from app.db.sqlite.plugins import PluginsMethods
bp_tag = Tag(name="Plugins", description="Manage plugins")
api = Blueprint("plugins", __name__, url_prefix="/plugins") api = APIBlueprint("plugins", __name__, url_prefix="/plugins", abp_tags=[bp_tag])
@api.route("/", methods=["GET"]) @api.get("/")
def get_all_plugins(): def get_all_plugins():
"""
List all plugins
"""
plugins = PluginsMethods.get_all_plugins() plugins = PluginsMethods.get_all_plugins()
return {"plugins": plugins} return {"plugins": plugins}
@api.route("/setactive", methods=["GET"]) class PluginBody(BaseModel):
def activate_deactivate_plugin(): plugin: str = Field(description="The plugin name", example="lyrics")
name = request.args.get("plugin", None)
state = request.args.get("state", None)
if not name or not state:
return {"error": "Missing plugin or state"}, 400
PluginsMethods.plugin_set_active(name, int(state)) class PluginActivateBody(PluginBody):
active: bool = Field(
description="New plugin active state", example=False, default=False
)
@api.post("/setactive")
def activate_deactivate_plugin(body: PluginActivateBody):
"""
Activate/Deactivate plugin
"""
name = body.plugin
active = 1 if body.active else 0
PluginsMethods.plugin_set_active(name, active)
return {"message": "OK"}, 200 return {"message": "OK"}, 200
@api.route("/settings", methods=["POST"]) class PluginSettingsBody(PluginBody):
def update_plugin_settings(): settings: dict = Field(
data = request.get_json() description="The new plugin settings", example={"key": "value"}
)
plugin = data.get("plugin", None)
settings = data.get("settings", None) @api.post("/settings")
def update_plugin_settings(body: PluginSettingsBody):
"""
Update plugin settings
"""
plugin = body.plugin
settings = body.settings
if not plugin or not settings: if not plugin or not settings:
return {"error": "Missing plugin or settings"}, 400 return {"error": "Missing plugin or settings"}, 400
+28 -11
View File
@@ -1,24 +1,41 @@
from flask import Blueprint, request from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import Field
from app.api.apischemas import TrackHashSchema
from app.lib.lyrics import format_synced_lyrics from app.lib.lyrics import format_synced_lyrics
from app.plugins.lyrics import Lyrics from app.plugins.lyrics import Lyrics
from app.settings import Defaults
from app.utils.hashing import create_hash from app.utils.hashing import create_hash
api = Blueprint("lyricsplugin", __name__, url_prefix="/plugins/lyrics") bp_tag = Tag(name="Lyrics Plugin", description="Musixmatch lyrics plugin")
api = APIBlueprint(
"lyricsplugin", __name__, url_prefix="/plugins/lyrics", abp_tags=[bp_tag]
)
@api.route("/search", methods=["POST"]) class LyricsSearchBody(TrackHashSchema):
def search_lyrics(): title: str = Field(description="The track title ", example=Defaults.API_TRACKNAME)
data = request.get_json() artist: str = Field(description="The track artist ", example=Defaults.API_ARTISTNAME)
album: str = Field(description="The track track album ", example=Defaults.API_ALBUMNAME)
filepath: str = Field(
description="Track filepath to save the lyrics file relative to",
example="/home/cwilvx/temp/crazy song.mp3",
)
trackhash = data.get("trackhash", "")
title = data.get("title", "") @api.post("/search")
artist = data.get("artist", "") def search_lyrics(body: LyricsSearchBody):
album = data.get("album", "") """
filepath = data.get("filepath", None) Search for lyrics by title and artist
"""
title = body.title
artist = body.artist
album = body.album
filepath = body.filepath
trackhash = body.trackhash
finder = Lyrics() finder = Lyrics()
data = finder.search_lyrics_by_title_and_artist(title, artist) data = finder.search_lyrics_by_title_and_artist(title, artist)
if not data: if not data:
+54 -37
View File
@@ -2,14 +2,19 @@
Contains all the search routes. Contains all the search routes.
""" """
from flask import Blueprint, request from flask import request
from unidecode import unidecode from unidecode import unidecode
from pydantic import BaseModel, Field
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from app import models from app import models
from app.lib import searchlib from app.lib import searchlib
from app.settings import Defaults
from app.store.tracks import TrackStore from app.store.tracks import TrackStore
api = Blueprint("search", __name__, url_prefix="/") tag = Tag(name="Search", description="Search for tracks, albums and artists")
api = APIBlueprint("search", __name__, url_prefix="/search", abp_tags=[tag])
SEARCH_COUNT = 30 SEARCH_COUNT = 30
"""The max amount of items to return per request""" """The max amount of items to return per request"""
@@ -63,18 +68,19 @@ class Search:
return finder.search(self.query, in_quotes=in_quotes, limit=limit) return finder.search(self.query, in_quotes=in_quotes, limit=limit)
@api.route("/search/tracks", methods=["GET"]) class SearchQuery(BaseModel):
def search_tracks(): q: str = Field(description="The search query", example=Defaults.API_ARTISTNAME)
@api.get("/tracks")
def search_tracks(query: SearchQuery):
""" """
Searches for tracks that match the search query. Search tracks
""" """
query = request.args.get("q") query = query.q
in_quotes = query_in_quotes(query) in_quotes = query_in_quotes(query)
if not query:
return {"error": "No query provided"}, 400
tracks = Search(query).search_tracks(in_quotes) tracks = Search(query).search_tracks(in_quotes)
return { return {
@@ -83,18 +89,15 @@ def search_tracks():
} }
@api.route("/search/albums", methods=["GET"]) @api.get("/albums")
def search_albums(): def search_albums(query: SearchQuery):
""" """
Searches for albums. Search albums.
""" """
query = request.args.get("q") query = query.q
in_quotes = query_in_quotes(query) in_quotes = query_in_quotes(query)
if not query:
return {"error": "No query provided"}, 400
albums = Search(query).search_albums(in_quotes) albums = Search(query).search_albums(in_quotes)
return { return {
@@ -103,13 +106,13 @@ def search_albums():
} }
@api.route("/search/artists", methods=["GET"]) @api.get("/artists")
def search_artists(): def search_artists(query: SearchQuery):
""" """
Searches for artists. Search artists.
""" """
query = request.args.get("q") query = query.q
if not query: if not query:
return {"error": "No query provided"}, 400 return {"error": "No query provided"}, 400
@@ -122,15 +125,21 @@ def search_artists():
} }
@api.route("/search/top", methods=["GET"]) class TopResultsQuery(SearchQuery):
def get_top_results(): limit: int = Field(
""" description="The number of items to return", default=Defaults.API_CARD_LIMIT
Returns the top results for the search query. )
"""
query = request.args.get("q")
limit = request.args.get("limit", "6") @api.get("/top")
limit = int(limit) def get_top_results(query: TopResultsQuery):
"""
Get top results
Returns the top results for the given query.
"""
limit = query.limit
query = query.q
in_quotes = query_in_quotes(query) in_quotes = query_in_quotes(query)
@@ -140,32 +149,40 @@ def get_top_results():
return Search(query).get_top_results(in_quotes=in_quotes, limit=limit) return Search(query).get_top_results(in_quotes=in_quotes, limit=limit)
@api.route("/search/loadmore") class SearchLoadMoreQuery(SearchQuery):
def search_load_more(): type: str = Field(description="The type of search", example="tracks")
index: int = Field(description="The index to start from", default=0)
@api.get("/loadmore")
def search_load_more(query: SearchLoadMoreQuery):
""" """
Load more
Returns more songs, albums or artists from a search query. Returns more songs, albums or artists from a search query.
NOTE: You must first initiate a search using the `/search` endpoint.
""" """
query = request.args.get("q") query = query.q
item_type = query.type
index = query.index
in_quotes = query_in_quotes(query) in_quotes = query_in_quotes(query)
s_type = request.args.get("type") if item_type == "tracks":
index = int(request.args.get("index") or 0)
if s_type == "tracks":
t = Search(query).search_tracks(in_quotes) t = Search(query).search_tracks(in_quotes)
return { return {
"tracks": t[index : index + SEARCH_COUNT], "tracks": t[index : index + SEARCH_COUNT],
"more": len(t) > index + SEARCH_COUNT, "more": len(t) > index + SEARCH_COUNT,
} }
elif s_type == "albums": elif item_type == "albums":
a = Search(query).search_albums(in_quotes) a = Search(query).search_albums(in_quotes)
return { return {
"albums": a[index : index + SEARCH_COUNT], "albums": a[index : index + SEARCH_COUNT],
"more": len(a) > index + SEARCH_COUNT, "more": len(a) > index + SEARCH_COUNT,
} }
elif s_type == "artists": elif item_type == "artists":
a = Search(query).search_artists() a = Search(query).search_artists()
return { return {
"artists": a[index : index + SEARCH_COUNT], "artists": a[index : index + SEARCH_COUNT],
+43 -15
View File
@@ -1,30 +1,43 @@
""" """
Contains all the track routes. Contains all the track routes.
""" """
import os import os
from flask import Blueprint, send_file, request from flask import Blueprint, send_file, request
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from app.api.apischemas import TrackHashSchema
from app.lib.trackslib import get_silence_paddings from app.lib.trackslib import get_silence_paddings
from app.store.tracks import TrackStore from app.store.tracks import TrackStore
api = Blueprint("track", __name__, url_prefix="/") bp_tag = Tag(name="File", description="Audio files")
api = APIBlueprint("track", __name__, url_prefix="/file", abp_tags=[bp_tag])
@api.route("/file/<trackhash>") class SendTrackFileQuery(BaseModel):
def send_track_file(trackhash: str): filepath: str = Field(
description="The filepath to play (if available)", default=None
)
@api.get("/<trackhash>")
def send_track_file(path: TrackHashSchema, query: SendTrackFileQuery):
""" """
Returns an audio file that matches the passed id to the client. Get file
Falls back to track hash if id is not found.
Returns a playable audio file that corresponds to the given filepath. Falls back to track hash if filepath is not found.
""" """
trackhash = path.trackhash
filepath = query.filepath
msg = {"msg": "File Not Found"} msg = {"msg": "File Not Found"}
def get_mime(filename: str) -> str: def get_mime(filename: str) -> str:
ext = filename.rsplit(".", maxsplit=1)[-1] ext = filename.rsplit(".", maxsplit=1)[-1]
return f"audio/{ext}" return f"audio/{ext}"
filepath = request.args.get("filepath") # If filepath is provide, try to send that
if filepath is not None: if filepath is not None:
try: try:
track = TrackStore.get_tracks_by_filepaths([filepath])[0] track = TrackStore.get_tracks_by_filepaths([filepath])[0]
@@ -37,9 +50,7 @@ def send_track_file(trackhash: str):
audio_type = get_mime(filepath) audio_type = get_mime(filepath)
return send_file(filepath, mimetype=audio_type) return send_file(filepath, mimetype=audio_type)
if trackhash is None: # Else, find file by trackhash
return msg, 404
tracks = TrackStore.get_tracks_by_trackhashes([trackhash]) tracks = TrackStore.get_tracks_by_trackhashes([trackhash])
for track in tracks: for track in tracks:
@@ -56,11 +67,28 @@ def send_track_file(trackhash: str):
return msg, 404 return msg, 404
@api.route("/file/silence", methods=["POST"]) class GetAudioSilenceBody(BaseModel):
def get_audio_silence(): ending_file: str = Field(
data = request.get_json() description="The ending file's path",
ending_file = data.get("end", None) # ending file's filepath example="/home/cwilvx/Music/Made in Kenya/Sol generation/Bensoul - Salama.mp3",
starting_file = data.get("start", None) # starting file's filepath )
starting_file: str = Field(
description="The beginning file's path",
example="/home/cwilvx/Music/Tidal/Albums/Bensoul - Qwarantunes/Bensoul - Peddi.m4a",
)
@api.post("/silence")
def get_audio_silence(body: GetAudioSilenceBody):
"""
Get silence paddings
Returns the duration of silence at the end of the current ending track and the duration of silence at the beginning of the next track.
NOTE: Durations are in milliseconds.
"""
ending_file = body.ending_file # ending file's filepath
starting_file = body.starting_file # starting file's filepath
if ending_file is None or starting_file is None: if ending_file is None or starting_file is None:
return {"msg": "No filepath provided"}, 400 return {"msg": "No filepath provided"}, 400
+46 -26
View File
@@ -1,4 +1,8 @@
from flask import Blueprint, request from typing import Any
from flask import request
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
from app.db.sqlite.plugins import PluginsMethods as pdb from app.db.sqlite.plugins import PluginsMethods as pdb
from app.db.sqlite.settings import SettingsSQLMethods as sdb from app.db.sqlite.settings import SettingsSQLMethods as sdb
@@ -12,7 +16,8 @@ from app.store.tracks import TrackStore
from app.utils.generators import get_random_str from app.utils.generators import get_random_str
from app.utils.threading import background from app.utils.threading import background
api = Blueprint("settings", __name__, url_prefix="") bp_tag = Tag(name="Settings", description="Customize stuff")
api = APIBlueprint("settings", __name__, url_prefix="/notsettings", abp_tags=[bp_tag])
def get_child_dirs(parent: str, children: list[str]): def get_child_dirs(parent: str, children: list[str]):
@@ -77,23 +82,24 @@ def finalize(new_: list[str], removed_: list[str], db_dirs_: list[str]):
rebuild_store(db_dirs_) rebuild_store(db_dirs_)
@api.route("/settings/add-root-dirs", methods=["POST"]) class AddRootDirsBody(BaseModel):
def add_root_dirs(): new_dirs: list[str] = Field(
description="The new directories to add",
example=["/home/user/Music", "/home/user/Downloads"],
)
removed: list[str] = Field(
description="The directories to remove",
example=["/home/user/Downloads"],
)
@api.post("/add-root-dirs")
def add_root_dirs(body: AddRootDirsBody):
""" """
Add custom root directories to the database. Add custom root directories to the database.
""" """
msg = {"msg": "Failed! No directories were given."} new_dirs = body.new_dirs
removed_dirs = body.removed
data = request.get_json()
if data is None:
return msg, 400
try:
new_dirs: list[str] = data["new_dirs"]
removed_dirs: list[str] = data["removed"]
except KeyError:
return msg, 400
db_dirs = sdb.get_root_dirs() db_dirs = sdb.get_root_dirs()
_h = "$home" _h = "$home"
@@ -132,10 +138,10 @@ def add_root_dirs():
return {"root_dirs": db_dirs} return {"root_dirs": db_dirs}
@api.route("/settings/get-root-dirs", methods=["GET"]) @api.get("/get-root-dirs")
def get_root_dirs(): def get_root_dirs():
""" """
Get custom root directories from the database. Get root directories
""" """
dirs = sdb.get_root_dirs() dirs = sdb.get_root_dirs()
@@ -154,10 +160,10 @@ mapp = {
} }
@api.route("/settings/", methods=["GET"]) @api.get("")
def get_all_settings(): def get_all_settings():
""" """
Get all settings from the database. Get all settings
""" """
settings = sdb.get_all_settings() settings = sdb.get_all_settings()
@@ -195,10 +201,24 @@ def reload_all_for_set_setting():
reload_everything(get_random_str()) reload_everything(get_random_str())
@api.route("/settings/set", methods=["POST"]) class SetSettingBody(BaseModel):
def set_setting(): key: str = Field(
key = request.get_json().get("key") description="The setting key",
value = request.get_json().get("value") example="artist_separators",
)
value: Any = Field(
description="The setting value",
example=",",
)
@api.post("/set")
def set_setting(body: SetSettingBody):
"""
Set a setting.
"""
key = body.key
value = body.value
if key is None or value is None or key == "root_dirs": if key is None or value is None or key == "root_dirs":
return {"msg": "Invalid arguments!"}, 400 return {"msg": "Invalid arguments!"}, 400
@@ -235,10 +255,10 @@ def run_populate():
populate.Populate(instance_key=get_random_str()) populate.Populate(instance_key=get_random_str())
@api.route("/settings/trigger-scan", methods=["GET"]) @api.get("/trigger-scan")
def trigger_scan(): def trigger_scan():
""" """
Triggers a scan. Triggers scan for new music
""" """
run_populate() run_populate()
+11 -1
View File
@@ -1,6 +1,7 @@
""" """
Handles arguments passed to the program. Handles arguments passed to the program.
""" """
import os.path import os.path
import sys import sys
@@ -9,6 +10,7 @@ import PyInstaller.__main__ as bundler
from app import settings from app import settings
from app.logger import log from app.logger import log
from app.print_help import HELP_MESSAGE from app.print_help import HELP_MESSAGE
from app.utils.paths import getFlaskOpenApiPath
from app.utils.xdg_utils import get_xdg_config_dir from app.utils.xdg_utils import get_xdg_config_dir
from app.utils.wintools import is_windows from app.utils.wintools import is_windows
@@ -45,6 +47,8 @@ class HandleArgs:
config_keys = [ config_keys = [
"SWINGMUSIC_APP_VERSION", "SWINGMUSIC_APP_VERSION",
"GIT_LATEST_COMMIT_HASH",
"GIT_CURRENT_BRANCH",
] ]
lines = [] lines = []
@@ -65,6 +69,8 @@ class HandleArgs:
_s = ";" if is_windows() else ":" _s = ";" if is_windows() else ":"
flask_openapi_path = getFlaskOpenApiPath()
bundler.run( bundler.run(
[ [
"manage.py", "manage.py",
@@ -74,6 +80,7 @@ class HandleArgs:
"--clean", "--clean",
f"--add-data=assets{_s}assets", f"--add-data=assets{_s}assets",
f"--add-data=client{_s}client", f"--add-data=client{_s}client",
f"--add-data={flask_openapi_path}/templates/static{_s}flask_openapi3/templates/static",
f"--icon=assets/logo-fill.light.ico", f"--icon=assets/logo-fill.light.ico",
"-y", "-y",
] ]
@@ -176,5 +183,8 @@ class HandleArgs:
@staticmethod @staticmethod
def handle_version(): def handle_version():
if any((a in ARGS for a in ALLARGS.version)): if any((a in ARGS for a in ALLARGS.version)):
print(settings.Keys.SWINGMUSIC_APP_VERSION) print(f"VERSION: v{settings.Keys.SWINGMUSIC_APP_VERSION}")
print(
f"COMMIT#: {settings.Keys.GIT_CURRENT_BRANCH}/{settings.Keys.GIT_LATEST_COMMIT_HASH}"
)
sys.exit(0) sys.exit(0)
+2
View File
@@ -1 +1,3 @@
SWINGMUSIC_APP_VERSION = "" SWINGMUSIC_APP_VERSION = ""
GIT_LATEST_COMMIT_HASH = ""
GIT_CURRENT_BRANCH = ""
+4 -2
View File
@@ -1,3 +1,4 @@
from datetime import datetime
from app.models import FavType from app.models import FavType
from .utils import SQLiteManager from .utils import SQLiteManager
@@ -26,9 +27,10 @@ class SQLiteFavoriteMethods:
if cls.check_is_favorite(fav_hash, fav_type): if cls.check_is_favorite(fav_hash, fav_type):
return return
sql = """INSERT INTO favorites(type, hash) VALUES(?,?)""" sql = """INSERT INTO favorites(type, hash, timestamp) VALUES(?,?,?)"""
current_timestamp = datetime.now().timestamp()
with SQLiteManager(userdata_db=True) as cur: with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (fav_type, fav_hash)) cur.execute(sql, (fav_type, fav_hash, current_timestamp))
cur.close() cur.close()
@classmethod @classmethod
+2 -2
View File
@@ -66,9 +66,9 @@ class PluginsMethods:
return [] return []
@classmethod @classmethod
def plugin_set_active(cls, name: str, state: int): def plugin_set_active(cls, name: str, active: int):
with SQLiteManager(userdata_db=True) as cur: with SQLiteManager(userdata_db=True) as cur:
cur.execute("UPDATE plugins SET active=? WHERE name=?", (state, name)) cur.execute("UPDATE plugins SET active=? WHERE name=?", (active, name))
cur.close() cur.close()
@classmethod @classmethod
+2 -1
View File
@@ -15,7 +15,8 @@ CREATE TABLE IF NOT EXISTS playlists (
CREATE TABLE IF NOT EXISTS favorites ( CREATE TABLE IF NOT EXISTS favorites (
id integer PRIMARY KEY, id integer PRIMARY KEY,
hash text not null, hash text not null,
type text not null type text not null,
timestamp integer not null default 0
); );
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
+3 -5
View File
@@ -12,7 +12,7 @@ from app.serializers.artist import serialize_for_card
from itertools import groupby from itertools import groupby
from app.utils.dates import timestamp_from_days_ago, timestamp_to_time_passed from app.utils.dates import timestamp_to_time_passed
older_albums = set() older_albums = set()
older_artists = set() older_artists = set()
@@ -216,8 +216,6 @@ def get_recent_items(limit: int = 7):
return recent_items return recent_items
def get_recent_tracks(cutoff_days: int): def get_recent_tracks(limit: int):
tracks = sorted(TrackStore.tracks, key=lambda t: t.created_date, reverse=True) tracks = sorted(TrackStore.tracks, key=lambda t: t.created_date, reverse=True)
timestamp = timestamp_from_days_ago(cutoff_days) return tracks[:limit]
return [t for t in tracks if t.created_date > timestamp]
+7 -4
View File
@@ -1,6 +1,7 @@
""" """
This library contains all the functions related to playlists. This library contains all the functions related to playlists.
""" """
import os import os
import random import random
import string import string
@@ -62,7 +63,7 @@ def create_gif_thumbnail(image: Any, img_path: str):
def save_p_image( def save_p_image(
img: Image, pid: str, content_type: str = None, filename: str = None img: Image, pid: int, content_type: str = None, filename: str = None
) -> str: ) -> str:
""" """
Saves a playlist banner image and returns the filepath. Saves a playlist banner image and returns the filepath.
@@ -72,7 +73,7 @@ def save_p_image(
random_str = "".join(random.choices(string.ascii_letters + string.digits, k=5)) random_str = "".join(random.choices(string.ascii_letters + string.digits, k=5))
if not filename: if not filename:
filename = pid + str(random_str) + ".webp" filename = str(pid) + str(random_str) + ".webp"
full_img_path = os.path.join(settings.Paths.get_playlist_img_path(), filename) full_img_path = os.path.join(settings.Paths.get_playlist_img_path(), filename)
@@ -134,7 +135,7 @@ def get_first_4_images(
return duplicate_images(images) return duplicate_images(images)
def get_recently_added_playlist(cutoff: int = 14): def get_recently_added_playlist(limit: int = 100):
playlist = Playlist( playlist = Playlist(
id="recentlyadded", id="recentlyadded",
name="Recently Added", name="Recently Added",
@@ -144,8 +145,10 @@ def get_recently_added_playlist(cutoff: int = 14):
trackhashes=[], trackhashes=[],
) )
tracks = get_recent_tracks(cutoff) tracks = get_recent_tracks(limit=limit)
try: try:
# Create date to show as last updated
date = datetime.fromtimestamp(tracks[0].created_date) date = datetime.fromtimestamp(tracks[0].created_date)
except IndexError: except IndexError:
return playlist, [] return playlist, []
+3 -3
View File
@@ -60,7 +60,7 @@ def get_silence_paddings(ending_file: str, starting_file: str):
""" """
Returns the ending silence of a track and the starting silence of the next. Returns the ending silence of a track and the starting silence of the next.
""" """
silence = {"start": 0, "end": 0} silence = {"starting_file": 0, "ending_file": 0}
ending_thread = None ending_thread = None
starting_thread = None starting_thread = None
@@ -77,9 +77,9 @@ def get_silence_paddings(ending_file: str, starting_file: str):
starting_thread.start() starting_thread.start()
if ending_thread: if ending_thread:
silence["end"] = ending_thread.join() silence["ending_file"] = ending_thread.join()
if starting_thread: if starting_thread:
silence["start"] = starting_thread.join() silence["starting_file"] = starting_thread.join()
return silence return silence
+5 -5
View File
@@ -10,10 +10,9 @@ ONLY MODIFY OLD MIGRATIONS FOR BUG FIXES OR ENHANCEMENTS ONLY
PS: Fuck that! Do what you want. PS: Fuck that! Do what you want.
""" """
from app.db.sqlite.migrations import MigrationManager from app.db.sqlite.migrations import MigrationManager
from app.logger import log from app.logger import log
from app.migrations import v1_3_0 from app.migrations import v1_3_0, v1_4_9
from app.migrations.base import Migration from app.migrations.base import Migration
migrations: list[list[Migration]] = [ migrations: list[list[Migration]] = [
@@ -26,7 +25,8 @@ migrations: list[list[Migration]] = [
v1_3_0.MovePlaylistsAndFavoritesTo10BitHashes, v1_3_0.MovePlaylistsAndFavoritesTo10BitHashes,
v1_3_0.RemoveAllTracks, v1_3_0.RemoveAllTracks,
v1_3_0.UpdateAppSettingsTable, v1_3_0.UpdateAppSettingsTable,
] ],
[v1_4_9.AddTimestampToFavoritesTable],
] ]
@@ -38,8 +38,8 @@ def apply_migrations():
version = MigrationManager.get_version() version = MigrationManager.get_version()
if version != len(migrations): if version != len(migrations):
# run migrations after the previous migration version # INFO: Apply new migrations
for migration in migrations[(version - 1) :]: for migration in migrations[version:]:
for m in migration: for m in migration:
try: try:
m.migrate() m.migrate()
+34
View File
@@ -0,0 +1,34 @@
from app.db.sqlite.utils import SQLiteManager
from app.migrations.base import Migration
class AddTimestampToFavoritesTable(Migration):
"""
Adds a timestamp column to the favorites table.
"""
@staticmethod
def migrate():
# INFO: add timestamp column with automatic current timestamp
sql = f"ALTER TABLE favorites ADD COLUMN timestamp INTEGER NOT NULL DEFAULT 0"
# INFO: execute the sql
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql)
# INFO: Update the timestamp column with the current timestamp
cur.execute("UPDATE favorites SET timestamp = strftime('%s', 'now')")
cur.close()
class MoveHashesToSha1(Migration):
"""
Moves the 10 bit item hashes from sha256 to sha1 which is
faster and more lenient on less powerful devices.
Thanks to [@tcsenpai](https:github.com/tcsenpai) for the contribution.
"""
pass
# INFO: Apparentlly, every single table is affected by this migration.
# NOTE: Use generators to avoid memory issues.
+42 -3
View File
@@ -1,7 +1,9 @@
""" """
Contains default configs Contains default configs
""" """
import os import os
import subprocess
import sys import sys
from typing import Any from typing import Any
@@ -96,6 +98,15 @@ class Defaults:
""" """
The size of extracted images in pixels The size of extracted images in pixels
""" """
HASH_LENGTH = 10
API_ALBUMHASH = "c5bcec6cb3"
API_ARTISTHASH = "fc6f0acac5"
API_TRACKHASH = "0853280a12"
API_ALBUMNAME = "Rumours"
API_ARTISTNAME = "girl in red"
API_TRACKNAME = "Apartment 402"
API_CARD_LIMIT = 6
FILES = ["flac", "mp3", "wav", "m4a", "ogg", "wma", "opus", "alac", "aiff"] FILES = ["flac", "mp3", "wav", "m4a", "ogg", "wma", "opus", "alac", "aiff"]
@@ -233,21 +244,49 @@ class TCOLOR:
# credits: https://stackoverflow.com/a/287944 # credits: https://stackoverflow.com/a/287944
def getLatestCommitHash():
"""
Returns the latest git commit hash for the current branch
"""
try:
hash = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"])
return hash.decode("utf-8").strip()
except:
return ""
def getCurrentBranch():
"""
Returns the current git branch
"""
try:
branch = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"])
return branch.decode("utf-8").strip()
except:
return ""
class Keys: class Keys:
SWINGMUSIC_APP_VERSION = os.environ.get("SWINGMUSIC_APP_VERSION") SWINGMUSIC_APP_VERSION = os.environ.get("SWINGMUSIC_APP_VERSION")
GIT_LATEST_COMMIT_HASH = "<unset>"
GIT_CURRENT_BRANCH = "<unset>"
@classmethod @classmethod
def load(cls): def load(cls):
if IS_BUILD: if IS_BUILD:
cls.SWINGMUSIC_APP_VERSION = configs.SWINGMUSIC_APP_VERSION cls.SWINGMUSIC_APP_VERSION = configs.SWINGMUSIC_APP_VERSION
cls.GIT_LATEST_COMMIT_HASH = configs.GIT_LATEST_COMMIT_HASH
cls.GIT_CURRENT_BRANCH = configs.GIT_CURRENT_BRANCH
else:
cls.GIT_LATEST_COMMIT_HASH = getLatestCommitHash()
cls.GIT_CURRENT_BRANCH = getCurrentBranch()
cls.verify_keys() cls.verify_keys()
@classmethod @classmethod
def verify_keys(cls): def verify_keys(cls):
# if not cls.LASTFM_API_KEY:
# print("ERROR: LASTFM_API_KEY not set in environment")
# sys.exit(0)
pass pass
@classmethod @classmethod
+3 -3
View File
@@ -3,7 +3,7 @@ import json
from app.db.sqlite.artistcolors import SQLiteArtistMethods as ardb from app.db.sqlite.artistcolors import SQLiteArtistMethods as ardb
from app.lib.artistlib import get_all_artists from app.lib.artistlib import get_all_artists
from app.models import Artist from app.models import Artist
from app.utils.bisection import UseBisection from app.utils.bisection import use_bisection
from app.utils.customlist import CustomList from app.utils.customlist import CustomList
from app.utils.progressbar import tqdm from app.utils.progressbar import tqdm
@@ -72,7 +72,7 @@ class ArtistStore:
""" """
artists = sorted(cls.artists, key=lambda x: x.artisthash) artists = sorted(cls.artists, key=lambda x: x.artisthash)
try: try:
artist = UseBisection(artists, "artisthash", [artisthash])()[0] artist = use_bisection(artists, "artisthash", [artisthash])[0]
return artist return artist
except IndexError: except IndexError:
return None return None
@@ -83,7 +83,7 @@ class ArtistStore:
Returns artists by their hashes. Returns artists by their hashes.
""" """
artists = sorted(cls.artists, key=lambda x: x.artisthash) artists = sorted(cls.artists, key=lambda x: x.artisthash)
artists = UseBisection(artists, "artisthash", artisthashes)() artists = use_bisection(artists, "artisthash", artisthashes)
return [a for a in artists if a is not None] return [a for a in artists if a is not None]
@classmethod @classmethod
+2 -2
View File
@@ -3,7 +3,7 @@
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.db.sqlite.tracks import SQLiteTrackMethods as tdb from app.db.sqlite.tracks import SQLiteTrackMethods as tdb
from app.models import Track from app.models import Track
from app.utils.bisection import UseBisection from app.utils.bisection import use_bisection
from app.utils.customlist import CustomList from app.utils.customlist import CustomList
from app.utils.remove_duplicates import remove_duplicates from app.utils.remove_duplicates import remove_duplicates
@@ -153,7 +153,7 @@ class TrackStore:
Returns all tracks matching the given paths. Returns all tracks matching the given paths.
""" """
tracks = sorted(cls.tracks, key=lambda x: x.filepath) tracks = sorted(cls.tracks, key=lambda x: x.filepath)
tracks = UseBisection(tracks, "filepath", paths)() tracks = use_bisection(tracks, "filepath", paths)
return [track for track in tracks if track is not None] return [track for track in tracks if track is not None]
@classmethod @classmethod
+16 -23
View File
@@ -1,52 +1,45 @@
from app.models.track import Track from typing import List, Optional, TypeVar
T = TypeVar("T")
class UseBisection: def use_bisection(
source: List[T], key: str, queries: List[str], limit: int = -1
) -> List[Optional[T]]:
""" """
Uses bisection to find a list of items in another list. Uses bisection to find a list of items in another list.
returns a list of found items with `None` items being not found Returns a list of found items with `None` items being not found items.
items.
""" """
def __init__( def find(query: str):
self, source: list, search_from: str, queries: list[str], limit=-1
) -> None:
self.source_list = source
self.queries_list = queries
self.attr = search_from
self.limit = limit
def find(self, query: str):
left = 0 left = 0
right = len(self.source_list) - 1 right = len(source) - 1
while left <= right: while left <= right:
mid = (left + right) // 2 mid = (left + right) // 2
if self.source_list[mid].__getattribute__(self.attr) == query: if source[mid].__getattribute__(key) == query:
return self.source_list[mid] return source[mid]
elif self.source_list[mid].__getattribute__(self.attr) > query: elif source[mid].__getattribute__(key) > query:
right = mid - 1 right = mid - 1
else: else:
left = mid + 1 left = mid + 1
return None return None
def __call__(self): if len(source) == 0:
if len(self.source_list) == 0:
return [] return []
results: list[Track] = [] results = []
for query in self.queries_list: for query in queries:
res = self.find(query) res = find(query)
if res is None: if res is None:
continue continue
results.append(res) results.append(res)
if self.limit != -1 and len(results) >= self.limit: if limit != -1 and len(results) >= limit:
break break
return results return results
+12
View File
@@ -0,0 +1,12 @@
import sys
def getFlaskOpenApiPath():
"""
Used to retrieve the path to the flask_openapi3 package
See: https://github.com/luolingchun/flask-openapi3/issues/147
"""
site_packages_path = [p for p in sys.path if "site-packages" in p][0]
return f"{site_packages_path}/flask_openapi3"
Generated
+129 -32
View File
@@ -12,19 +12,16 @@ files = [
] ]
[[package]] [[package]]
name = "aniso8601" name = "annotated-types"
version = "9.0.1" version = "0.6.0"
description = "A library for parsing ISO 8601 strings." description = "Reusable constraint types to use with typing.Annotated"
optional = false optional = false
python-versions = "*" python-versions = ">=3.8"
files = [ files = [
{file = "aniso8601-9.0.1-py2.py3-none-any.whl", hash = "sha256:1d2b7ef82963909e93c4f24ce48d4de9e66009a21bf1c1e1c85bdd0812fe412f"}, {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"},
{file = "aniso8601-9.0.1.tar.gz", hash = "sha256:72e3117667eedf66951bb2d93f4296a56b94b078a8a95905a052611fb3f1b973"}, {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"},
] ]
[package.extras]
dev = ["black", "coverage", "isort", "pre-commit", "pyenchant", "pylint"]
[[package]] [[package]]
name = "astroid" name = "astroid"
version = "2.15.8" version = "2.15.8"
@@ -561,24 +558,25 @@ Flask = ">=0.9"
Six = "*" Six = "*"
[[package]] [[package]]
name = "flask-restful" name = "flask-openapi3"
version = "0.3.10" version = "3.0.2"
description = "Simple framework for creating REST APIs" description = "Generate REST API and OpenAPI documentation for your Flask project."
optional = false optional = false
python-versions = "*" python-versions = ">=3.8"
files = [ files = [
{file = "Flask-RESTful-0.3.10.tar.gz", hash = "sha256:fe4af2ef0027df8f9b4f797aba20c5566801b6ade995ac63b588abf1a59cec37"}, {file = "flask_openapi3-3.0.2-py3-none-any.whl", hash = "sha256:e39359bf07da47d9abf30d4b370feaac4035bd805f03518ce65016958796c636"},
{file = "Flask_RESTful-0.3.10-py2.py3-none-any.whl", hash = "sha256:1cf93c535172f112e080b0d4503a8d15f93a48c88bdd36dd87269bdaf405051b"}, {file = "flask_openapi3-3.0.2.tar.gz", hash = "sha256:92e6a308c5b13692ad8aea04ee951ffbfabc6853b20bd3b678c2569dcb781916"},
] ]
[package.dependencies] [package.dependencies]
aniso8601 = ">=0.82" flask = ">=2.0"
Flask = ">=0.8" pydantic = ">=2.4"
pytz = "*"
six = ">=1.3.0"
[package.extras] [package.extras]
docs = ["sphinx"] async = ["asgiref (>=3.2)"]
dotenv = ["python-dotenv"]
email = ["email-validator"]
yaml = ["pyyaml"]
[[package]] [[package]]
name = "gevent" name = "gevent"
@@ -1447,6 +1445,116 @@ files = [
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
] ]
[[package]]
name = "pydantic"
version = "2.6.3"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic-2.6.3-py3-none-any.whl", hash = "sha256:72c6034df47f46ccdf81869fddb81aade68056003900a8724a4f160700016a2a"},
{file = "pydantic-2.6.3.tar.gz", hash = "sha256:e07805c4c7f5c6826e33a1d4c9d47950d7eaf34868e2690f8594d2e30241f11f"},
]
[package.dependencies]
annotated-types = ">=0.4.0"
pydantic-core = "2.16.3"
typing-extensions = ">=4.6.1"
[package.extras]
email = ["email-validator (>=2.0.0)"]
[[package]]
name = "pydantic-core"
version = "2.16.3"
description = ""
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic_core-2.16.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:75b81e678d1c1ede0785c7f46690621e4c6e63ccd9192af1f0bd9d504bbb6bf4"},
{file = "pydantic_core-2.16.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9c865a7ee6f93783bd5d781af5a4c43dadc37053a5b42f7d18dc019f8c9d2bd1"},
{file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:162e498303d2b1c036b957a1278fa0899d02b2842f1ff901b6395104c5554a45"},
{file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2f583bd01bbfbff4eaee0868e6fc607efdfcc2b03c1c766b06a707abbc856187"},
{file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b926dd38db1519ed3043a4de50214e0d600d404099c3392f098a7f9d75029ff8"},
{file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:716b542728d4c742353448765aa7cdaa519a7b82f9564130e2b3f6766018c9ec"},
{file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ad7f7ee1a13d9cb49d8198cd7d7e3aa93e425f371a68235f784e99741561f"},
{file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bd87f48924f360e5d1c5f770d6155ce0e7d83f7b4e10c2f9ec001c73cf475c99"},
{file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0df446663464884297c793874573549229f9eca73b59360878f382a0fc085979"},
{file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4df8a199d9f6afc5ae9a65f8f95ee52cae389a8c6b20163762bde0426275b7db"},
{file = "pydantic_core-2.16.3-cp310-none-win32.whl", hash = "sha256:456855f57b413f077dff513a5a28ed838dbbb15082ba00f80750377eed23d132"},
{file = "pydantic_core-2.16.3-cp310-none-win_amd64.whl", hash = "sha256:732da3243e1b8d3eab8c6ae23ae6a58548849d2e4a4e03a1924c8ddf71a387cb"},
{file = "pydantic_core-2.16.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:519ae0312616026bf4cedc0fe459e982734f3ca82ee8c7246c19b650b60a5ee4"},
{file = "pydantic_core-2.16.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b3992a322a5617ded0a9f23fd06dbc1e4bd7cf39bc4ccf344b10f80af58beacd"},
{file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d62da299c6ecb04df729e4b5c52dc0d53f4f8430b4492b93aa8de1f541c4aac"},
{file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2acca2be4bb2f2147ada8cac612f8a98fc09f41c89f87add7256ad27332c2fda"},
{file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b662180108c55dfbf1280d865b2d116633d436cfc0bba82323554873967b340"},
{file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7c6ed0dc9d8e65f24f5824291550139fe6f37fac03788d4580da0d33bc00c97"},
{file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1bb0827f56654b4437955555dc3aeeebeddc47c2d7ed575477f082622c49e"},
{file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e56f8186d6210ac7ece503193ec84104da7ceb98f68ce18c07282fcc2452e76f"},
{file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:936e5db01dd49476fa8f4383c259b8b1303d5dd5fb34c97de194560698cc2c5e"},
{file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:33809aebac276089b78db106ee692bdc9044710e26f24a9a2eaa35a0f9fa70ba"},
{file = "pydantic_core-2.16.3-cp311-none-win32.whl", hash = "sha256:ded1c35f15c9dea16ead9bffcde9bb5c7c031bff076355dc58dcb1cb436c4721"},
{file = "pydantic_core-2.16.3-cp311-none-win_amd64.whl", hash = "sha256:d89ca19cdd0dd5f31606a9329e309d4fcbb3df860960acec32630297d61820df"},
{file = "pydantic_core-2.16.3-cp311-none-win_arm64.whl", hash = "sha256:6162f8d2dc27ba21027f261e4fa26f8bcb3cf9784b7f9499466a311ac284b5b9"},
{file = "pydantic_core-2.16.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f56ae86b60ea987ae8bcd6654a887238fd53d1384f9b222ac457070b7ac4cff"},
{file = "pydantic_core-2.16.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9bd22a2a639e26171068f8ebb5400ce2c1bc7d17959f60a3b753ae13c632975"},
{file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4204e773b4b408062960e65468d5346bdfe139247ee5f1ca2a378983e11388a2"},
{file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f651dd19363c632f4abe3480a7c87a9773be27cfe1341aef06e8759599454120"},
{file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aaf09e615a0bf98d406657e0008e4a8701b11481840be7d31755dc9f97c44053"},
{file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e47755d8152c1ab5b55928ab422a76e2e7b22b5ed8e90a7d584268dd49e9c6b"},
{file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:500960cb3a0543a724a81ba859da816e8cf01b0e6aaeedf2c3775d12ee49cade"},
{file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf6204fe865da605285c34cf1172879d0314ff267b1c35ff59de7154f35fdc2e"},
{file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d33dd21f572545649f90c38c227cc8631268ba25c460b5569abebdd0ec5974ca"},
{file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:49d5d58abd4b83fb8ce763be7794d09b2f50f10aa65c0f0c1696c677edeb7cbf"},
{file = "pydantic_core-2.16.3-cp312-none-win32.whl", hash = "sha256:f53aace168a2a10582e570b7736cc5bef12cae9cf21775e3eafac597e8551fbe"},
{file = "pydantic_core-2.16.3-cp312-none-win_amd64.whl", hash = "sha256:0d32576b1de5a30d9a97f300cc6a3f4694c428d956adbc7e6e2f9cad279e45ed"},
{file = "pydantic_core-2.16.3-cp312-none-win_arm64.whl", hash = "sha256:ec08be75bb268473677edb83ba71e7e74b43c008e4a7b1907c6d57e940bf34b6"},
{file = "pydantic_core-2.16.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:b1f6f5938d63c6139860f044e2538baeee6f0b251a1816e7adb6cbce106a1f01"},
{file = "pydantic_core-2.16.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2a1ef6a36fdbf71538142ed604ad19b82f67b05749512e47f247a6ddd06afdc7"},
{file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:704d35ecc7e9c31d48926150afada60401c55efa3b46cd1ded5a01bdffaf1d48"},
{file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d937653a696465677ed583124b94a4b2d79f5e30b2c46115a68e482c6a591c8a"},
{file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9803edf8e29bd825f43481f19c37f50d2b01899448273b3a7758441b512acf8"},
{file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72282ad4892a9fb2da25defeac8c2e84352c108705c972db82ab121d15f14e6d"},
{file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f752826b5b8361193df55afcdf8ca6a57d0232653494ba473630a83ba50d8c9"},
{file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4384a8f68ddb31a0b0c3deae88765f5868a1b9148939c3f4121233314ad5532c"},
{file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a4b2bf78342c40b3dc830880106f54328928ff03e357935ad26c7128bbd66ce8"},
{file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:13dcc4802961b5f843a9385fc821a0b0135e8c07fc3d9949fd49627c1a5e6ae5"},
{file = "pydantic_core-2.16.3-cp38-none-win32.whl", hash = "sha256:e3e70c94a0c3841e6aa831edab1619ad5c511199be94d0c11ba75fe06efe107a"},
{file = "pydantic_core-2.16.3-cp38-none-win_amd64.whl", hash = "sha256:ecdf6bf5f578615f2e985a5e1f6572e23aa632c4bd1dc67f8f406d445ac115ed"},
{file = "pydantic_core-2.16.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bda1ee3e08252b8d41fa5537413ffdddd58fa73107171a126d3b9ff001b9b820"},
{file = "pydantic_core-2.16.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:21b888c973e4f26b7a96491c0965a8a312e13be108022ee510248fe379a5fa23"},
{file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be0ec334369316fa73448cc8c982c01e5d2a81c95969d58b8f6e272884df0074"},
{file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5b6079cc452a7c53dd378c6f881ac528246b3ac9aae0f8eef98498a75657805"},
{file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ee8d5f878dccb6d499ba4d30d757111847b6849ae07acdd1205fffa1fc1253c"},
{file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7233d65d9d651242a68801159763d09e9ec96e8a158dbf118dc090cd77a104c9"},
{file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6119dc90483a5cb50a1306adb8d52c66e447da88ea44f323e0ae1a5fcb14256"},
{file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:578114bc803a4c1ff9946d977c221e4376620a46cf78da267d946397dc9514a8"},
{file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d8f99b147ff3fcf6b3cc60cb0c39ea443884d5559a30b1481e92495f2310ff2b"},
{file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4ac6b4ce1e7283d715c4b729d8f9dab9627586dafce81d9eaa009dd7f25dd972"},
{file = "pydantic_core-2.16.3-cp39-none-win32.whl", hash = "sha256:e7774b570e61cb998490c5235740d475413a1f6de823169b4cf94e2fe9e9f6b2"},
{file = "pydantic_core-2.16.3-cp39-none-win_amd64.whl", hash = "sha256:9091632a25b8b87b9a605ec0e61f241c456e9248bfdcf7abdf344fdb169c81cf"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:36fa178aacbc277bc6b62a2c3da95226520da4f4e9e206fdf076484363895d2c"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:dcca5d2bf65c6fb591fff92da03f94cd4f315972f97c21975398bd4bd046854a"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a72fb9963cba4cd5793854fd12f4cfee731e86df140f59ff52a49b3552db241"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60cc1a081f80a2105a59385b92d82278b15d80ebb3adb200542ae165cd7d183"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cbcc558401de90a746d02ef330c528f2e668c83350f045833543cd57ecead1ad"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:fee427241c2d9fb7192b658190f9f5fd6dfe41e02f3c1489d2ec1e6a5ab1e04a"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f4cb85f693044e0f71f394ff76c98ddc1bc0953e48c061725e540396d5c8a2e1"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b29eeb887aa931c2fcef5aa515d9d176d25006794610c264ddc114c053bf96fe"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a425479ee40ff021f8216c9d07a6a3b54b31c8267c6e17aa88b70d7ebd0e5e5b"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5c5cbc703168d1b7a838668998308018a2718c2130595e8e190220238addc96f"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b6add4c0b39a513d323d3b93bc173dac663c27b99860dd5bf491b240d26137"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f76ee558751746d6a38f89d60b6228fa174e5172d143886af0f85aa306fd89"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00ee1c97b5364b84cb0bd82e9bbf645d5e2871fb8c58059d158412fee2d33d8a"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:287073c66748f624be4cef893ef9174e3eb88fe0b8a78dc22e88eca4bc357ca6"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ed25e1835c00a332cb10c683cd39da96a719ab1dfc08427d476bce41b92531fc"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:86b3d0033580bd6bbe07590152007275bd7af95f98eaa5bd36f3da219dcd93da"},
{file = "pydantic_core-2.16.3.tar.gz", hash = "sha256:1cac689f80a3abab2d3c0048b29eea5751114054f032a941a32de4c852c59cad"},
]
[package.dependencies]
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]] [[package]]
name = "pyinstaller" name = "pyinstaller"
version = "5.13.2" version = "5.13.2"
@@ -1555,17 +1663,6 @@ files = [
[package.dependencies] [package.dependencies]
six = ">=1.5" six = ">=1.5"
[[package]]
name = "pytz"
version = "2023.3.post1"
description = "World timezone definitions, modern and historical"
optional = false
python-versions = "*"
files = [
{file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"},
{file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"},
]
[[package]] [[package]]
name = "pywin32" name = "pywin32"
version = "306" version = "306"
@@ -2371,4 +2468,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.10,<3.12" python-versions = ">=3.10,<3.12"
content-hash = "a4c52a13b0952ab8848381800032d35994c17d39ad6f884f509c7f6d848a5ce9" content-hash = "feb13f92b7b3a909fcb851860a405b96579feac0e2dde7681ed0e9c381c4f6cd"
+1 -1
View File
@@ -20,11 +20,11 @@ show-in-file-manager = "^1.1.4"
flask-compress = "^1.13" flask-compress = "^1.13"
tabulate = "^0.9.0" tabulate = "^0.9.0"
setproctitle = "^1.3.2" setproctitle = "^1.3.2"
flask-restful = "^0.3.10"
locust = "^2.20.1" locust = "^2.20.1"
waitress = "^2.1.2" waitress = "^2.1.2"
watchdog = "^4.0.0" watchdog = "^4.0.0"
pendulum = "^3.0.0" pendulum = "^3.0.0"
flask-openapi3 = "^3.0.2"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pylint = "^2.15.5" pylint = "^2.15.5"
+70
View File
@@ -0,0 +1,70 @@
altgraph==0.17.4
annotated-types==0.6.0
astroid==2.15.8
attrs==23.1.0
black==22.12.0
blinker==1.6.3
Brotli==1.1.0
certifi==2023.7.22
charset-normalizer==3.3.0
click==8.1.7
colorgram.py==1.2.0
ConfigArgParse==1.7
dill==0.3.7
Flask==2.3.3
Flask-BasicAuth==0.2.0
Flask-Compress==1.14
Flask-Cors==3.0.10
flask-openapi3==3.0.2
gevent==23.9.1
geventhttpclient==2.0.11
greenlet==3.0.3
hypothesis==6.87.3
idna==3.4
iniconfig==2.0.0
isort==5.12.0
itsdangerous==2.1.2
Jinja2==3.1.2
lazy-object-proxy==1.9.0
locust==2.20.1
MarkupSafe==2.1.3
mccabe==0.7.0
msgpack==1.0.7
mypy-extensions==1.0.0
packaging==23.2
pathspec==0.11.2
pendulum==3.0.0
Pillow==9.5.0
platformdirs==3.11.0
pluggy==1.3.0
psutil==5.9.5
pydantic==2.6.3
pydantic_core==2.16.3
pyinstaller==5.13.2
pyinstaller-hooks-contrib==2023.9
pylint==2.17.7
pytest==7.4.2
python-dateutil==2.8.2
pyxdg==0.28
pyzmq==25.1.2
rapidfuzz==2.15.2
requests==2.31.0
roundrobin==0.0.4
setproctitle==1.3.3
show-in-file-manager==1.1.4
six==1.16.0
sortedcontainers==2.4.0
tabulate==0.9.0
tinytag==1.9.0
tomlkit==0.12.1
tqdm==4.66.1
typing_extensions==4.8.0
tzdata==2024.1
Unidecode==1.3.7
urllib3==2.0.6
waitress==2.1.2
watchdog==4.0.0
Werkzeug==3.0.0
wrapt==1.15.0
zope.event==5.0
zope.interface==6.1