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?
- New opt-in alternate (no sidebar) layout
- Added search bar to the top bar (all layouts)
- 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
- Hovering on recent favorite item will show how long ago it was ♥ed
- Recently added playlist returns a max of 100 tracks, but without a cutoff period
# Development
- 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
- API documentation on /openapi
+3 -1
View File
@@ -79,7 +79,9 @@ yarn dev
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?
+19 -5
View File
@@ -62,15 +62,29 @@ jobs:
uses: actions/setup-python@v2
with:
python-version: "3.10.11"
- name: Install Poetry
- name: Create virtualenv
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
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
run: |
python -m poetry run python manage.py --build
python manage.py --build
env:
SWINGMUSIC_APP_VERSION: ${{ inputs.tag }}
- name: Verify Linux build success
@@ -169,6 +183,6 @@ jobs:
tags: ghcr.io/${{github.repository}}:${{format('v{0}', inputs.tag)}}, ${{env.LATEST_TAG}}
labels: org.opencontainers.image.title=Docker
build-args: |
client_tag=v${{inputs.tag}}
app_version=${{inputs.tag}}
env:
LATEST_TAG: ${{ inputs.is_latest == 'true' && format('ghcr.io/{0}:latest', github.repository) || '' }}
+1 -1
View File
@@ -1,7 +1,7 @@
# local env files
.env.local
.env.*.local
venv
# Editor directories and files
.idea
+10 -7
View File
@@ -28,12 +28,15 @@ VOLUME /music
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 pip install poetry
RUN poetry config virtualenvs.create false
RUN poetry install
ENTRYPOINT ["poetry", "run", "python", "manage.py", "--host", "0.0.0.0", "--config", "/config"]
# ENTRYPOINT ["poetry", "run", "python", "manage.py", "--host", "0.0.0.0", "--config", "/config"]
ENTRYPOINT ["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.
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
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.
[Contributing Guidelines](.github/contributing.md).
[**CONTRIBUTING GUIDELINES**](.github/contributing.md).
### License
+45 -19
View File
@@ -2,10 +2,14 @@
This module combines all API blueprints into a single Flask app instance.
"""
from flask import Flask
from flask_compress import Compress
import datetime
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 app.api import (
album,
@@ -25,12 +29,34 @@ from app.api import (
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():
"""
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="*")
Compress(app)
@@ -39,29 +65,29 @@ def create_api():
]
with app.app_context():
app.register_blueprint(album.api)
app.register_blueprint(artist.api)
app.register_blueprint(send_file.api)
app.register_blueprint(search.api)
app.register_blueprint(folder.api)
app.register_blueprint(playlist.api)
app.register_blueprint(favorites.api)
app.register_blueprint(imgserver.api)
app.register_blueprint(settings.api)
app.register_blueprint(colors.api)
app.register_blueprint(lyrics.api)
app.register_api(album.api)
app.register_api(artist.api)
app.register_api(send_file.api)
app.register_api(search.api)
app.register_api(folder.api)
app.register_api(playlist.api)
app.register_api(favorites.api)
app.register_api(imgserver.api)
app.register_api(settings.api)
app.register_api(colors.api)
app.register_api(lyrics.api)
# Plugins
app.register_blueprint(plugins.api)
app.register_blueprint(lyrics_plugin.api)
app.register_api(plugins.api)
app.register_api(lyrics_plugin.api)
# Logger
app.register_blueprint(logger.api_bp)
app.register_api(logger.api)
# Home
app.register_blueprint(home.api_bp)
app.register_api(home.api)
# Flask Restful
app.register_blueprint(getall.api_bp)
app.register_api(getall.api)
return app
+79 -62
View File
@@ -4,41 +4,39 @@ Contains all the album routes.
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.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.settings import Defaults
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.tracks import TrackStore
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
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"])
def get_album_tracks_and_info():
# NOTE: Don't use "/" as it will cause redirects (failure)
@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.
"""
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
albumhash = body.albumhash
error_msg = {"error": "Album not created yet."}
album = AlbumStore.get_album_by_hash(albumhash)
@@ -82,27 +80,43 @@ def get_album_tracks_and_info():
}
@api.route("/album/<albumhash>/tracks", methods=["GET"])
def get_album_tracks(albumhash: str):
@api.get("/<albumhash>/tracks")
def get_album_tracks(path: AlbumHashSchema):
"""
Get album tracks
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)
return {"tracks": tracks}
return tracks
@api.route("/album/from-artist", methods=["POST"])
def get_artist_albums():
data = request.get_json()
class GetMoreFromArtistsBody(AlbumLimitSchema):
albumartists: str = Field(
description="The artist hashes to get more albums from",
example=Defaults.API_ARTISTHASH,
)
if data is None:
return {"msg": "No albumartist provided"}
base_title: str = Field(
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")
base_title: str = data.get("base_title")
@api.post("/from-artist")
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(",")
@@ -125,23 +139,30 @@ def get_artist_albums():
if len(a["albums"]) > 0
]
return {"data": albums}
return albums
@api.route("/album/versions", methods=["POST"])
def get_album_versions():
class GetAlbumVersionsBody(ArtistHashSchema):
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.
"""
data = request.get_json()
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"]
og_album_title = body.og_album_title
base_title = body.base_title
artisthash = body.artisthash
albums = AlbumStore.get_albums_by_artisthash(artisthash)
@@ -156,26 +177,22 @@ def get_album_versions():
tracks = TrackStore.get_tracks_by_albumhash(a.albumhash)
a.get_date_from_tracks(tracks)
return {"data": albums}
return albums
@api.route("/album/similar", methods=["GET"])
def get_similar_albums():
class GetSimilarAlbumsQuery(ArtistHashSchema, AlbumLimitSchema):
pass
@api.get("/similar")
def get_similar_albums(query: GetSimilarAlbumsQuery):
"""
Get similar albums
Returns similar albums to the given album.
"""
data = request.args
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)
artisthash = query.artisthash
limit = query.limit
similar_artists = lastfmdb.get_similar_artists_for(artisthash)
@@ -197,4 +214,4 @@ def get_similar_albums():
except ValueError:
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.
"""
import math
import random
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.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.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"])
def get_artist(artisthash: str):
@api.get("/<string:artisthash>")
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")
if limit is None:
limit = 6
limit = int(limit)
artisthash = path.artisthash
limit = query.limit
artist = ArtistStore.get_artist_by_hash(artisthash)
@@ -79,19 +81,23 @@ def get_artist(artisthash: str):
}
@api.route("/artist/<artisthash>/albums", methods=["GET"])
def get_artist_albums(artisthash: str):
limit = request.args.get("limit")
class GetArtistAlbumsQuery(AlbumLimitSchema):
all: bool = Field(
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)
# start: check for missing albums. ie. compilations and features
all_tracks = TrackStore.get_tracks_by_artisthash(artisthash)
@@ -156,7 +162,7 @@ def get_artist_albums(artisthash: str):
if artist is None:
return {"error": "Artist not found"}, 404
if return_all is not None and return_all == "true":
if return_all:
limit = len(all_albums)
singles_and_eps = singles + eps
@@ -170,29 +176,26 @@ def get_artist_albums(artisthash: str):
}
@api.route("/artist/<artisthash>/tracks", methods=["GET"])
def get_all_artist_tracks(artisthash: str):
@api.get("/<artisthash>/tracks")
def get_all_artist_tracks(path: ArtistHashSchema):
"""
Get artist tracks
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"])
def get_similar_artists(artisthash: str):
@api.get("/<artisthash>/similar")
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:
limit = 6
limit = int(limit)
artist = ArtistStore.get_artist_by_hash(artisthash)
artist = ArtistStore.get_artist_by_hash(path.artisthash)
if artist is None:
return {"error": "Artist not found"}, 404
@@ -207,7 +210,7 @@ def get_similar_artists(artisthash: str):
if len(similar) > limit:
similar = random.sample(similar, limit)
return {"artists": similar[:limit]}
return similar[:limit]
# 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
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>")
def get_album_color(albumhash: str):
album = Store.get_album_by_hash(albumhash)
@api.get("/album/<albumhash>")
def get_album_color(path: AlbumHashSchema):
"""
Get album color
"""
album = Store.get_album_by_hash(path.albumhash)
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.serializers.album import serialize_for_card, serialize_for_card_many
from app.serializers.artist import serialize_for_card as serialize_artist
from app.settings import Defaults
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.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.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]
@api.route("/favorite/add", methods=["POST"])
def add_favorite():
class FavoritesAddBody(BaseModel):
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.
"""
data = request.get_json()
if data is None:
return {"error": "No data provided"}, 400
itemhash = data.get("hash")
itemtype = data.get("type")
itemhash = body.hash
itemtype = body.type
favdb.insert_one_favorite(itemtype, itemhash)
@@ -40,18 +55,13 @@ def add_favorite():
return {"msg": "Added to favorites"}
@api.route("/favorite/remove", methods=["POST"])
def remove_favorite():
@api.post("/remove")
def remove_favorite(body: FavoritesAddBody):
"""
Removes a favorite from the database.
"""
data = request.get_json()
if data is None:
return {"error": "No data provided"}, 400
itemhash = data.get("hash")
itemtype = data.get("type")
itemhash = body.hash
itemtype = body.type
favdb.delete_favorite(itemtype, itemhash)
@@ -61,22 +71,19 @@ def remove_favorite():
return {"msg": "Removed from favorites"}
@api.route("/albums/favorite")
def get_favorite_albums():
limit = request.args.get("limit")
if limit is None:
limit = 6
limit = int(limit)
@api.get("/albums")
def get_favorite_albums(query: GenericLimitSchema):
"""
Get favorite albums
"""
limit = query.limit
albums = favdb.get_fav_albums()
albumhashes = [a[1] for a in albums]
albumhashes.reverse()
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)
if limit == 0:
@@ -85,21 +92,18 @@ def get_favorite_albums():
return {"albums": serialize_for_card_many(fav_albums[:limit])}
@api.route("/tracks/favorite")
def get_favorite_tracks():
limit = request.args.get("limit")
if limit is None:
limit = 6
limit = int(limit)
@api.get("/tracks")
def get_favorite_tracks(query: GenericLimitSchema):
"""
Get favorite tracks
"""
limit = query.limit
tracks = favdb.get_fav_tracks()
trackhashes = [t[1] for t in tracks]
trackhashes.reverse()
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)
if limit == 0:
@@ -108,22 +112,19 @@ def get_favorite_tracks():
return {"tracks": serialize_tracks(tracks[:limit])}
@api.route("/artists/favorite")
def get_favorite_artists():
limit = request.args.get("limit")
if limit is None:
limit = 6
limit = int(limit)
@api.get("/artists")
def get_favorite_artists(query: GenericLimitSchema):
"""
Get favorite artists
"""
limit = query.limit
artists = favdb.get_fav_artists()
artisthashes = [a[1] for a in artists]
artisthashes.reverse()
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)
if limit == 0:
@@ -132,27 +133,38 @@ def get_favorite_artists():
return {"artists": artists[:limit]}
@api.route("/favorites")
def get_all_favorites():
class GetAllFavoritesQuery(BaseModel):
"""
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.
"""
track_limit = request.args.get("track_limit")
album_limit = request.args.get("album_limit")
artist_limit = request.args.get("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)
track_limit = query.track_limit
album_limit = query.album_limit
artist_limit = query.artist_limit
# largest is x2 to accound for broken hashes if any
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)
for fav in favs:
# INFO: hash is [1], type is [2], timestamp is [3]
hash = fav[1]
if fav[2] == FavType.track:
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_artists = sorted(ArtistStore.artists, key=lambda x: x.artisthash)
tracks = UseBisection(src_tracks, "trackhash", tracks, limit=track_limit)()
albums = UseBisection(src_albums, "albumhash", albums, limit=album_limit)()
artists = UseBisection(src_artists, "artisthash", artists, limit=artist_limit)()
tracks = use_bisection(src_tracks, "trackhash", tracks, limit=track_limit)
albums = use_bisection(src_albums, "albumhash", albums, limit=album_limit)
artists = use_bisection(src_artists, "artisthash", artists, limit=artist_limit)
tracks = remove_none(tracks)
albums = remove_none(albums)
@@ -201,6 +214,7 @@ def get_all_favorites():
# first_n = favs
for fav in favs:
# INFO: hash is [1], type is [2], timestamp is [3]
if len(recents) >= largest:
break
@@ -212,6 +226,7 @@ def get_all_favorites():
album = serialize_for_card(album)
album["help_text"] = "album"
album["time"] = timestamp_to_time_passed(fav[3])
recents.append(
{
@@ -228,6 +243,7 @@ def get_all_favorites():
artist = serialize_artist(artist)
artist["help_text"] = "artist"
artist["time"] = timestamp_to_time_passed(fav[3])
recents.append(
{
@@ -244,6 +260,7 @@ def get_all_favorites():
track = serialize_track(track)
track["help_text"] = "track"
track["time"] = timestamp_to_time_passed(fav[3])
recents.append({"type": "track", "item": track})
@@ -256,20 +273,13 @@ def get_all_favorites():
}
@api.route("/favorites/check")
def check_favorite():
@api.get("/check")
def check_favorite(query: FavoritesAddBody):
"""
Checks if a favorite exists in the database.
"""
itemhash = request.args.get("hash")
itemtype = request.args.get("type")
if itemhash is None:
return {"error": "No hash provided"}, 400
if itemtype is None:
return {"error": "No type provided"}, 400
itemhash = query.hash
itemtype = query.type
exists = favdb.check_is_favorite(itemhash, itemtype)
return {"is_favorite": exists}
+62 -37
View File
@@ -1,11 +1,14 @@
"""
Contains all the folder routes.
"""
import os
from pathlib import Path
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 app import settings
@@ -15,24 +18,24 @@ from app.serializers.track import serialize_track
from app.store.tracks import TrackStore as store
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"])
def get_folder_tree():
class FolderTree(BaseModel):
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.
"""
data = request.get_json()
req_dir = "$home"
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"
req_dir = body.folder
tracks_only = body.tracks_only
root_dirs = db.get_root_dirs()
root_dirs.sort()
@@ -44,6 +47,9 @@ def get_folder_tree():
pass
if req_dir == "$home":
if len(root_dirs) == 1:
req_dir = root_dirs[0]
else:
folders = get_folders(root_dirs)
return {
@@ -92,18 +98,23 @@ def get_all_drives(is_win: bool = False):
return drives
@api.route("/folder/dir-browser", methods=["POST"])
def list_folders():
"""
Returns a list of all the folders in the given folder.
"""
data = request.get_json()
is_win = is_windows()
class DirBrowserBody(BaseModel):
folder: str = Field(
"$root",
description="The folder to list directories from",
)
try:
req_dir: str = data["folder"]
except KeyError:
req_dir = "$root"
@api.post("/dir-browser")
def list_folders(body: DirBrowserBody):
"""
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":
return {
@@ -131,26 +142,40 @@ def list_folders():
}
@api.route("/folder/show-in-files")
def open_in_file_manager():
path = request.args.get("path")
class FolderOpenInFileManagerQuery(BaseModel):
path: str = Field(
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}
@api.route("/folder/tracks/all")
def get_tracks_in_path():
path = request.args.get("path")
class GetTracksInPathQuery(BaseModel):
path: str = Field(
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 = (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_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")
api = Api(api_bp)
from datetime import datetime
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_restful import Api
from flask_openapi3 import Tag
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")
api = Api(api_bp)
bp_tag = Tag(name="Home", description="Homepage items")
api = APIBlueprint("home", __name__, url_prefix="/home", abp_tags=[bp_tag])
api.add_resource(RecentlyAdded, "/recents/added")
api.add_resource(RecentlyPlayed, "/recents/played")
@api.get("/recents/added")
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 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
api = Blueprint("imgserver", __name__, url_prefix="/img")
@api.route("/")
def hello():
return "<h1>Image Server</h1>"
bp_tag = Tag(
name="Images", description="Image filenames are constructured as '{itemhash}.webp'"
)
api = APIBlueprint("imgserver", __name__, url_prefix="/img", abp_tags=[bp_tag])
def send_fallback_img(filename: str = "default.webp"):
path = Paths.get_assets_path()
img = Path(path) / filename
folder = Paths.get_assets_path()
img = Path(folder) / filename
if not img.exists():
return "", 404
return send_from_directory(path, filename)
return send_from_directory(folder, filename)
@api.route("/t/o/<imgpath>")
def send_original_thumbnail(imgpath: str):
path = Paths.get_original_thumb_path()
fpath = Path(path) / imgpath
class ImagePath(BaseModel):
imgpath: str = Field(
description="The image filename",
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():
return send_from_directory(path, imgpath)
return send_from_directory(folder, path.imgpath)
return send_fallback_img()
@api.route("/t/<imgpath>")
def send_lg_thumbnail(imgpath: str):
path = Paths.get_lg_thumb_path()
fpath = Path(path) / imgpath
@api.get("/t/<imgpath>")
def send_lg_thumbnail(path: ImagePath):
"""
Get large thumbnail (500 x 500)
"""
folder = Paths.get_lg_thumb_path()
fpath = Path(folder) / path.imgpath
if fpath.exists():
return send_from_directory(path, imgpath)
return send_from_directory(folder, path.imgpath)
return send_fallback_img()
@api.route("/t/s/<imgpath>")
def send_sm_thumbnail(imgpath: str):
path = Paths.get_sm_thumb_path()
fpath = Path(path) / imgpath
@api.get("/t/s/<imgpath>")
def send_sm_thumbnail(path: ImagePath):
"""
Get small thumbnail (64 x 64)
"""
folder = Paths.get_sm_thumb_path()
fpath = Path(folder) / path.imgpath
if fpath.exists():
return send_from_directory(path, imgpath)
return send_from_directory(folder, path.imgpath)
return send_fallback_img()
@api.route("/a/<imgpath>")
def send_lg_artist_image(imgpath: str):
path = Paths.get_artist_img_lg_path()
fpath = Path(path) / imgpath
@api.get("/a/<imgpath>")
def send_lg_artist_image(path: ImagePath):
"""
Get large artist image (500 x 500)
"""
folder = Paths.get_artist_img_lg_path()
fpath = Path(folder) / path.imgpath
if fpath.exists():
return send_from_directory(path, imgpath)
return send_from_directory(folder, path.imgpath)
return send_fallback_img("artist.webp")
@api.route("/a/s/<imgpath>")
def send_sm_artist_image(imgpath: str):
path = Paths.get_artist_img_sm_path()
fpath = Path(path) / imgpath
@api.get("/a/s/<imgpath>")
def send_sm_artist_image(path: ImagePath):
"""
Get small artist image (64 x 64)
"""
folder = Paths.get_artist_img_sm_path()
fpath = Path(folder) / path.imgpath
if fpath.exists():
return send_from_directory(path, imgpath)
return send_from_directory(folder, path.imgpath)
return send_fallback_img("artist.webp")
@api.route("/p/<imgpath>")
def send_playlist_image(imgpath: str):
path = Paths.get_playlist_img_path()
fpath = Path(path) / imgpath
class PlaylistImagePath(BaseModel):
imgpath: str = Field(
description="The image path",
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():
return send_from_directory(path, imgpath)
return send_from_directory(folder, path.imgpath)
return send_fallback_img("playlist.svg")
+33 -6
View File
@@ -1,11 +1,38 @@
from flask import Blueprint
from flask_restful import Api
from flask_openapi3 import Tag
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")
api = Api(api_bp)
class LogTrackBody(TrackHashSchema):
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 (
get_lyrics,
check_lyrics_file,
@@ -7,21 +10,24 @@ from app.lib.lyrics import (
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"])
def send_lyrics():
class SendLyricsBody(TrackHashSchema):
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
"""
data = request.get_json()
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
filepath = body.filepath
trackhash = body.trackhash
is_synced = True
lyrics, copyright = get_lyrics(filepath)
@@ -38,15 +44,13 @@ def send_lyrics():
return {"lyrics": lyrics, "synced": is_synced, "copyright": copyright}, 200
@api.route("/lyrics/check", methods=["POST"])
def check_lyrics():
data = request.get_json()
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
@api.post("/check")
def check_lyrics(body: SendLyricsBody):
"""
Checks if lyrics exist for a track
"""
filepath = body.filepath
trackhash = body.trackhash
exists = check_lyrics_file(filepath, trackhash)
@@ -56,4 +60,3 @@ def check_lyrics():
exists = get_lyrics_from_tags(filepath, just_check=True)
return {"exists": exists}, 200
+117 -110
View File
@@ -1,12 +1,15 @@
"""
All playlist-related routes.
"""
import json
from datetime import datetime
import pathlib
from flask import Blueprint, request
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.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.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
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.
"""
no_images = request.args.get("no_images", False)
playlists = PL.get_all_playlists()
playlists = list(playlists)
for playlist in playlists:
if not no_images:
if not query.no_images:
playlist.images = playlistlib.get_first_4_images(
trackhashes=playlist.trackhashes
)
@@ -69,22 +74,23 @@ def insert_playlist(name: str, image: str = None):
return PL.insert_one_playlist(playlist)
@api.route("/playlist/new", methods=["POST"])
def create_playlist():
class CreatePlaylistBody(BaseModel):
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.
"""
data = request.get_json()
if data is None:
return {"error": "Playlist name not provided"}, 400
existing_playlist_count = PL.count_playlist_by_name(data["name"])
existing_playlist_count = PL.count_playlist_by_name(body.name)
if existing_playlist_count > 0:
return {"error": "Playlist already exists"}, 409
playlist = insert_playlist(data["name"])
playlist = insert_playlist(body.name)
if playlist is None:
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]
@api.route("/playlist/<playlist_id>/add", methods=["POST"])
def add_item_to_playlist(playlist_id: str):
class PlaylistIDPath(BaseModel):
# 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()
if data is None:
return {"error": "Track hash not provided"}, 400
try:
itemtype = data["itemtype"]
except KeyError:
itemtype = None
try:
itemhash: str = data["itemhash"]
except KeyError:
itemhash = None
itemtype = body.itemtype
itemhash = body.itemhash
playlist_id = path.playlistid
if itemtype == "tracks":
trackhashes = itemhash.split(",")
@@ -158,13 +169,17 @@ def add_item_to_playlist(playlist_id: str):
return {"msg": "Done"}, 200
@api.route("/playlist/<playlistid>")
def get_playlist(playlistid: str):
class GetPlaylistQuery(BaseModel):
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 = no_tracks == "true"
no_tracks = query.no_tracks
playlistid = path.playlistid
is_recently_added = playlistid == "recentlyadded"
@@ -201,31 +216,40 @@ def get_playlist(playlistid: str):
return {"info": playlist, "tracks": tracks if not no_tracks else []}
@api.route("/playlist/<playlistid>/update", methods=["PUT"])
def update_playlist_info(playlistid: str):
if playlistid is None:
return {"error": "Playlist ID not provided"}, 400
class UpdatePlaylistForm(BaseModel):
image: FileStorage = Field(None, description="The image file")
name: str = Field(..., description="The name of the playlist")
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:
return {"error": "Playlist not found"}, 404
image = None
image = form.image
if "image" in request.files:
image = request.files["image"]
if form.image:
image = form.image
data = request.form
settings = json.loads(data.get("settings"))
settings = json.loads(form.settings)
settings["has_gif"] = False
playlist = {
"id": int(playlistid),
"id": playlistid,
"image": db_playlist.image,
"last_updated": create_new_date(),
"name": str(data.get("name")).strip(),
"name": str(form.name).strip(),
"settings": settings,
"trackhashes": json.dumps([]),
}
@@ -247,7 +271,7 @@ def update_playlist_info(playlistid: str):
p_tuple = (*playlist.values(),)
PL.update_playlist(int(playlistid), playlist)
PL.update_playlist(playlistid, playlist)
playlist = models.Playlist(*p_tuple)
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"])
def pin_unpin_playlist(playlistid: str):
@api.post("/<playlistid>/pin_unpin")
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:
return {"error": "Playlist not found"}, 404
@@ -274,23 +298,22 @@ def pin_unpin_playlist(playlistid: str):
except KeyError:
settings["pinned"] = True
PL.update_settings(int(playlistid), settings)
PL.update_settings(path.playlistid, settings)
return {"msg": "Done"}, 200
@api.route("/playlist/<playlistid>/remove-img", methods=["GET"])
def remove_playlist_image(playlistid: str):
@api.delete("/<playlistid>/remove-img")
def remove_playlist_image(path: PlaylistIDPath):
"""
Removes the playlist image.
Clear playlist image.
"""
pid = int(playlistid)
playlist = PL.get_playlist_by_id(pid)
playlist = PL.get_playlist_by_id(path.playlistid)
if playlist is None:
return {"error": "Playlist not found"}, 404
PL.remove_banner(pid)
PL.remove_banner(path.playlistid)
playlist.image = None
playlist.thumb = None
@@ -303,78 +326,62 @@ def remove_playlist_image(playlistid: str):
return {"playlist": playlist}, 200
@api.route("/playlist/delete", methods=["POST"])
def remove_playlist():
@api.delete("/<playlistid>/delete", methods=["DELETE"])
def remove_playlist(path: PlaylistIDPath):
"""
Deletes a playlist by ID.
Delete playlist
"""
message = {"error": "Playlist ID not provided"}
data = request.get_json()
if data is None:
return message, 400
try:
pid = data["pid"]
except KeyError:
return message, 400
PL.delete_playlist(pid)
PL.delete_playlist(path.playlistid)
return {"msg": "Done"}, 200
@api.route("/playlist/<pid>/remove-tracks", methods=["POST"])
def remove_tracks_from_playlist(pid: int):
data = request.get_json()
class RemoveTracksFromPlaylistBody(BaseModel):
tracks: list[dict] = Field(..., description="A list of trackhashes to remove")
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;
# index: int;
# }
tracks = data["tracks"]
PL.remove_tracks_from_playlist(pid, tracks)
PL.remove_tracks_from_playlist(path.playlistid, body.tracks)
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
@api.route("/playlist/save-item", methods=["POST"])
def save_item_as_playlist():
data = request.get_json()
msg = {"error": "'itemtype', 'playlist_name' and 'itemhash' not provided"}, 400
class SavePlaylistAsItemBody(BaseModel):
itemtype: str = Field(..., description="The type of item", example="tracks")
playlist_name: str = Field(..., description="The name of the playlist")
itemhash: str = Field(..., description="The hash of the item to save")
if data is None:
return msg
try:
playlist_name = data["playlist_name"]
except KeyError:
playlist_name = None
@api.post("/save-item")
def save_item_as_playlist(body: SavePlaylistAsItemBody):
"""
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
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":
trackhashes = itemhash.split(",")
elif itemtype == "folder":
+38 -15
View File
@@ -1,37 +1,60 @@
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
api = Blueprint("plugins", __name__, url_prefix="/plugins")
bp_tag = Tag(name="Plugins", description="Manage plugins")
api = APIBlueprint("plugins", __name__, url_prefix="/plugins", abp_tags=[bp_tag])
@api.route("/", methods=["GET"])
@api.get("/")
def get_all_plugins():
"""
List all plugins
"""
plugins = PluginsMethods.get_all_plugins()
return {"plugins": plugins}
@api.route("/setactive", methods=["GET"])
def activate_deactivate_plugin():
name = request.args.get("plugin", None)
state = request.args.get("state", None)
class PluginBody(BaseModel):
plugin: str = Field(description="The plugin name", example="lyrics")
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
@api.route("/settings", methods=["POST"])
def update_plugin_settings():
data = request.get_json()
class PluginSettingsBody(PluginBody):
settings: dict = Field(
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:
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.plugins.lyrics import Lyrics
from app.settings import Defaults
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"])
def search_lyrics():
data = request.get_json()
class LyricsSearchBody(TrackHashSchema):
title: str = Field(description="The track title ", example=Defaults.API_TRACKNAME)
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", "")
artist = data.get("artist", "")
album = data.get("album", "")
filepath = data.get("filepath", None)
@api.post("/search")
def search_lyrics(body: LyricsSearchBody):
"""
Search for lyrics by title and artist
"""
title = body.title
artist = body.artist
album = body.album
filepath = body.filepath
trackhash = body.trackhash
finder = Lyrics()
data = finder.search_lyrics_by_title_and_artist(title, artist)
if not data:
+54 -37
View File
@@ -2,14 +2,19 @@
Contains all the search routes.
"""
from flask import Blueprint, request
from flask import request
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.lib import searchlib
from app.settings import Defaults
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
"""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)
@api.route("/search/tracks", methods=["GET"])
def search_tracks():
class SearchQuery(BaseModel):
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)
if not query:
return {"error": "No query provided"}, 400
tracks = Search(query).search_tracks(in_quotes)
return {
@@ -83,18 +89,15 @@ def search_tracks():
}
@api.route("/search/albums", methods=["GET"])
def search_albums():
@api.get("/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)
if not query:
return {"error": "No query provided"}, 400
albums = Search(query).search_albums(in_quotes)
return {
@@ -103,13 +106,13 @@ def search_albums():
}
@api.route("/search/artists", methods=["GET"])
def search_artists():
@api.get("/artists")
def search_artists(query: SearchQuery):
"""
Searches for artists.
Search artists.
"""
query = request.args.get("q")
query = query.q
if not query:
return {"error": "No query provided"}, 400
@@ -122,15 +125,21 @@ def search_artists():
}
@api.route("/search/top", methods=["GET"])
def get_top_results():
"""
Returns the top results for the search query.
"""
class TopResultsQuery(SearchQuery):
limit: int = Field(
description="The number of items to return", default=Defaults.API_CARD_LIMIT
)
query = request.args.get("q")
limit = request.args.get("limit", "6")
limit = int(limit)
@api.get("/top")
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)
@@ -140,32 +149,40 @@ def get_top_results():
return Search(query).get_top_results(in_quotes=in_quotes, limit=limit)
@api.route("/search/loadmore")
def search_load_more():
class SearchLoadMoreQuery(SearchQuery):
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.
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)
s_type = request.args.get("type")
index = int(request.args.get("index") or 0)
if s_type == "tracks":
if item_type == "tracks":
t = Search(query).search_tracks(in_quotes)
return {
"tracks": t[index : index + SEARCH_COUNT],
"more": len(t) > index + SEARCH_COUNT,
}
elif s_type == "albums":
elif item_type == "albums":
a = Search(query).search_albums(in_quotes)
return {
"albums": a[index : index + SEARCH_COUNT],
"more": len(a) > index + SEARCH_COUNT,
}
elif s_type == "artists":
elif item_type == "artists":
a = Search(query).search_artists()
return {
"artists": a[index : index + SEARCH_COUNT],
+43 -15
View File
@@ -1,30 +1,43 @@
"""
Contains all the track routes.
"""
import os
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.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>")
def send_track_file(trackhash: str):
class SendTrackFileQuery(BaseModel):
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.
Falls back to track hash if id is not found.
Get file
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"}
def get_mime(filename: str) -> str:
ext = filename.rsplit(".", maxsplit=1)[-1]
return f"audio/{ext}"
filepath = request.args.get("filepath")
# If filepath is provide, try to send that
if filepath is not None:
try:
track = TrackStore.get_tracks_by_filepaths([filepath])[0]
@@ -37,9 +50,7 @@ def send_track_file(trackhash: str):
audio_type = get_mime(filepath)
return send_file(filepath, mimetype=audio_type)
if trackhash is None:
return msg, 404
# Else, find file by trackhash
tracks = TrackStore.get_tracks_by_trackhashes([trackhash])
for track in tracks:
@@ -56,11 +67,28 @@ def send_track_file(trackhash: str):
return msg, 404
@api.route("/file/silence", methods=["POST"])
def get_audio_silence():
data = request.get_json()
ending_file = data.get("end", None) # ending file's filepath
starting_file = data.get("start", None) # starting file's filepath
class GetAudioSilenceBody(BaseModel):
ending_file: str = Field(
description="The ending file's path",
example="/home/cwilvx/Music/Made in Kenya/Sol generation/Bensoul - Salama.mp3",
)
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:
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.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.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]):
@@ -77,23 +82,24 @@ def finalize(new_: list[str], removed_: list[str], db_dirs_: list[str]):
rebuild_store(db_dirs_)
@api.route("/settings/add-root-dirs", methods=["POST"])
def add_root_dirs():
class AddRootDirsBody(BaseModel):
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.
"""
msg = {"msg": "Failed! No directories were given."}
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
new_dirs = body.new_dirs
removed_dirs = body.removed
db_dirs = sdb.get_root_dirs()
_h = "$home"
@@ -132,10 +138,10 @@ def add_root_dirs():
return {"root_dirs": db_dirs}
@api.route("/settings/get-root-dirs", methods=["GET"])
@api.get("/get-root-dirs")
def get_root_dirs():
"""
Get custom root directories from the database.
Get root directories
"""
dirs = sdb.get_root_dirs()
@@ -154,10 +160,10 @@ mapp = {
}
@api.route("/settings/", methods=["GET"])
@api.get("")
def get_all_settings():
"""
Get all settings from the database.
Get all settings
"""
settings = sdb.get_all_settings()
@@ -195,10 +201,24 @@ def reload_all_for_set_setting():
reload_everything(get_random_str())
@api.route("/settings/set", methods=["POST"])
def set_setting():
key = request.get_json().get("key")
value = request.get_json().get("value")
class SetSettingBody(BaseModel):
key: str = Field(
description="The setting key",
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":
return {"msg": "Invalid arguments!"}, 400
@@ -235,10 +255,10 @@ def run_populate():
populate.Populate(instance_key=get_random_str())
@api.route("/settings/trigger-scan", methods=["GET"])
@api.get("/trigger-scan")
def trigger_scan():
"""
Triggers a scan.
Triggers scan for new music
"""
run_populate()
+11 -1
View File
@@ -1,6 +1,7 @@
"""
Handles arguments passed to the program.
"""
import os.path
import sys
@@ -9,6 +10,7 @@ import PyInstaller.__main__ as bundler
from app import settings
from app.logger import log
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.wintools import is_windows
@@ -45,6 +47,8 @@ class HandleArgs:
config_keys = [
"SWINGMUSIC_APP_VERSION",
"GIT_LATEST_COMMIT_HASH",
"GIT_CURRENT_BRANCH",
]
lines = []
@@ -65,6 +69,8 @@ class HandleArgs:
_s = ";" if is_windows() else ":"
flask_openapi_path = getFlaskOpenApiPath()
bundler.run(
[
"manage.py",
@@ -74,6 +80,7 @@ class HandleArgs:
"--clean",
f"--add-data=assets{_s}assets",
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",
"-y",
]
@@ -176,5 +183,8 @@ class HandleArgs:
@staticmethod
def handle_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)
+2
View File
@@ -1 +1,3 @@
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 .utils import SQLiteManager
@@ -26,9 +27,10 @@ class SQLiteFavoriteMethods:
if cls.check_is_favorite(fav_hash, fav_type):
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:
cur.execute(sql, (fav_type, fav_hash))
cur.execute(sql, (fav_type, fav_hash, current_timestamp))
cur.close()
@classmethod
+2 -2
View File
@@ -66,9 +66,9 @@ class PluginsMethods:
return []
@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:
cur.execute("UPDATE plugins SET active=? WHERE name=?", (state, name))
cur.execute("UPDATE plugins SET active=? WHERE name=?", (active, name))
cur.close()
@classmethod
+2 -1
View File
@@ -15,7 +15,8 @@ CREATE TABLE IF NOT EXISTS playlists (
CREATE TABLE IF NOT EXISTS favorites (
id integer PRIMARY KEY,
hash text not null,
type text not null
type text not null,
timestamp integer not null default 0
);
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 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_artists = set()
@@ -216,8 +216,6 @@ def get_recent_items(limit: int = 7):
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)
timestamp = timestamp_from_days_ago(cutoff_days)
return [t for t in tracks if t.created_date > timestamp]
return tracks[:limit]
+7 -4
View File
@@ -1,6 +1,7 @@
"""
This library contains all the functions related to playlists.
"""
import os
import random
import string
@@ -62,7 +63,7 @@ def create_gif_thumbnail(image: Any, img_path: str):
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:
"""
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))
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)
@@ -134,7 +135,7 @@ def get_first_4_images(
return duplicate_images(images)
def get_recently_added_playlist(cutoff: int = 14):
def get_recently_added_playlist(limit: int = 100):
playlist = Playlist(
id="recentlyadded",
name="Recently Added",
@@ -144,8 +145,10 @@ def get_recently_added_playlist(cutoff: int = 14):
trackhashes=[],
)
tracks = get_recent_tracks(cutoff)
tracks = get_recent_tracks(limit=limit)
try:
# Create date to show as last updated
date = datetime.fromtimestamp(tracks[0].created_date)
except IndexError:
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.
"""
silence = {"start": 0, "end": 0}
silence = {"starting_file": 0, "ending_file": 0}
ending_thread = None
starting_thread = None
@@ -77,9 +77,9 @@ def get_silence_paddings(ending_file: str, starting_file: str):
starting_thread.start()
if ending_thread:
silence["end"] = ending_thread.join()
silence["ending_file"] = ending_thread.join()
if starting_thread:
silence["start"] = starting_thread.join()
silence["starting_file"] = starting_thread.join()
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.
"""
from app.db.sqlite.migrations import MigrationManager
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
migrations: list[list[Migration]] = [
@@ -26,7 +25,8 @@ migrations: list[list[Migration]] = [
v1_3_0.MovePlaylistsAndFavoritesTo10BitHashes,
v1_3_0.RemoveAllTracks,
v1_3_0.UpdateAppSettingsTable,
]
],
[v1_4_9.AddTimestampToFavoritesTable],
]
@@ -38,8 +38,8 @@ def apply_migrations():
version = MigrationManager.get_version()
if version != len(migrations):
# run migrations after the previous migration version
for migration in migrations[(version - 1) :]:
# INFO: Apply new migrations
for migration in migrations[version:]:
for m in migration:
try:
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
"""
import os
import subprocess
import sys
from typing import Any
@@ -96,6 +98,15 @@ class Defaults:
"""
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"]
@@ -233,21 +244,49 @@ class TCOLOR:
# 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:
SWINGMUSIC_APP_VERSION = os.environ.get("SWINGMUSIC_APP_VERSION")
GIT_LATEST_COMMIT_HASH = "<unset>"
GIT_CURRENT_BRANCH = "<unset>"
@classmethod
def load(cls):
if IS_BUILD:
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()
@classmethod
def verify_keys(cls):
# if not cls.LASTFM_API_KEY:
# print("ERROR: LASTFM_API_KEY not set in environment")
# sys.exit(0)
pass
@classmethod
+3 -3
View File
@@ -3,7 +3,7 @@ import json
from app.db.sqlite.artistcolors import SQLiteArtistMethods as ardb
from app.lib.artistlib import get_all_artists
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.progressbar import tqdm
@@ -72,7 +72,7 @@ class ArtistStore:
"""
artists = sorted(cls.artists, key=lambda x: x.artisthash)
try:
artist = UseBisection(artists, "artisthash", [artisthash])()[0]
artist = use_bisection(artists, "artisthash", [artisthash])[0]
return artist
except IndexError:
return None
@@ -83,7 +83,7 @@ class ArtistStore:
Returns artists by their hashes.
"""
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]
@classmethod
+2 -2
View File
@@ -3,7 +3,7 @@
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.db.sqlite.tracks import SQLiteTrackMethods as tdb
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.remove_duplicates import remove_duplicates
@@ -153,7 +153,7 @@ class TrackStore:
Returns all tracks matching the given paths.
"""
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]
@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.
returns a list of found items with `None` items being not found
items.
Returns a list of found items with `None` items being not found items.
"""
def __init__(
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):
def find(query: str):
left = 0
right = len(self.source_list) - 1
right = len(source) - 1
while left <= right:
mid = (left + right) // 2
if self.source_list[mid].__getattribute__(self.attr) == query:
return self.source_list[mid]
elif self.source_list[mid].__getattribute__(self.attr) > query:
if source[mid].__getattribute__(key) == query:
return source[mid]
elif source[mid].__getattribute__(key) > query:
right = mid - 1
else:
left = mid + 1
return None
def __call__(self):
if len(self.source_list) == 0:
if len(source) == 0:
return []
results: list[Track] = []
results = []
for query in self.queries_list:
res = self.find(query)
for query in queries:
res = find(query)
if res is None:
continue
results.append(res)
if self.limit != -1 and len(results) >= self.limit:
if limit != -1 and len(results) >= limit:
break
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]]
name = "aniso8601"
version = "9.0.1"
description = "A library for parsing ISO 8601 strings."
name = "annotated-types"
version = "0.6.0"
description = "Reusable constraint types to use with typing.Annotated"
optional = false
python-versions = "*"
python-versions = ">=3.8"
files = [
{file = "aniso8601-9.0.1-py2.py3-none-any.whl", hash = "sha256:1d2b7ef82963909e93c4f24ce48d4de9e66009a21bf1c1e1c85bdd0812fe412f"},
{file = "aniso8601-9.0.1.tar.gz", hash = "sha256:72e3117667eedf66951bb2d93f4296a56b94b078a8a95905a052611fb3f1b973"},
{file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"},
{file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"},
]
[package.extras]
dev = ["black", "coverage", "isort", "pre-commit", "pyenchant", "pylint"]
[[package]]
name = "astroid"
version = "2.15.8"
@@ -561,24 +558,25 @@ Flask = ">=0.9"
Six = "*"
[[package]]
name = "flask-restful"
version = "0.3.10"
description = "Simple framework for creating REST APIs"
name = "flask-openapi3"
version = "3.0.2"
description = "Generate REST API and OpenAPI documentation for your Flask project."
optional = false
python-versions = "*"
python-versions = ">=3.8"
files = [
{file = "Flask-RESTful-0.3.10.tar.gz", hash = "sha256:fe4af2ef0027df8f9b4f797aba20c5566801b6ade995ac63b588abf1a59cec37"},
{file = "Flask_RESTful-0.3.10-py2.py3-none-any.whl", hash = "sha256:1cf93c535172f112e080b0d4503a8d15f93a48c88bdd36dd87269bdaf405051b"},
{file = "flask_openapi3-3.0.2-py3-none-any.whl", hash = "sha256:e39359bf07da47d9abf30d4b370feaac4035bd805f03518ce65016958796c636"},
{file = "flask_openapi3-3.0.2.tar.gz", hash = "sha256:92e6a308c5b13692ad8aea04ee951ffbfabc6853b20bd3b678c2569dcb781916"},
]
[package.dependencies]
aniso8601 = ">=0.82"
Flask = ">=0.8"
pytz = "*"
six = ">=1.3.0"
flask = ">=2.0"
pydantic = ">=2.4"
[package.extras]
docs = ["sphinx"]
async = ["asgiref (>=3.2)"]
dotenv = ["python-dotenv"]
email = ["email-validator"]
yaml = ["pyyaml"]
[[package]]
name = "gevent"
@@ -1447,6 +1445,116 @@ files = [
{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]]
name = "pyinstaller"
version = "5.13.2"
@@ -1555,17 +1663,6 @@ files = [
[package.dependencies]
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]]
name = "pywin32"
version = "306"
@@ -2371,4 +2468,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.0"
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"
tabulate = "^0.9.0"
setproctitle = "^1.3.2"
flask-restful = "^0.3.10"
locust = "^2.20.1"
waitress = "^2.1.2"
watchdog = "^4.0.0"
pendulum = "^3.0.0"
flask-openapi3 = "^3.0.2"
[tool.poetry.dev-dependencies]
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