diff --git a/.browserslistrc b/.browserslistrc deleted file mode 100644 index 214388f..0000000 --- a/.browserslistrc +++ /dev/null @@ -1,3 +0,0 @@ -> 1% -last 2 versions -not dead diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index bb9ee2f..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,11 +0,0 @@ -module.exports = { - root: true, - env: { - es2021: true, - }, - extends: ["plugin:vue/vue3-essential", "eslint:recommended"], - rules: { - "no-console": process.env.NODE_ENV === "production" ? "warn" : "off", - "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", - }, -}; diff --git a/.gitignore b/.gitignore index 3d5d1e7..f87f9f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,7 @@ -.DS_Store -node_modules -/dist -.yarn* - - # local env files .env.local .env.*.local -# Log files -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* # Editor directories and files .idea @@ -23,4 +12,14 @@ pnpm-debug.log* *.sln *.sw? -__pycache__ \ No newline at end of file +__pycache__ +.hypothesis +sqllib.py +encoderx.py +tests +.pytest_cache + +# pyinstaller files +dist +build +client \ No newline at end of file diff --git a/README.md b/README.md index 6b51fa9..b1ffffc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,94 @@ -### Swing music client -This repo contains the client code for the swing music player. Documentation coming soon ... +![SWING MUSIC PLAYER BANNER IMAGE](./rd-me-banner.png) --- + +### Make listening to your local music fun again. + +`Swing` is a music player for local audio files that is built with both visual coolness and functionality in mind. Just run the app and enjoy your music library in a web browser. + +> Note: This project is in the early stages of development. Many features are missing but will be added with time. + +The app is currently only available on linux. (I don't have access to a Windows machine for building and testing purposes and my machine is not strong enough to support Windows in VM). + +### Setup + +Download the latest release from the [release page](#) and extract it in your machine. Then execute the extracted file in a terminal. + +```bash +./swing +``` + +The file will start the app at by default. See the setup option section on how to change the host and port. + +### Setup options + +``` +Usage: swing [options] + +Options: + --host: Set the host + --port: Set the port + --help, -h: Show this help message + --version, -v: Show the version +``` + +### Development + +This project is broken down into 2. The client and the server. The client comprises of the user interface code. This part is written in Typescript, Vue 3 and SCSS. To setup the client, checkout the [swing client repo ](#) on GitHub. + +The second part of this project is the server. This is the main part of the app that runs on your machine, interacts with audio files and send data to the client. It's written in Python 3. + +The following instructions will guide you on how to setup the **server**. + +--- + +The project uses [Python poetry](https://python-poetry.org) as the dependency manager. Follow the instructions in [their docs](https://python-poetry.org/docs/) to install it in your machine. + +> It is assumed that you have `Python 3.10` or newer installed in your machine. This project uses duck typing features so older version of Python will not work. If you don't have Python installed in your machine, get it from the [python website](https://www.python.org/downloads/). + +Clone this repo locally in your machine. Then install the project dependencies and start the app. + +```sh +git clone <$> + +cd swing-core + +# install dependencies using poetry +poetry install + +# start the app +poetry run python manage.py +``` + +### Contributing + +If you want to contribute to this project, feel free to open an issue or a pull request on Github. Your contributions are highly valued and appreciated. Feature suggestions, bug reports and code contribution are welcome. + +### License + +This software is provided to you with terms stated in the MIT License. Read the full text in the `LICENSE` file located at the root of this repository. + +### A brain dump ... + +I started working on this project on dec 2021. Why? I like listening and exploring music and I like it more when I can enjoy it (like really really). I'd been searching for cute music players for linux that allow me to manage my ever growing music library. Some of the main features I was looking for were: + +- A simple, lively and beautiful user interface (main reason) +- Creating automated daily mixes based on my listening activity. +- Ability to move files around without breaking my playlists and mixes. +- Something that can bring together all the audio files scattered all over my disks into a single place. +- Browsing related artists and albums. +- Reading artists and albums biographies. +- Web browser based user interface. +- a lot more ... but I can't remember them at the moment + +I've been working to make sure that most (if not all) of the features listed above are built. Some of them are done, but most are not even touched yet. A lot of work is needed and I know that it will take a lot of time to build and perfect them. + +I've been keeping a small 🤥 list of a few cool features that I'd like to see implemented in the project. You can check it out in [this notion page](https://rhetorical-othnielia-565.notion.site/Cool-features-1a0cd5b797904da687bec441e7c7aa19). https://rhetorical-othnielia-565.notion.site/Cool-features-1a0cd5b797904da687bec441e7c7aa19 + +I have been working on this project solo, so it’s very hard to push things fast. The app is written in Python for the backend and Vue3 for the client. If you have knowledge in any or both of this areas, feel free to contribute to the project. We’ll be excited to have you. Your help is highly appreciated. + +_The backend is basically a bunch of Python classes and functions. The client just sends API request and displays it._ + +--- + **[MIT License](https://opensource.org/licenses/MIT) | Copyright (c) 2023 Mungai Njoroge** diff --git a/alice.spec b/alice.spec new file mode 100644 index 0000000..5a0fc6f --- /dev/null +++ b/alice.spec @@ -0,0 +1,44 @@ +# -*- mode: python ; coding: utf-8 -*- + + +block_cipher = None + + +a = Analysis( + ['manage.py'], + pathex=[], + binaries=[], + datas=[('assets', 'assets'), ('client', 'client'), ('pyinstaller.config.ini', '.')], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='swing', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/src/assets/scss/Global/controls.scss b/app/__init__.py similarity index 100% rename from src/assets/scss/Global/controls.scss rename to app/__init__.py diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..5e7a5c7 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,30 @@ +""" +This module combines all API blueprints into a single Flask app instance. +""" + +from flask import Flask +from flask_cors import CORS + +from app.api import album, artist, favorites, folder, playlist, search, track +from app.imgserver import imgbp as imgserver + + +def create_api(): + """ + Creates the Flask instance, registers modules and registers all the API blueprints. + """ + app = Flask(__name__, static_url_path="") + CORS(app) + + with app.app_context(): + + app.register_blueprint(album.albumbp) + app.register_blueprint(artist.artistbp) + app.register_blueprint(track.trackbp) + app.register_blueprint(search.searchbp) + app.register_blueprint(folder.folderbp) + app.register_blueprint(playlist.playlistbp) + app.register_blueprint(favorites.favbp) + app.register_blueprint(imgserver) + + return app diff --git a/app/api/album.py b/app/api/album.py new file mode 100644 index 0000000..46ca817 --- /dev/null +++ b/app/api/album.py @@ -0,0 +1,151 @@ +""" +Contains all the album routes. +""" + +from dataclasses import asdict + +from flask import Blueprint, request + +from app import utils +from app.db.sqlite.albums import SQLiteAlbumMethods as adb +from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb +from app.db.store import Store +from app.models import FavType, Track + + +get_album_by_id = adb.get_album_by_id +get_albums_by_albumartist = adb.get_albums_by_albumartist +check_is_fav = favdb.check_is_favorite + +albumbp = Blueprint("album", __name__, url_prefix="") + + +@albumbp.route("/album", methods=["POST"]) +def get_album(): + """Returns all the tracks in the given album.""" + + data = request.get_json() + error_msg = {"msg": "No hash provided"} + + if data is None: + return error_msg, 400 + + try: + albumhash = data["hash"] + except KeyError: + return error_msg, 400 + + error_msg = {"error": "Album not created yet."} + album = Store.get_album_by_hash(albumhash) + + if album is None: + return error_msg, 204 + + tracks = Store.get_tracks_by_albumhash(albumhash) + + if tracks is None: + return error_msg, 404 + + if len(tracks) == 0: + return error_msg, 204 + + def get_album_genres(tracks: list[Track]): + genres = set() + + for track in tracks: + if track.genre is not None: + genres.update(track.genre) + + return list(genres) + + album.genres = get_album_genres(tracks) + tracks = utils.remove_duplicates(tracks) + + album.count = len(tracks) + + for track in tracks: + if track.date != "Unknown": + album.date = track.date + break + + try: + album.duration = sum((t.duration for t in tracks)) + except AttributeError: + album.duration = 0 + + if ( + album.count == 1 + and tracks[0].title == album.title + # and tracks[0].track == 1 + # and tracks[0].disc == 1 + ): + album.is_single = True + else: + album.check_type() + + album.is_favorite = check_is_fav(albumhash, FavType.album) + + return {"tracks": tracks, "info": album} + + +@albumbp.route("/album//tracks", methods=["GET"]) +def get_album_tracks(albumhash: str): + """ + Returns all the tracks in the given album. + """ + tracks = Store.get_tracks_by_albumhash(albumhash) + tracks = [asdict(t) for t in tracks] + + for t in tracks: + track = str(t["track"]).zfill(3) + t["pos"] = int(f"{t['disc']}{track}") + + tracks = sorted(tracks, key=lambda t: t["pos"]) + + return {"tracks": tracks} + + +@albumbp.route("/album/from-artist", methods=["POST"]) +def get_artist_albums(): + data = request.get_json() + + if data is None: + return {"msg": "No albumartist provided"} + + albumartists: str = data["albumartists"] # type: ignore + limit: int = data.get("limit") + exclude: str = data.get("exclude") + + albumartists: list[str] = albumartists.split(",") # type: ignore + + albums = [ + { + "artisthash": a, + "albums": Store.get_albums_by_albumartist(a, limit, exclude=exclude), + } + for a in albumartists + ] + + albums = [a for a in albums if len(a["albums"]) > 0] + + return {"data": albums} + + +# @album_bp.route("/album/bio", methods=["POST"]) +# def get_album_bio(): +# """Returns the album bio for the given album.""" +# data = request.get_json() +# album_hash = data["hash"] +# err_msg = {"bio": "No bio found"} + +# album = instances.album_instance.find_album_by_hash(album_hash) + +# if album is None: +# return err_msg, 404 + +# bio = FetchAlbumBio(album["title"], album["artist"])() + +# if bio is None: +# return err_msg, 404 + +# return {"bio": bio} diff --git a/app/api/artist.py b/app/api/artist.py new file mode 100644 index 0000000..19c664c --- /dev/null +++ b/app/api/artist.py @@ -0,0 +1,323 @@ +""" +Contains all the artist(s) routes. +""" +from collections import deque + +from flask import Blueprint, request + +from app.db.store import Store +from app.models import Album, FavType, Track +from app.utils import remove_duplicates +from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb + +artistbp = Blueprint("artist", __name__, url_prefix="/") + + +class CacheEntry: + """ + The cache entry class for the artists cache. + """ + + def __init__( + self, artisthash: str, albumhashes: set[str], tracks: list[Track] + ) -> None: + self.albums: list[Album] = [] + self.tracks: list[Track] = [] + + self.artisthash: str = artisthash + self.albumhashes: set[str] = albumhashes + + if len(tracks) > 0: + self.tracks: list[Track] = tracks + + self.type_checked = False + self.albums_fetched = False + + +class ArtistsCache: + """ + Holds artist page cache. + """ + + artists: deque[CacheEntry] = deque(maxlen=6) + + @classmethod + def get_albums_by_artisthash(cls, artisthash: str): + """ + Returns the cached albums for the given artisthash. + """ + for (index, albums) in enumerate(cls.artists): + if albums.artisthash == artisthash: + return (albums.albums, index) + + return ([], -1) + + @classmethod + def albums_cached(cls, artisthash: str) -> bool: + """ + Returns True if the artist is in the cache. + """ + for entry in cls.artists: + if entry.artisthash == artisthash and len(entry.albums) > 0: + return True + + return False + + @classmethod + def albums_fetched(cls, artisthash: str): + """ + Checks if the albums have been fetched for the given artisthash. + """ + for entry in cls.artists: + if entry.artisthash == artisthash: + return entry.albums_fetched + + @classmethod + def tracks_cached(cls, artisthash: str) -> bool: + """ + Checks if the tracks have been cached for the given artisthash. + """ + for entry in cls.artists: + if entry.artisthash == artisthash and len(entry.tracks) > 0: + return True + + return False + + @classmethod + def add_entry(cls, artisthash: str, albumhashes: set[str], tracks: list[Track]): + """ + Adds a new entry to the cache. + """ + cls.artists.append(CacheEntry(artisthash, albumhashes, tracks)) + + @classmethod + def get_tracks(cls, artisthash: str): + """ + Returns the cached tracks for the given artisthash. + """ + entry = [a for a in cls.artists if a.artisthash == artisthash][0] + return entry.tracks + + @classmethod + def get_albums(cls, artisthash: str): + """ + Returns the cached albums for the given artisthash. + """ + entry = [a for a in cls.artists if a.artisthash == artisthash][0] + + albums = [Store.get_album_by_hash(h) for h in entry.albumhashes] + entry.albums = [album for album in albums if album is not None] + + store_albums = Store.get_albums_by_artisthash(artisthash) + + all_albums_hash = "-".join([a.albumhash for a in entry.albums]) + + for album in store_albums: + if album.albumhash not in all_albums_hash: + entry.albums.append(album) + + entry.albums_fetched = True + + @classmethod + def process_album_type(cls, artisthash: str): + """ + Checks the cached albums type for the given artisthash. + """ + entry = [a for a in cls.artists if a.artisthash == artisthash][0] + + for album in entry.albums: + album.check_type() + + album_tracks = Store.get_tracks_by_albumhash(album.albumhash) + album_tracks = remove_duplicates(album_tracks) + + album.check_is_single(album_tracks) + + entry.type_checked = True + + +def add_albums_to_cache(artisthash: str): + """ + Fetches albums and adds them to the cache. + """ + tracks = Store.get_tracks_by_artist(artisthash) + + if len(tracks) == 0: + return False + + albumhashes = set(t.albumhash for t in tracks) + ArtistsCache.add_entry(artisthash, albumhashes, []) + + return True + + +# ======================================================= +# ===================== ROUTES ========================== +# ======================================================= + + +@artistbp.route("/artist/", methods=["GET"]) +def get_artist(artisthash: str): + """ + Get artist data. + """ + limit = request.args.get("limit") + + if limit is None: + limit = 6 + + limit = int(limit) + + artist = Store.get_artist_by_hash(artisthash) + + if artist is None: + return {"error": "Artist not found"}, 404 + + tracks_cached = ArtistsCache.tracks_cached(artisthash) + + if tracks_cached: + tracks = ArtistsCache.get_tracks(artisthash) + else: + tracks = Store.get_tracks_by_artist(artisthash) + albumhashes = set(t.albumhash for t in tracks) + hashes_from_albums = set( + a.albumhash for a in Store.get_albums_by_artisthash(artisthash) + ) + + albumhashes = albumhashes.union(hashes_from_albums) + ArtistsCache.add_entry(artisthash, albumhashes, tracks) + + tcount = len(tracks) + acount = Store.count_albums_by_artisthash(artisthash) + + if acount == 0 and tcount < 10: + limit = tcount + + artist.trackcount = tcount + artist.albumcount = acount + + artist.duration = sum(t.duration for t in tracks) + + artist.is_favorite = favdb.check_is_favorite(artisthash, FavType.artist) + + return {"artist": artist, "tracks": tracks[:limit]} + + +@artistbp.route("/artist//albums", methods=["GET"]) +def get_artist_albums(artisthash: str): + limit = request.args.get("limit") + + if limit is None: + limit = 6 + + return_all = request.args.get("all") + + limit = int(limit) + + all_albums = [] + is_cached = ArtistsCache.albums_cached(artisthash) + + if not is_cached: + add_albums_to_cache(artisthash) + + albums_fetched = ArtistsCache.albums_fetched(artisthash) + + if not albums_fetched: + ArtistsCache.get_albums(artisthash) + + all_albums, index = ArtistsCache.get_albums_by_artisthash(artisthash) + + if not ArtistsCache.artists[index].type_checked: + ArtistsCache.process_album_type(artisthash) + + singles = [a for a in all_albums if a.is_single] + eps = [a for a in all_albums if a.is_EP] + + def remove_EPs_and_singles(albums: list[Album]): + albums = [a for a in albums if not a.is_EP] + albums = [a for a in albums if not a.is_single] + return albums + + albums = filter(lambda a: artisthash in a.albumartisthash, all_albums) + albums = list(albums) + albums = remove_EPs_and_singles(albums) + + appearances = filter(lambda a: artisthash not in a.albumartisthash, all_albums) + appearances = list(appearances) + + appearances = remove_EPs_and_singles(appearances) + + artist = Store.get_artist_by_hash(artisthash) + + if return_all is not None: + limit = len(all_albums) + + return { + "artistname": artist.name, + "albums": albums[:limit], + "singles": singles[:limit], + "eps": eps[:limit], + "appearances": appearances[:limit], + } + + +@artistbp.route("/artist//tracks", methods=["GET"]) +def get_artist_tracks(artisthash: str): + """ + Returns all artists by a given artist. + """ + tracks = Store.get_tracks_by_artist(artisthash) + + return {"tracks": tracks} + # artist = Store.get_artist_by_hash(artisthash) + # if artist is None: + # return {"error": "Artist not found"}, 404 + + # return {"albums": albums[:limit]} + + +# @artist_bp.route("/artist/") +# @cache.cached() +# def get_artist_data(artist: str): +# """Returns the artist's data, tracks and albums""" +# artist = urllib.parse.unquote(artist) +# artist_obj = instances.artist_instance.get_artists_by_name(artist) + +# def get_artist_tracks(): +# songs = instances.tracks_instance.find_songs_by_artist(artist) + +# return songs + +# artist_songs = get_artist_tracks() +# songs = utils.remove_duplicates(artist_songs) + +# def get_artist_albums(): +# artist_albums = [] +# albums_with_count = [] + +# albums = instances.tracks_instance.find_songs_by_albumartist(artist) + +# for song in albums: +# if song["album"] not in artist_albums: +# artist_albums.append(song["album"]) + +# for album in artist_albums: +# count = 0 +# length = 0 + +# for song in artist_songs: +# if song["album"] == album: +# count = count + 1 +# length = length + song["length"] + +# album_ = {"title": album, "count": count, "length": length} + +# albums_with_count.append(album_) + +# return albums_with_count + +# return { +# "artist": artist_obj, +# "songs": songs, +# "albums": get_artist_albums() +# } diff --git a/app/api/favorites.py b/app/api/favorites.py new file mode 100644 index 0000000..8ab7087 --- /dev/null +++ b/app/api/favorites.py @@ -0,0 +1,210 @@ +from flask import Blueprint, request +from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb +from app.db.store import Store +from app.models import FavType +from app.utils import UseBisection + +favbp = Blueprint("favorite", __name__, url_prefix="/") + + +def remove_none(items: list): + return [i for i in items if i is not None] + + +@favbp.route("/favorite/add", methods=["POST"]) +def add_favorite(): + """ + 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") + + favdb.insert_one_favorite(itemtype, itemhash) + + if itemtype == FavType.track: + Store.add_fav_track(itemhash) + + return {"msg": "Added to favorites"} + + +@favbp.route("/favorite/remove", methods=["POST"]) +def remove_favorite(): + """ + 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") + + favdb.delete_favorite(itemtype, itemhash) + + if itemtype == FavType.track: + Store.remove_fav_track(itemhash) + + return {"msg": "Removed from favorites"} + + +@favbp.route("/albums/favorite") +def get_favorite_albums(): + limit = request.args.get("limit") + + if limit is None: + limit = 6 + + limit = int(limit) + + albums = favdb.get_fav_albums() + albumhashes = [a[1] for a in albums] + albumhashes.reverse() + + src_albums = sorted(Store.albums, key=lambda x: x.albumhash) + + fav_albums = UseBisection(src_albums, "albumhash", albumhashes)() + fav_albums = remove_none(fav_albums) + + if limit == 0: + limit = len(albums) + + return {"albums": fav_albums[:limit]} + + +@favbp.route("/tracks/favorite") +def get_favorite_tracks(): + limit = request.args.get("limit") + + if limit is None: + limit = 6 + + limit = int(limit) + + tracks = favdb.get_fav_tracks() + trackhashes = [t[1] for t in tracks] + trackhashes.reverse() + src_tracks = sorted(Store.tracks, key=lambda x: x.trackhash) + + tracks = UseBisection(src_tracks, "trackhash", trackhashes)() + tracks = remove_none(tracks) + + if limit == 0: + limit = len(tracks) + + return {"tracks": tracks[:limit]} + + +@favbp.route("/artists/favorite") +def get_favorite_artists(): + limit = request.args.get("limit") + + if limit is None: + limit = 6 + + limit = int(limit) + + artists = favdb.get_fav_artists() + artisthashes = [a[1] for a in artists] + artisthashes.reverse() + + src_artists = sorted(Store.artists, key=lambda x: x.artisthash) + + artists = UseBisection(src_artists, "artisthash", artisthashes)() + artists = remove_none(artists) + + if limit == 0: + limit = len(artists) + + return {"artists": artists[:limit]} + + +@favbp.route("/favorites") +def get_all_favorites(): + """ + 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) + + favs = favdb.get_all() + favs.reverse() + + tracks = [] + albums = [] + artists = [] + + for fav in favs: + if ( + len(tracks) >= track_limit + and len(albums) >= album_limit + and len(artists) >= artist_limit + ): + break + + if fav[2] == FavType.track: + tracks.append(fav[1]) + elif fav[2] == FavType.album: + albums.append(fav[1]) + elif fav[2] == FavType.artist: + artists.append(fav[1]) + + src_tracks = sorted(Store.tracks, key=lambda x: x.trackhash) + src_albums = sorted(Store.albums, key=lambda x: x.albumhash) + src_artists = sorted(Store.artists, key=lambda x: x.artisthash) + + tracks = tracks[:track_limit] + albums = albums[:album_limit] + artists = artists[:artist_limit] + + tracks = UseBisection(src_tracks, "trackhash", tracks)() + albums = UseBisection(src_albums, "albumhash", albums)() + artists = UseBisection(src_artists, "artisthash", artists)() + + tracks = remove_none(tracks) + albums = remove_none(albums) + artists = remove_none(artists) + + return { + "tracks": tracks, + "albums": albums, + "artists": artists, + } + + +@favbp.route("/favorites/check") +def check_favorite(): + """ + 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 + + exists = favdb.check_is_favorite(itemhash, itemtype) + + return {"is_favorite": exists} diff --git a/app/api/folder.py b/app/api/folder.py new file mode 100644 index 0000000..88ac6cb --- /dev/null +++ b/app/api/folder.py @@ -0,0 +1,32 @@ +""" +Contains all the folder routes. +""" +from flask import Blueprint, request + +from app import settings +from app.lib.folderslib import GetFilesAndDirs + +folderbp = Blueprint("folder", __name__, url_prefix="/") + + +@folderbp.route("/folder", methods=["POST"]) +def get_folder_tree(): + """ + Returns a list of all the folders and tracks in the given folder. + """ + data = request.get_json() + + if data is not None: + req_dir: str = data["folder"] + else: + req_dir = settings.HOME_DIR + + if req_dir == "$home": + req_dir = settings.HOME_DIR + + tracks, folders = GetFilesAndDirs(req_dir)() + + return { + "tracks": tracks, + "folders": sorted(folders, key=lambda i: i.name), + } diff --git a/app/api/playlist.py b/app/api/playlist.py new file mode 100644 index 0000000..c24579b --- /dev/null +++ b/app/api/playlist.py @@ -0,0 +1,226 @@ +""" +All playlist-related routes. +""" +import json +from datetime import datetime + +from flask import Blueprint, request +from PIL import UnidentifiedImageError + +from app import models, serializer +from app.db.sqlite.playlists import SQLitePlaylistMethods +from app.db.store import Store +from app.lib import playlistlib +from app.utils import create_new_date, remove_duplicates + +playlistbp = Blueprint("playlist", __name__, url_prefix="/") + +PL = SQLitePlaylistMethods + +insert_one_playlist = PL.insert_one_playlist +get_playlist_by_name = PL.get_playlist_by_name +count_playlist_by_name = PL.count_playlist_by_name +get_all_playlists = PL.get_all_playlists +get_playlist_by_id = PL.get_playlist_by_id +tracks_to_playlist = PL.add_tracks_to_playlist +add_artist_to_playlist = PL.add_artist_to_playlist +update_playlist = PL.update_playlist +delete_playlist = PL.delete_playlist + +# get_tracks_by_trackhashes = SQLiteTrackMethods.get_tracks_by_trackhashes + + +@playlistbp.route("/playlists", methods=["GET"]) +def send_all_playlists(): + """ + Gets all the playlists. + """ + playlists = get_all_playlists() + playlists = list(playlists) + + playlists.sort( + key=lambda p: datetime.strptime(p.last_updated, "%Y-%m-%d %H:%M:%S"), + reverse=True, + ) + + return {"data": playlists} + + +@playlistbp.route("/playlist/new", methods=["POST"]) +def create_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 = count_playlist_by_name(data["name"]) + + if existing_playlist_count > 0: + return {"error": "Playlist already exists"}, 409 + + playlist = { + "artisthashes": json.dumps([]), + "banner_pos": 50, + "has_gif": 0, + "image": None, + "last_updated": create_new_date(), + "name": data["name"], + "trackhashes": json.dumps([]), + } + + playlist = insert_one_playlist(playlist) + + if playlist is None: + return {"error": "Playlist could not be created"}, 500 + + return {"playlist": playlist}, 201 + + +@playlistbp.route("/playlist//add", methods=["POST"]) +def add_track_to_playlist(playlist_id: str): + """ + Takes a playlist ID and a track hash, and adds the track to the playlist + """ + data = request.get_json() + + if data is None: + return {"error": "Track hash not provided"}, 400 + + trackhash = data["track"] + + insert_count = tracks_to_playlist(int(playlist_id), [trackhash]) + + if insert_count == 0: + return {"error": "Track already exists in playlist"}, 409 + + add_artist_to_playlist(int(playlist_id), trackhash) + + return {"msg": "Done"}, 200 + + +@playlistbp.route("/playlist/") +def get_playlist(playlistid: str): + """ + Gets a playlist by id, and if it exists, it gets all the tracks in the playlist and returns them. + """ + playlist = get_playlist_by_id(int(playlistid)) + + if playlist is None: + return {"msg": "Playlist not found"}, 404 + + tracks = Store.get_tracks_by_trackhashes(list(playlist.trackhashes)) + tracks = remove_duplicates(tracks) + + duration = sum(t.duration for t in tracks) + playlist.last_updated = serializer.date_string_to_time_passed(playlist.last_updated) + + playlist.duration = duration + + return {"info": playlist, "tracks": tracks} + + +@playlistbp.route("/playlist//update", methods=["PUT"]) +def update_playlist_info(playlistid: str): + if playlistid is None: + return {"error": "Playlist ID not provided"}, 400 + + db_playlist = get_playlist_by_id(int(playlistid)) + + if db_playlist is None: + return {"error": "Playlist not found"}, 404 + + image = None + + if "image" in request.files: + image = request.files["image"] + + data = request.form + + playlist = { + "id": int(playlistid), + "artisthashes": json.dumps([]), + "banner_pos": db_playlist.banner_pos, + "has_gif": 0, + "image": db_playlist.image, + "last_updated": create_new_date(), + "name": str(data.get("name")).strip(), + "trackhashes": json.dumps([]), + } + + if image: + try: + playlist["image"] = playlistlib.save_p_image(image, playlistid) + + if image.content_type == "image/gif": + playlist["has_gif"] = 1 + + # reset banner position to center. + playlist["banner_pos"] = 50 + PL.update_banner_pos(int(playlistid), 50) + + except UnidentifiedImageError: + return {"error": "Failed: Invalid image"}, 400 + + p_tuple = (*playlist.values(),) + print("banner pos:", playlist["banner_pos"]) + + update_playlist(int(playlistid), playlist) + + playlist = models.Playlist(*p_tuple) + playlist.last_updated = serializer.date_string_to_time_passed(playlist.last_updated) + + return { + "data": playlist, + } + + +# @playlist_bp.route("/playlist/artists", methods=["POST"]) +# def get_playlist_artists(): +# data = request.get_json() + +# pid = data["pid"] +# artists = playlistlib.GetPlaylistArtists(pid)() + +# return {"data": artists} + + +@playlistbp.route("/playlist/delete", methods=["POST"]) +def remove_playlist(): + """ + Deletes a playlist by ID. + """ + 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 + + delete_playlist(pid) + + return {"msg": "Done"}, 200 + + +@playlistbp.route("/playlist//set-image-pos", methods=["POST"]) +def update_image_position(pid: int): + data = request.get_json() + message = {"msg": "No data provided"} + + if data is None: + return message, 400 + + try: + pos = data["pos"] + except KeyError: + return message, 400 + + PL.update_banner_pos(pid, pos) + + return {"msg": "Image position saved"}, 200 diff --git a/app/api/search.py b/app/api/search.py new file mode 100644 index 0000000..92463d1 --- /dev/null +++ b/app/api/search.py @@ -0,0 +1,218 @@ +""" +Contains all the search routes. +""" + +from flask import Blueprint, request + +from app import models, utils +from app.db.store import Store +from app.lib import searchlib + +searchbp = Blueprint("search", __name__, url_prefix="/") + + +SEARCH_COUNT = 12 +"""The max amount of items to return per request""" + + +class SearchResults: + """ + Holds all the search results. + """ + + query: str = "" + tracks: list[models.Track] = [] + albums: list[models.Album] = [] + playlists: list[models.Playlist] = [] + artists: list[models.Artist] = [] + + +class DoSearch: + """Class containing the methods that perform searching.""" + + def __init__(self, query: str) -> None: + """ + :param :str:`query`: the search query. + """ + self.tracks: list[models.Track] = [] + self.query = query + SearchResults.query = query + + def search_tracks(self): + """Calls :class:`SearchTracks` which returns the tracks that fuzzily match + the search terms. Then adds them to the `SearchResults` store. + """ + self.tracks = Store.tracks + tracks = searchlib.SearchTracks(self.tracks, self.query)() + + if len(tracks) == 0: + return [] + + tracks = utils.remove_duplicates(tracks) + SearchResults.tracks = tracks + + return tracks + + def search_artists(self): + """Calls :class:`SearchArtists` which returns the artists that fuzzily match + the search term. Then adds them to the `SearchResults` store. + """ + # self.artists = utils.Get.get_all_artists() + artists = [a.name for a in Store.artists] + artists = searchlib.SearchArtists(artists, self.query)() + SearchResults.artists = artists + + return artists + + def search_albums(self): + """Calls :class:`SearchAlbums` which returns the albums that fuzzily match + the search term. Then adds them to the `SearchResults` store. + """ + # albums = utils.Get.get_all_albums() + albums = Store.albums + albums = searchlib.SearchAlbums(albums, self.query)() + SearchResults.albums = albums + + return albums + + # def search_playlists(self): + # """Calls :class:`SearchPlaylists` which returns the playlists that fuzzily match + # the search term. Then adds them to the `SearchResults` store. + # """ + # playlists = utils.Get.get_all_playlists() + # playlists = [serializer.Playlist(playlist) for playlist in playlists] + + # playlists = searchlib.SearchPlaylists(playlists, self.query)() + # SearchResults.playlists = playlists + + # return playlists + + def search_all(self): + """Calls all the search methods.""" + self.search_tracks() + self.search_albums() + self.search_artists() + # self.search_playlists() + + +@searchbp.route("/search/tracks", methods=["GET"]) +def search_tracks(): + """ + Searches for tracks that match the search query. + """ + + query = request.args.get("q") + if not query: + return {"error": "No query provided"}, 400 + + tracks = DoSearch(query).search_tracks() + + return { + "tracks": tracks[:SEARCH_COUNT], + "more": len(tracks) > SEARCH_COUNT, + } + + +@searchbp.route("/search/albums", methods=["GET"]) +def search_albums(): + """ + Searches for albums. + """ + + query = request.args.get("q") + if not query: + return {"error": "No query provided"}, 400 + + tracks = DoSearch(query).search_albums() + + return { + "albums": tracks[:SEARCH_COUNT], + "more": len(tracks) > SEARCH_COUNT, + } + + +@searchbp.route("/search/artists", methods=["GET"]) +def search_artists(): + """ + Searches for artists. + """ + + query = request.args.get("q") + if not query: + return {"error": "No query provided"}, 400 + + artists = DoSearch(query).search_artists() + + return { + "artists": artists[:SEARCH_COUNT], + "more": len(artists) > SEARCH_COUNT, + } + + +# @searchbp.route("/search/playlists", methods=["GET"]) +# def search_playlists(): +# """ +# Searches for playlists. +# """ + +# query = request.args.get("q") +# if not query: +# return {"error": "No query provided"}, 400 + +# playlists = DoSearch(query).search_playlists() + +# return { +# "playlists": playlists[:SEARCH_COUNT], +# "more": len(playlists) > SEARCH_COUNT, +# } + + +@searchbp.route("/search/top", methods=["GET"]) +def get_top_results(): + """ + Returns the top results for the search query. + """ + + query = request.args.get("q") + if not query: + return {"error": "No query provided"}, 400 + + DoSearch(query).search_all() + + max = 2 + return { + "tracks": SearchResults.tracks[:max], + "albums": SearchResults.albums[:max], + "artists": SearchResults.artists[:max], + "playlists": SearchResults.playlists[:max], + } + + +@searchbp.route("/search/loadmore") +def search_load_more(): + """ + Returns more songs, albums or artists from a search query. + """ + s_type = request.args.get("type") + index = int(request.args.get("index") or 0) + + if s_type == "tracks": + t = SearchResults.tracks + return { + "tracks": t[index : index + SEARCH_COUNT], + "more": len(t) > index + SEARCH_COUNT, + } + + elif s_type == "albums": + a = SearchResults.albums + return { + "albums": a[index : index + SEARCH_COUNT], + "more": len(a) > index + SEARCH_COUNT, + } + + elif s_type == "artists": + a = SearchResults.artists + return { + "artists": a[index : index + SEARCH_COUNT], + "more": len(a) > index + SEARCH_COUNT, + } diff --git a/app/api/track.py b/app/api/track.py new file mode 100644 index 0000000..9262cfe --- /dev/null +++ b/app/api/track.py @@ -0,0 +1,33 @@ +""" +Contains all the track routes. +""" +from flask import Blueprint, send_file +from app.db.store import Store + +trackbp = Blueprint("track", __name__, url_prefix="/") + + +@trackbp.route("/file/") +def send_track_file(trackhash: str): + """ + Returns an audio file that matches the passed id to the client. + Falls back to track hash if id is not found. + """ + msg = {"msg": "File Not Found"} + if trackhash is None: + return msg, 404 + + try: + track = Store.get_tracks_by_trackhashes([trackhash])[0] + except IndexError: + track = None + + if track is None: + return msg, 404 + + audio_type = track.filepath.rsplit(".", maxsplit=1)[-1] + + try: + return send_file(track.filepath, mimetype=f"audio/{audio_type}") + except FileNotFoundError: + return msg, 404 diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e6216ae --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1,214 @@ +class AlbumMethods: + """ + Lists all the methods that can be found in the Albums class. + """ + + def insert_album(): + """ + Inserts a new album object into the database. + """ + pass + + def get_all_albums(): + """ + Returns all the albums in the database. + """ + pass + + def get_album_by_id(): + """ + Returns a single album matching the passed id. + """ + pass + + def get_album_by_name(): + """ + Returns a single album matching the passed name. + """ + pass + + def get_album_by_artist(): + """ + Returns a single album matching the passed artist name. + """ + pass + + +class ArtistMethods: + """ + Lists all the methods that can be found in the Artists class. + """ + + def insert_artist(): + """ + Inserts a new artist object into the database. + """ + pass + + def get_all_artists(): + """ + Returns all the artists in the database. + """ + pass + + def get_artist_by_id(): + """ + Returns an artist matching the mongo Id. + """ + pass + + def get_artists_by_name(): + """ + Returns all the artists matching the query. + """ + pass + + +class PlaylistMethods: + """ + Lists all the methods that can be found in the Playlists class. + """ + + def insert_playlist(): + """ + Inserts a new playlist object into the database. + """ + pass + + def get_all_playlists(): + """ + Returns all the playlists in the database. + """ + pass + + def get_playlist_by_id(): + """ + Returns a single playlist matching the id in the query params. + """ + pass + + def add_track_to_playlist(): + """ + Adds a track to a playlist. + """ + pass + + def get_playlist_by_name(): + """ + Returns a single playlist matching the name in the query params. + """ + pass + + def update_playlist(): + """ + Updates a playlist. + """ + pass + + +class TrackMethods: + """ + Lists all the methods that can be found in the Tracks class. + """ + + def insert_one_track(): + """ + Inserts a new track object into the database. + """ + pass + + def drop_db(): + """ + Drops the entire database. + """ + pass + + def get_all_tracks(): + """ + Returns all the tracks in the database. + """ + pass + + def get_track_by_id(): + """ + Returns a single track matching the id in the query params. + """ + pass + + def get_track_by_album(): + """ + Returns a single track matching the album in the query params. + """ + pass + + def search_tracks_by_album(): + """ + Returns all the tracks matching the albums in the query params (using regex). + """ + pass + + def search_tracks_by_artist(): + """ + Returns all the tracks matching the artists in the query params. + """ + pass + + def find_track_by_title(): + """ + Finds all the tracks matching the title in the query params. + """ + pass + + def find_tracks_by_album(): + """ + Finds all the tracks matching the album in the query params. + """ + pass + + def find_tracks_by_folder(): + """ + Finds all the tracks matching the folder in the query params. + """ + pass + + def find_tracks_by_artist(): + """ + Finds all the tracks matching the artist in the query params. + """ + pass + + def find_tracks_by_albumartist(): + """ + Finds all the tracks matching the album artist in the query params. + """ + pass + + def get_track_by_path(): + """ + Returns a single track matching the path in the query params. + """ + pass + + def remove_track_by_path(): + """ + Removes a track from the database. Returns a boolean indicating success or failure of the operation. + """ + pass + + def remove_track_by_id(): + """ + Removes a track from the database. Returns a boolean indicating success or failure of the operation. + """ + pass + + def find_tracks_by_albumhash(): + """ + Returns all the tracks matching the passed hash. + """ + pass + + def get_dir_t_count(): + """ + Returns a list of all the tracks matching the path in the query params. + """ + pass diff --git a/app/db/sqlite/__init__.py b/app/db/sqlite/__init__.py new file mode 100644 index 0000000..96f374a --- /dev/null +++ b/app/db/sqlite/__init__.py @@ -0,0 +1,47 @@ +""" +This module contains the functions to interact with the SQLite database. +""" + +import sqlite3 +from pathlib import Path +from sqlite3 import Connection as SqlConn + +from app.settings import APP_DB_PATH + + +def create_connection(db_file: str) -> SqlConn: + """ + Creates a connection to the specified database. + """ + conn = sqlite3.connect(db_file) + return conn + + +def get_sqlite_conn(): + """ + It opens a connection to the database + :return: A connection to the database. + """ + return create_connection(APP_DB_PATH) + + +def create_tables(conn: SqlConn, sql_query: str): + """ + Executes the specifiend SQL file to create database tables. + """ + # with open(sql_query, "r", encoding="utf-8") as sql_file: + conn.executescript(sql_query) + + +def setup_search_db(): + """ + Creates the search database. + """ + db = sqlite3.connect(":memory:") + sql_file = "queries/fts5.sql" + + current_path = Path(__file__).parent.resolve() + sql_path = current_path.joinpath(sql_file) + + with open(sql_path, "r", encoding="utf-8") as sql_file: + db.executescript(sql_file.read()) diff --git a/app/db/sqlite/albums.py b/app/db/sqlite/albums.py new file mode 100644 index 0000000..c016bba --- /dev/null +++ b/app/db/sqlite/albums.py @@ -0,0 +1,125 @@ +from sqlite3 import Cursor + +from app.db import AlbumMethods + +from .utils import SQLiteManager, tuple_to_album, tuples_to_albums + + +class SQLiteAlbumMethods(AlbumMethods): + @classmethod + def insert_one_album(cls, cur: Cursor, albumhash: str, colors: str): + """ + Inserts one album into the database + """ + + sql = """INSERT INTO albums( + albumhash, + colors + ) VALUES(?,?) + """ + + cur.execute(sql, (albumhash, colors)) + + return cur.lastrowid + + # @classmethod + # def insert_many_albums(cls, albums: list[dict]): + # """ + # Takes a generator of albums, and inserts them into the database + + # Parameters + # ---------- + # albums : Generator + # Generator + # """ + # with SQLiteManager() as cur: + # for album in albums: + # cls.insert_one_album(cur, album["albumhash"], album["colors"]) + + @classmethod + def get_all_albums(cls): + with SQLiteManager() as cur: + cur.execute("SELECT * FROM albums") + albums = cur.fetchall() + + if albums is not None: + return albums + + return [] + + # @staticmethod + # def get_album_by_id(album_id: int): + # conn = get_sqlite_conn() + # cur = conn.cursor() + + # cur.execute("SELECT * FROM albums WHERE id=?", (album_id,)) + # album = cur.fetchone() + + # conn.close() + + # if album is None: + # return None + + # return tuple_to_album(album) + + @staticmethod + def get_album_by_hash(album_hash: str): + with SQLiteManager() as cur: + cur.execute("SELECT * FROM albums WHERE albumhash=?", (album_hash,)) + album = cur.fetchone() + + if album is not None: + return tuple_to_album(album) + + return None + + @classmethod + def get_albums_by_hashes(cls, album_hashes: list): + """ + Gets all the albums with the specified hashes. Returns a generator of albums or an empty list. + """ + with SQLiteManager() as cur: + hashes = ",".join("?" * len(album_hashes)) + cur.execute( + f"SELECT * FROM albums WHERE albumhash IN ({hashes})", album_hashes + ) + albums = cur.fetchall() + + if albums is not None: + return tuples_to_albums(albums) + + return [] + + # @staticmethod + # def update_album_colors(album_hash: str, colors: list[str]): + # sql = "UPDATE albums SET colors=? WHERE albumhash=?" + + # colors_str = json.dumps(colors) + + # with SQLiteManager() as cur: + # cur.execute(sql, (colors_str, album_hash)) + + @staticmethod + def get_albums_by_albumartist(albumartist: str): + with SQLiteManager() as cur: + cur.execute("SELECT * FROM albums WHERE albumartist=?", (albumartist,)) + albums = cur.fetchall() + + if albums is not None: + return tuples_to_albums(albums) + + return [] + + @staticmethod + def get_all_albums_raw(): + """ + Returns all the albums in the database, as a list of tuples. + """ + with SQLiteManager() as cur: + cur.execute("SELECT * FROM albums") + albums = cur.fetchall() + + if albums is not None: + return albums + + return [] diff --git a/app/db/sqlite/artists.py b/app/db/sqlite/artists.py new file mode 100644 index 0000000..822bb75 --- /dev/null +++ b/app/db/sqlite/artists.py @@ -0,0 +1,36 @@ +""" +Contains methods for reading and writing to the sqlite artists database. +""" + +import json +from .utils import SQLiteManager + + +class SQLiteArtistMethods: + @classmethod + def insert_one_artist(cls, artisthash: str, colors: str | list[str]): + """ + Inserts a single artist into the database. + """ + sql = """INSERT INTO artists( + artisthash, + colors + ) VALUES(?,?) + """ + colors = json.dumps(colors) + + with SQLiteManager() as cur: + cur.execute(sql, (artisthash, colors)) + + @classmethod + def get_all_artists(cls): + """ + Get all artists from the database and return a generator of Artist objects + """ + sql = """SELECT * FROM artists""" + + with SQLiteManager() as cur: + cur.execute(sql) + + for artist in cur.fetchall(): + yield artist diff --git a/app/db/sqlite/favorite.py b/app/db/sqlite/favorite.py new file mode 100644 index 0000000..4db97b9 --- /dev/null +++ b/app/db/sqlite/favorite.py @@ -0,0 +1,77 @@ +from app.models import FavType +from .utils import SQLiteManager + + +class SQLiteFavoriteMethods: + """THis class contains methods for interacting with the favorites table.""" + + @classmethod + def insert_one_favorite(cls, fav_type: str, fav_hash: str): + """ + Inserts a single favorite into the database. + """ + sql = """INSERT INTO favorites(type, hash) VALUES(?,?)""" + with SQLiteManager(userdata_db=True) as cur: + cur.execute(sql, (fav_type, fav_hash)) + + @classmethod + def get_all(cls) -> list[tuple]: + """ + Returns a list of all favorites. + """ + sql = """SELECT * FROM favorites""" + with SQLiteManager(userdata_db=True) as cur: + cur.execute(sql) + return cur.fetchall() + + @classmethod + def get_favorites(cls, fav_type: str) -> list[tuple]: + """ + Returns a list of favorite tracks. + """ + sql = """SELECT * FROM favorites WHERE type = ?""" + with SQLiteManager(userdata_db=True) as cur: + cur.execute(sql, (fav_type,)) + return cur.fetchall() + + @classmethod + def get_fav_tracks(cls) -> list[tuple]: + """ + Returns a list of favorite tracks. + """ + return cls.get_favorites(FavType.track) + + @classmethod + def get_fav_albums(cls) -> list[tuple]: + """ + Returns a list of favorite albums. + """ + return cls.get_favorites(FavType.album) + + @classmethod + def get_fav_artists(cls) -> list[tuple]: + """ + Returns a list of favorite artists. + """ + return cls.get_favorites(FavType.artist) + + @classmethod + def delete_favorite(cls, fav_type: str, fav_hash: str): + """ + Deletes a favorite from the database. + """ + sql = """DELETE FROM favorites WHERE hash = ? AND type = ?""" + + with SQLiteManager(userdata_db=True) as cur: + cur.execute(sql, (fav_hash, fav_type)) + + @classmethod + def check_is_favorite(cls, itemhash: str, fav_type: str): + """ + Checks if an item is favorited. + """ + sql = """SELECT * FROM favorites WHERE hash = ? AND type = ?""" + with SQLiteManager(userdata_db=True) as cur: + cur.execute(sql, (itemhash, fav_type)) + items = cur.fetchall() + return len(items) > 0 diff --git a/app/db/sqlite/playlists.py b/app/db/sqlite/playlists.py new file mode 100644 index 0000000..9abe515 --- /dev/null +++ b/app/db/sqlite/playlists.py @@ -0,0 +1,179 @@ +import json +from collections import OrderedDict + +from app.db.sqlite.tracks import SQLiteTrackMethods +from app.db.sqlite.utils import SQLiteManager, tuple_to_playlist, tuples_to_playlists +from app.models import Artist +from app.utils import background + + +class SQLitePlaylistMethods: + """ + This class contains methods for interacting with the playlists table. + """ + + @staticmethod + def insert_one_playlist(playlist: dict): + sql = """INSERT INTO playlists( + artisthashes, + banner_pos, + has_gif, + image, + last_updated, + name, + trackhashes + ) VALUES(?,?,?,?,?,?,?) + """ + + playlist = OrderedDict(sorted(playlist.items())) + params = (*playlist.values(),) + + with SQLiteManager(userdata_db=True) as cur: + cur.execute(sql, params) + pid = cur.lastrowid + params = (pid, *params) + + return tuple_to_playlist(params) + + @staticmethod + def get_playlist_by_name(name: str): + sql = "SELECT * FROM playlists WHERE name = ?" + + with SQLiteManager(userdata_db=True) as cur: + cur.execute(sql, (name,)) + + data = cur.fetchone() + + if data is not None: + return tuple_to_playlist(data) + + return None + + @staticmethod + def count_playlist_by_name(name: str): + sql = "SELECT COUNT(*) FROM playlists WHERE name = ?" + + with SQLiteManager(userdata_db=True) as cur: + cur.execute(sql, (name,)) + + data = cur.fetchone() + + return int(data[0]) + + @staticmethod + def get_all_playlists(): + with SQLiteManager(userdata_db=True) as cur: + cur.execute("SELECT * FROM playlists") + playlists = cur.fetchall() + + if playlists is not None: + return tuples_to_playlists(playlists) + + return [] + + @staticmethod + def get_playlist_by_id(playlist_id: int): + sql = "SELECT * FROM playlists WHERE id = ?" + + with SQLiteManager(userdata_db=True) as cur: + cur.execute(sql, (playlist_id,)) + + data = cur.fetchone() + + if data is not None: + return tuple_to_playlist(data) + + return None + + # FIXME: Extract the "add_track_to_playlist" method to use it for both the artisthash and trackhash lists. + + @staticmethod + def add_item_to_json_list(playlist_id: int, field: str, items: list[str]): + """ + Adds a string item to a json dumped list using a playlist id and field name. Takes the playlist ID, a field name, an item to add to the field, and an error to raise if the item is already in the field. + + Parameters + ---------- + playlist_id : int + The ID of the playlist to add the item to. + field : str + The field in the database that you want to add the item to. + item : str + The item to add to the list. + error : Exception + The error to raise if the item is already in the list. + + Returns + ------- + A list of strings. + + """ + sql = f"SELECT {field} FROM playlists WHERE id = ?" + + with SQLiteManager(userdata_db=True) as cur: + cur.execute(sql, (playlist_id,)) + data = cur.fetchone() + + if data is not None: + db_items: list[str] = json.loads(data[0]) + + for item in items: + if item in db_items: + items.remove(item) + + db_items.extend(items) + + sql = f"UPDATE playlists SET {field} = ? WHERE id = ?" + cur.execute(sql, (json.dumps(db_items), playlist_id)) + return len(items) + + @classmethod + def add_tracks_to_playlist(cls, playlist_id: int, trackhashes: list[str]): + return cls.add_item_to_json_list(playlist_id, "trackhashes", trackhashes) + + @classmethod + @background + def add_artist_to_playlist(cls, playlist_id: int, trackhash: str): + track = SQLiteTrackMethods.get_track_by_trackhash(trackhash) + if track is None: + return + + artists: list[Artist] = track.artist # type: ignore + artisthashes = [a.artisthash for a in artists] + + cls.add_item_to_json_list(playlist_id, "artisthashes", artisthashes) + + @staticmethod + def update_playlist(playlist_id: int, playlist: dict): + sql = """UPDATE playlists SET + has_gif = ?, + image = ?, + last_updated = ?, + name = ? + WHERE id = ? + """ + + del playlist["id"] + del playlist["trackhashes"] + del playlist["artisthashes"] + del playlist['banner_pos'] + + playlist = OrderedDict(sorted(playlist.items())) + params = (*playlist.values(), playlist_id) + + with SQLiteManager(userdata_db=True) as cur: + cur.execute(sql, params) + + @staticmethod + def delete_playlist(pid: str): + sql = "DELETE FROM playlists WHERE id = ?" + + with SQLiteManager(userdata_db=True) as cur: + cur.execute(sql, (pid,)) + + @staticmethod + def update_banner_pos(playlistid: int, pos: int): + sql = """UPDATE playlists SET banner_pos = ? WHERE id = ?""" + + with SQLiteManager(userdata_db=True) as cur: + cur.execute(sql, (pos, playlistid)) diff --git a/app/db/sqlite/queries.py b/app/db/sqlite/queries.py new file mode 100644 index 0000000..003784a --- /dev/null +++ b/app/db/sqlite/queries.py @@ -0,0 +1,65 @@ +""" +This file contains the SQL queries to create the database tables. +""" + + +CREATE_USERDATA_TABLES = """ +CREATE TABLE IF NOT EXISTS playlists ( + id integer PRIMARY KEY, + artisthashes text, + banner_pos integer NOT NULL, + has_gif integer, + image text, + last_updated text not null, + name text not null, + trackhashes text +); + +CREATE TABLE IF NOT EXISTS favorites ( + id integer PRIMARY KEY, + hash text not null, + type text not null +); +""" + +CREATE_APPDB_TABLES = """ +CREATE TABLE IF NOT EXISTS tracks ( + id integer PRIMARY KEY, + album text NOT NULL, + albumartist text NOT NULL, + albumhash text NOT NULL, + artist text NOT NULL, + bitrate integer NOT NULL, + copyright text, + date text NOT NULL, + disc integer NOT NULL, + duration integer NOT NULL, + filepath text NOT NULL, + folder text NOT NULL, + genre text, + title text NOT NULL, + track integer NOT NULL, + trackhash text NOT NULL +); + +CREATE TABLE IF NOT EXISTS albums ( + id integer PRIMARY KEY, + albumhash text NOT NULL, + colors text NOT NULL +); + + + +CREATE TABLE IF NOT EXISTS artists ( + id integer PRIMARY KEY, + artisthash text NOT NULL, + colors text, + bio text +); + +CREATE TABLE IF NOT EXISTS folders ( + id integer PRIMARY KEY, + path text NOT NULL, + trackcount integer NOT NULL +); +""" diff --git a/app/db/sqlite/tracks.py b/app/db/sqlite/tracks.py new file mode 100644 index 0000000..d83961c --- /dev/null +++ b/app/db/sqlite/tracks.py @@ -0,0 +1,142 @@ +""" +Contains the SQLiteTrackMethods class which contains methods for +interacting with the tracks table. +""" + + +from sqlite3 import Cursor + +from app.db.sqlite.utils import tuple_to_track, tuples_to_tracks + +from .utils import SQLiteManager + + +class SQLiteTrackMethods: + """ + This class contains all methods for interacting with the tracks table. + """ + + @classmethod + def insert_one_track(cls, track: dict, cur: Cursor): + """ + Inserts a single track into the database. + """ + sql = """INSERT INTO tracks( + album, + albumartist, + albumhash, + artist, + bitrate, + copyright, + date, + disc, + duration, + filepath, + folder, + genre, + title, + track, + trackhash + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """ + + cur.execute( + sql, + ( + track["album"], + track["albumartist"], + track["albumhash"], + track["artist"], + track["bitrate"], + track["copyright"], + track["date"], + track["disc"], + track["duration"], + track["filepath"], + track["folder"], + track["genre"], + track["title"], + track["track"], + track["trackhash"], + ), + ) + + @classmethod + def insert_many_tracks(cls, tracks: list[dict]): + """ + Inserts a list of tracks into the database. + """ + with SQLiteManager() as cur: + for track in tracks: + cls.insert_one_track(track, cur) + + @staticmethod + def get_all_tracks(): + """ + Get all tracks from the database and return a generator of Track objects + or an empty list. + """ + with SQLiteManager() as cur: + cur.execute("SELECT * FROM tracks") + rows = cur.fetchall() + + if rows is not None: + return tuples_to_tracks(rows) + + return [] + + @staticmethod + def get_track_by_trackhash(trackhash: str): + """ + Gets a track using its trackhash. Returns a Track object or None. + """ + with SQLiteManager() as cur: + cur.execute("SELECT * FROM tracks WHERE trackhash=?", (trackhash,)) + row = cur.fetchone() + + if row is not None: + return tuple_to_track(row) + + return None + + @staticmethod + def get_tracks_by_trackhashes(hashes: list[str]): + """ + Gets all tracks in a list of trackhashes. + Returns a generator of Track objects or an empty list. + """ + + sql = "SELECT * FROM tracks WHERE trackhash IN ({})".format( + ",".join("?" * len(hashes)) + ) + + with SQLiteManager() as cur: + cur.execute(sql, hashes) + rows = cur.fetchall() + + if rows is not None: + return tuples_to_tracks(rows) + + return [] + + @staticmethod + def remove_track_by_filepath(filepath: str): + """ + Removes a track from the database using its filepath. + """ + with SQLiteManager() as cur: + cur.execute("DELETE FROM tracks WHERE filepath=?", (filepath,)) + + @staticmethod + def track_exists(filepath: str): + """ + Checks if a track exists in the database using its filepath. + """ + with SQLiteManager() as cur: + cur.execute("SELECT * FROM tracks WHERE filepath=?", (filepath,)) + row = cur.fetchone() + + if row is not None: + return True + + return False diff --git a/app/db/sqlite/utils.py b/app/db/sqlite/utils.py new file mode 100644 index 0000000..6e51120 --- /dev/null +++ b/app/db/sqlite/utils.py @@ -0,0 +1,93 @@ +""" +Helper functions for use with the SQLite database. +""" + +import sqlite3 +from sqlite3 import Connection, Cursor + +from app.models import Album, Playlist, Track +from app.settings import APP_DB_PATH, USERDATA_DB_PATH + + +def tuple_to_track(track: tuple): + """ + Takes a tuple and returns a Track object + """ + return Track(*track[1:]) # rowid is removed from the tuple + + +def tuples_to_tracks(tracks: list[tuple]): + """ + Takes a list of tuples and returns a generator that yields a Track object for each tuple + """ + for track in tracks: + yield tuple_to_track(track) + + +def tuple_to_album(album: tuple): + """ + Takes a tuple and returns an Album object + """ + return Album(*album[1:]) # rowid is removed from the tuple + + +def tuples_to_albums(albums: list[tuple]): + """ + Takes a list of tuples and returns a generator that yields an album object for each tuple + """ + for album in albums: + yield tuple_to_album(album) + + +def tuple_to_playlist(playlist: tuple): + """ + Takes a tuple and returns a Playlist object + """ + return Playlist(*playlist) + + +def tuples_to_playlists(playlists: list[tuple]): + """ + Takes a list of tuples and returns a list of Playlist objects + """ + for playlist in playlists: + yield tuple_to_playlist(playlist) + + +class SQLiteManager: + """ + This is a context manager that handles the connection and cursor + for you. It also commits and closes the connection when you're done. + """ + + def __init__(self, conn: Connection | None = None, userdata_db=False) -> None: + """ + When a connection is passed in, don't close the connection, because it's + a connection to the search database [in memory db]. + """ + self.conn: Connection | None = conn + self.CLOSE_CONN = True + self.userdata_db = userdata_db + + if conn: + self.conn = conn + self.CLOSE_CONN = False + + def __enter__(self) -> Cursor: + if self.conn is not None: + return self.conn.cursor() + + db_path = APP_DB_PATH + + if self.userdata_db: + db_path = USERDATA_DB_PATH + + self.conn = sqlite3.connect(db_path) + return self.conn.cursor() + + def __exit__(self, exc_type, exc_value, exc_traceback): + if self.conn: + self.conn.commit() + + if self.CLOSE_CONN: + self.conn.close() diff --git a/app/db/store.py b/app/db/store.py new file mode 100644 index 0000000..13d7ea0 --- /dev/null +++ b/app/db/store.py @@ -0,0 +1,459 @@ +""" +In memory store. +""" +import json +import random +from pathlib import Path + +from tqdm import tqdm + +from app.db.sqlite.albums import SQLiteAlbumMethods as aldb +from app.db.sqlite.artists import SQLiteArtistMethods as ardb +from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb +from app.db.sqlite.tracks import SQLiteTrackMethods as tdb +from app.models import Album, Artist, Folder, Track +from app.utils import ( + UseBisection, + create_folder_hash, + get_all_artists, + remove_duplicates, +) + + +class Store: + """ + This class holds all tracks in memory and provides methods for + interacting with them. + """ + + tracks: list[Track] = [] + folders: list[Folder] = [] + albums: list[Album] = [] + artists: list[Artist] = [] + + @classmethod + def load_all_tracks(cls): + """ + Loads all tracks from the database into the store. + """ + + cls.tracks = list(tdb.get_all_tracks()) + + fav_hashes = favdb.get_fav_tracks() + fav_hashes = [t[1] for t in fav_hashes] + + for track in tqdm(cls.tracks, desc="Loading tracks"): + if track.trackhash in fav_hashes: + track.is_favorite = True + + @classmethod + def add_track(cls, track: Track): + """ + Adds a single track to the store. + """ + + cls.tracks.append(track) + + @classmethod + def add_tracks(cls, tracks: list[Track]): + """ + Adds multiple tracks to the store. + """ + + cls.tracks.extend(tracks) + + @classmethod + def get_tracks_by_trackhashes(cls, trackhashes: list[str]) -> list[Track]: + """ + Returns a list of tracks by their hashes. + """ + + tracks = [] + + for trackhash in trackhashes: + for track in cls.tracks: + if track.trackhash == trackhash: + tracks.append(track) + + return tracks + + @classmethod + def remove_track_by_filepath(cls, filepath: str): + """ + Removes a track from the store by its filepath. + """ + + for track in cls.tracks: + if track.filepath == filepath: + cls.tracks.remove(track) + break + + @classmethod + def count_tracks_by_hash(cls, trackhash: str) -> int: + """ + Counts the number of tracks with a specific hash. + """ + + count = 0 + + for track in cls.tracks: + if track.trackhash == trackhash: + count += 1 + + return count + + # ==================================================== + # =================== FAVORITES ====================== + # ==================================================== + + @classmethod + def add_fav_track(cls, trackhash: str): + """ + Adds a track to the favorites. + """ + + for track in cls.tracks: + if track.trackhash == trackhash: + track.is_favorite = True + + @classmethod + def remove_fav_track(cls, trackhash: str): + """ + Removes a track from the favorites. + """ + + for track in cls.tracks: + if track.trackhash == trackhash: + track.is_favorite = False + + # ==================================================== + # ==================== FOLDERS ======================= + # ==================================================== + + @classmethod + def check_has_tracks(cls, path: str): # type: ignore + """ + Checks if a folder has tracks. + """ + path_hashes = "".join(f.path_hash for f in cls.folders) + path_hash = create_folder_hash(*Path(path).parts[1:]) + + return path_hash in path_hashes + + @classmethod + def is_empty_folder(cls, path: str): + """ + Checks if a folder has tracks using tracks in the store. + """ + + all_folders = set(track.folder for track in cls.tracks) + folder_hashes = "".join( + create_folder_hash(*Path(f).parts[1:]) for f in all_folders + ) + + path_hash = create_folder_hash(*Path(path).parts[1:]) + return path_hash in folder_hashes + + @staticmethod + def create_folder(path: str) -> Folder: + """ + Creates a folder object from a path. + """ + folder = Path(path) + + return Folder( + name=folder.name, + path=str(folder), + is_sym=folder.is_symlink(), + has_tracks=True, + path_hash=create_folder_hash(*folder.parts[1:]), + ) + + @classmethod + def add_folder(cls, path: str): + """ + Adds a folder to the store. + """ + + if cls.check_has_tracks(path): + return + + folder = cls.create_folder(path) + cls.folders.append(folder) + + @classmethod + def remove_folder(cls, path: str): + """ + Removes a folder from the store. + """ + + for folder in cls.folders: + if folder.path == path: + cls.folders.remove(folder) + break + + @classmethod + def process_folders(cls): + """ + Creates a list of folders from the tracks in the store. + """ + all_folders = [track.folder for track in cls.tracks] + all_folders = set(all_folders) + + all_folders = [ + folder for folder in all_folders if not cls.check_has_tracks(folder) + ] + + all_folders = [Path(f) for f in all_folders] + all_folders = [f for f in all_folders if f.exists()] + + for path in tqdm(all_folders, desc="Processing folders"): + folder = cls.create_folder(str(path)) + + cls.folders.append(folder) + + @classmethod + def get_folder(cls, path: str): # type: ignore + """ + Returns a folder object by its path. + """ + folders = sorted(cls.folders, key=lambda x: x.path) + folder = UseBisection(folders, "path", [path])()[0] + + if folder is not None: + return folder + + has_tracks = cls.check_has_tracks(path) + + if not has_tracks: + return None + + folder = cls.create_folder(path) + cls.folders.append(folder) + return folder + + @classmethod + def get_tracks_by_filepaths(cls, paths: list[str]) -> list[Track]: + """ + Returns all tracks matching the given paths. + """ + tracks = sorted(cls.tracks, key=lambda x: x.filepath) + tracks = UseBisection(tracks, "filepath", paths)() + return [track for track in tracks if track is not None] + + @classmethod + def get_tracks_by_albumhash(cls, album_hash: str) -> list[Track]: + """ + Returns all tracks matching the given album hash. + """ + return [t for t in cls.tracks if t.albumhash == album_hash] + + @classmethod + def get_tracks_by_artist(cls, artisthash: str) -> list[Track]: + """ + Returns all tracks matching the given artist. Duplicate tracks are removed. + """ + tracks = [t for t in cls.tracks if artisthash in t.artist_hashes] + return remove_duplicates(tracks) + + # ==================================================== + # ==================== ALBUMS ======================== + # ==================================================== + + @staticmethod + def create_album(track: Track): + """ + Creates album object from a track + """ + return Album( + albumhash=track.albumhash, + albumartists=track.albumartist, # type: ignore + title=track.album, + ) + + @classmethod + def load_albums(cls): + """ + Loads all albums from the database into the store. + """ + + albumhashes = set(t.albumhash for t in cls.tracks) + + for albumhash in tqdm(albumhashes, desc="Loading albums"): + for track in cls.tracks: + if track.albumhash == albumhash: + cls.albums.append(cls.create_album(track)) + break + + db_albums: list[tuple] = aldb.get_all_albums() + + for album in tqdm(db_albums, desc="Mapping album colors"): + albumhash = album[1] + colors = json.loads(album[2]) + + for al in cls.albums: + if al.albumhash == albumhash: + al.set_colors(colors) + break + + @classmethod + def add_album(cls, album: Album): + """ + Adds an album to the store. + """ + cls.albums.append(album) + + @classmethod + def add_albums(cls, albums: list[Album]): + """ + Adds multiple albums to the store. + """ + cls.albums.extend(albums) + + @classmethod + def get_albums_by_albumartist( + cls, artisthash: str, limit: int, exclude: str + ) -> list[Album]: + """ + Returns N albums by the given albumartist, excluding the specified album. + """ + + albums = [album for album in cls.albums if artisthash in album.albumartisthash] + + albums = [album for album in albums if album.albumhash != exclude] + + if len(albums) > limit: + random.shuffle(albums) + + # TODO: Merge this with `cls.get_albums_by_artisthash()` + return albums[:limit] + + @classmethod + def get_album_by_hash(cls, albumhash: str) -> Album | None: + """ + Returns an album by its hash. + """ + try: + return [a for a in cls.albums if a.albumhash == albumhash][0] + except IndexError: + return None + + @classmethod + def get_albums_by_artisthash(cls, artisthash: str) -> list[Album]: + """ + Returns all albums by the given artist. + """ + return [album for album in cls.albums if artisthash in album.albumartisthash] + + @classmethod + def count_albums_by_artisthash(cls, artisthash: str): + """ + Count albums for the given artisthash. + """ + albumartists = [a.albumartists for a in cls.albums] + artisthashes = [] + + for artist in albumartists: + artisthashes.extend([a.artisthash for a in artist]) # type: ignore + + master_string = "-".join(artisthashes) + + return master_string.count(artisthash) + + @classmethod + def album_exists(cls, albumhash: str) -> bool: + """ + Checks if an album exists. + """ + return albumhash in "-".join([a.albumhash for a in cls.albums]) + + @classmethod + def remove_album_by_hash(cls, albumhash: str): + """ + Removes an album from the store. + """ + cls.albums = [a for a in cls.albums if a.albumhash != albumhash] + + # ==================================================== + # ==================== ARTISTS ======================= + # ==================================================== + + @classmethod + def load_artists(cls): + """ + Loads all artists from the database into the store. + """ + cls.artists = get_all_artists(cls.tracks, cls.albums) + + db_artists: list[tuple] = list(ardb.get_all_artists()) + + for art in tqdm(db_artists, desc="Loading artists"): + cls.map_artist_color(art) + + @classmethod + def map_artist_color(cls, artist_tuple: tuple): + """ + Maps a color to the corresponding artist. + """ + + artisthash = artist_tuple[1] + color = json.loads(artist_tuple[2]) + + for artist in cls.artists: + if artist.artisthash == artisthash: + artist.colors = color + break + + @classmethod + def add_artist(cls, artist: Artist): + """ + Adds an artist to the store. + """ + cls.artists.append(artist) + + @classmethod + def add_artists(cls, artists: list[Artist]): + """ + Adds multiple artists to the store. + """ + for artist in artists: + if artist not in cls.artists: + cls.artists.append(artist) + + @classmethod + def get_artist_by_hash(cls, artisthash: str) -> Artist: + """ + Returns an artist by its hash. + """ + artists = sorted(cls.artists, key=lambda x: x.artisthash) + artist = UseBisection(artists, "artisthash", [artisthash])()[0] + return artist + + @classmethod + def artist_exists(cls, artisthash: str) -> bool: + """ + Checks if an artist exists. + """ + return artisthash in "-".join([a.artisthash for a in cls.artists]) + + @classmethod + def artist_has_tracks(cls, artisthash: str) -> bool: + """ + Checks if an artist has tracks. + """ + artists: set[str] = set() + + for track in cls.tracks: + artists.update(track.artist_hashes) + album_artists: list[str] = [a.artisthash for a in track.albumartist] + artists.update(album_artists) + + master_hash = "-".join(artists) + return artisthash in master_hash + + @classmethod + def remove_artist_by_hash(cls, artisthash: str): + """ + Removes an artist from the store. + """ + cls.artists = [a for a in cls.artists if a.artisthash != artisthash] diff --git a/app/functions.py b/app/functions.py new file mode 100644 index 0000000..52e458b --- /dev/null +++ b/app/functions.py @@ -0,0 +1,40 @@ +""" +This module contains functions for the server +""" +import time +from requests import ConnectionError as RequestConnectionError +from requests import ReadTimeout + +from app import utils +from app.lib.artistlib import CheckArtistImages +from app.lib.colorlib import ProcessAlbumColors, ProcessArtistColors +from app.lib.populate import Populate, ProcessTrackThumbnails +from app.lib.trackslib import validate_tracks +from app.logger import log + + +@utils.background +def run_periodic_checks(): + """ + Checks for new songs every N minutes. + """ + # ValidateAlbumThumbs() + # ValidatePlaylistThumbs() + validate_tracks() + + while True: + + Populate() + ProcessTrackThumbnails() + ProcessAlbumColors() + ProcessArtistColors() + + if utils.Ping()(): + try: + CheckArtistImages() + except (RequestConnectionError, ReadTimeout): + log.error( + "Internet connection lost. Downloading artist images stopped." + ) + + time.sleep(300) diff --git a/app/imgserver/__init__.py b/app/imgserver/__init__.py new file mode 100644 index 0000000..228ac36 --- /dev/null +++ b/app/imgserver/__init__.py @@ -0,0 +1,114 @@ +import os +from pathlib import Path + +from flask import Blueprint, request, send_from_directory + +imgbp = Blueprint("imgserver", __name__, url_prefix="/img") +SUPPORTED_IMAGES = (".jpg", ".png", ".webp", ".jpeg") + +HOME = os.path.expanduser("~") + +APP_DIR = Path(HOME) / ".swing" +IMG_PATH = APP_DIR / "images" +ASSETS_PATH = APP_DIR / "assets" + +THUMB_PATH = IMG_PATH / "thumbnails" +LG_THUMB_PATH = THUMB_PATH / "large" +SM_THUMB_PATH = THUMB_PATH / "small" + +ARTIST_PATH = IMG_PATH / "artists" +ARTIST_LG_PATH = ARTIST_PATH / "large" +ARTIST_SM_PATH = ARTIST_PATH / "small" + +PLAYLIST_PATH = IMG_PATH / "playlists" + + +@imgbp.route("/") +def hello(): + return "

Image Server

" + + +def send_fallback_img(filename: str = "default.webp"): + img = ASSETS_PATH / filename + + if not img.exists(): + return "", 404 + + return send_from_directory(ASSETS_PATH, filename) + + +@imgbp.route("/t/") +def send_lg_thumbnail(imgpath: str): + fpath = LG_THUMB_PATH / imgpath + + if fpath.exists(): + return send_from_directory(LG_THUMB_PATH, imgpath) + + return send_fallback_img() + + +@imgbp.route("/t/s/") +def send_sm_thumbnail(imgpath: str): + fpath = SM_THUMB_PATH / imgpath + + if fpath.exists(): + return send_from_directory(SM_THUMB_PATH, imgpath) + + return send_fallback_img() + + +@imgbp.route("/a/") +def send_lg_artist_image(imgpath: str): + fpath = ARTIST_LG_PATH / imgpath + + if fpath.exists(): + return send_from_directory(ARTIST_LG_PATH, imgpath) + + return send_fallback_img("artist.webp") + + +@imgbp.route("/a/s/") +def send_sm_artist_image(imgpath: str): + fpath = ARTIST_SM_PATH / imgpath + + if fpath.exists(): + return send_from_directory(ARTIST_SM_PATH, imgpath) + + return send_fallback_img("artist.webp") + + +@imgbp.route("/p/") +def send_playlist_image(imgpath: str): + fpath = PLAYLIST_PATH / imgpath + + if fpath.exists(): + return send_from_directory(PLAYLIST_PATH, imgpath) + + return send_fallback_img("playlist.svg") + + +# @app.route("/raw") +# @app.route("/raw/") +# def send_from_filepath(imgpath: str = ""): +# imgpath = "/" + imgpath +# filename = path.basename(imgpath) + +# def verify_is_image(): +# _, ext = path.splitext(filename) +# return ext in SUPPORTED_IMAGES + +# verified = verify_is_image() + +# if not verified: +# return imgpath, 404 + +# exists = path.exists(imgpath) + +# if verified and exists: +# return send_from_directory(path.dirname(imgpath), filename) + +# return imgpath, 404 + + +# def serve_imgs(): +# app.run(threaded=True, port=1971, host="0.0.0.0", debug=True) diff --git a/app/lib/__init__.py b/app/lib/__init__.py new file mode 100644 index 0000000..d1f9259 --- /dev/null +++ b/app/lib/__init__.py @@ -0,0 +1,3 @@ +""" +This module contains all the data processing and non-API libraries +""" \ No newline at end of file diff --git a/app/lib/albumslib.py b/app/lib/albumslib.py new file mode 100644 index 0000000..e154c93 --- /dev/null +++ b/app/lib/albumslib.py @@ -0,0 +1,3 @@ +""" +Contains methods relating to albums. +""" \ No newline at end of file diff --git a/app/lib/artistlib.py b/app/lib/artistlib.py new file mode 100644 index 0000000..77f7835 --- /dev/null +++ b/app/lib/artistlib.py @@ -0,0 +1,130 @@ +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from io import BytesIO +from PIL import Image +import requests +import urllib + +from tqdm import tqdm +from requests.exceptions import ConnectionError as ReqConnError, ReadTimeout + +from app import settings +from app.models import Artist +from app.db.store import Store +from app.utils import create_hash + + +def get_artist_image_link(artist: str): + """ + Returns an artist image url. + """ + + try: + query = urllib.parse.quote(artist) # type: ignore + + url = f"https://api.deezer.com/search/artist?q={query}" + response = requests.get(url, timeout=30) + data = response.json() + + for res in data["data"]: + res_hash = create_hash(res["name"], decode=True) + artist_hash = create_hash(artist, decode=True) + + if res_hash == artist_hash: + return res["picture_big"] + + return None + except (ReqConnError, ReadTimeout, IndexError, KeyError): + return None + + +class DownloadImage: + def __init__(self, url: str, name: str) -> None: + sm_path = Path(settings.ARTIST_IMG_SM_PATH) / name + lg_path = Path(settings.ARTIST_IMG_LG_PATH) / name + + img = self.download(url) + + if img is not None: + self.save_img(img, sm_path, lg_path) + + @staticmethod + def download(url: str) -> Image.Image | None: + """ + Downloads the image from the url. + """ + return Image.open(BytesIO(requests.get(url, timeout=10).content)) + + @staticmethod + def save_img(img: Image.Image, sm_path: Path, lg_path: Path): + """ + Saves the image to the destinations. + """ + img.save(lg_path, format="webp") + + sm_size = settings.SM_ARTIST_IMG_SIZE + img.resize((sm_size, sm_size), Image.ANTIALIAS).save(sm_path, format="webp") + + +class CheckArtistImages: + def __init__(self): + with ThreadPoolExecutor() as pool: + list( + tqdm( + pool.map(self.download_image, Store.artists), + total=len(Store.artists), + desc="Downloading artist images", + ) + ) + + @staticmethod + def download_image(artist: Artist): + """ + Checks if an artist image exists and downloads it if not. + + :param artistname: The artist name + """ + img_path = Path(settings.ARTIST_IMG_SM_PATH) / f"{artist.artisthash}.webp" + + if img_path.exists(): + return + + url = get_artist_image_link(artist.name) + + if url is not None: + return DownloadImage(url, name=f"{artist.artisthash}.webp") + + +# def fetch_album_bio(title: str, albumartist: str) -> str | None: +# """ +# Returns the album bio for a given album. +# """ +# last_fm_url = "http://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key={}&artist={}&album={}&format=json".format( +# settings.LAST_FM_API_KEY, albumartist, title +# ) + +# try: +# response = requests.get(last_fm_url) +# data = response.json() +# except: +# return None + +# try: +# bio = data["album"]["wiki"]["summary"].split(' list[str]: + """Extracts 2 of the most dominant colors from an image.""" + try: + colors = sorted(colorgram.extract(image, 1), key=lambda c: c.hsl.h) + except OSError: + return [] + + formatted_colors = [] + + for color in colors: + color = f"rgb({color.rgb.r}, {color.rgb.g}, {color.rgb.b})" + formatted_colors.append(color) + + return formatted_colors + + +class ProcessAlbumColors: + """ + Extracts the most dominant color from the album art and saves it to the database. + """ + + def __init__(self) -> None: + + with SQLiteManager() as cur: + for album in tqdm(Store.albums, desc="Processing album colors"): + if len(album.colors) == 0: + colors = self.process_color(album) + + if colors is None: + continue + + album.set_colors(colors) + + color_str = json.dumps(colors) + db.insert_one_album(cur, album.albumhash, color_str) + + @staticmethod + def process_color(album: Album): + path = Path(settings.SM_THUMB_PATH) / album.image + + if not path.exists(): + return + + colors = get_image_colors(str(path)) + return colors + + +class ProcessArtistColors: + """ + Extracts the most dominant color from the artist art and saves it to the database. + """ + + def __init__(self) -> None: + all_artists = Store.artists + + if all_artists is None: + return + + for artist in tqdm(all_artists, desc="Processing artist colors"): + if len(artist.colors) == 0: + self.process_color(artist) + + @staticmethod + def process_color(artist: Artist): + path = Path(settings.ARTIST_IMG_SM_PATH) / artist.image + + if not path.exists(): + return + + colors = get_image_colors(str(path)) + + if len(colors) > 0: + adb.insert_one_artist(artisthash=artist.artisthash, colors=colors) + Store.map_artist_color((0, artist.artisthash, json.dumps(colors))) + + # TODO: Load album and artist colors into the store. diff --git a/app/lib/folderslib.py b/app/lib/folderslib.py new file mode 100644 index 0000000..547ac75 --- /dev/null +++ b/app/lib/folderslib.py @@ -0,0 +1,47 @@ +import os +import pathlib +from concurrent.futures import ThreadPoolExecutor + +from app.db.store import Store +from app.models import Folder, Track +from app.settings import SUPPORTED_FILES + + +class GetFilesAndDirs: + """ + Get files and folders from a directory. + """ + + def __init__(self, path: str) -> None: + self.path = path + + def __call__(self) -> tuple[list[Track], list[Folder]]: + try: + entries = os.scandir(self.path) + except FileNotFoundError: + return ([], []) + + dirs, files = [], [] + + for entry in entries: + ext = os.path.splitext(entry.name)[1].lower() + + if entry.is_dir() and not entry.name.startswith("."): + dirs.append(entry.path) + elif entry.is_file() and ext in SUPPORTED_FILES: + files.append(entry.path) + + # sort files by modified time + files.sort( + key=lambda f: os.path.getmtime(f) # pylint: disable=unnecessary-lambda + ) + + tracks = Store.get_tracks_by_filepaths(files) + + with ThreadPoolExecutor() as pool: + iterable = pool.map(Store.get_folder, dirs) + folders = [i for i in iterable if i is not None] + + folders = filter(lambda f: f.has_tracks, folders) + + return (tracks, folders) # type: ignore diff --git a/app/lib/playlistlib.py b/app/lib/playlistlib.py new file mode 100644 index 0000000..6043abb --- /dev/null +++ b/app/lib/playlistlib.py @@ -0,0 +1,115 @@ +""" +This library contains all the functions related to playlists. +""" +import os +import random +import string +from datetime import datetime +from typing import Any + +from PIL import Image, ImageSequence + +from app import settings +from app.logger import log + + +def create_thumbnail(image: Any, img_path: str) -> str: + """ + Creates a 250 x 250 thumbnail from a playlist image + """ + thumb_path = "thumb_" + img_path + full_thumb_path = os.path.join(settings.APP_DIR, "images", "playlists", thumb_path) + + aspect_ratio = image.width / image.height + + new_w = round(250 * aspect_ratio) + + thumb = image.resize((new_w, 250), Image.ANTIALIAS) + thumb.save(full_thumb_path, "webp") + + return thumb_path + + +def create_gif_thumbnail(image: Any, img_path: str): + """ + Creates a 250 x 250 thumbnail from a playlist image + """ + thumb_path = "thumb_" + img_path + full_thumb_path = os.path.join(settings.APP_DIR, "images", "playlists", thumb_path) + + frames = [] + + for frame in ImageSequence.Iterator(image): + aspect_ratio = frame.width / frame.height + + new_w = round(250 * aspect_ratio) + + thumb = frame.resize((new_w, 250), Image.ANTIALIAS) + frames.append(thumb) + + frames[0].save(full_thumb_path, save_all=True, append_images=frames[1:]) + + return thumb_path + + +def save_p_image(file, pid: str): + """ + Saves the image of a playlist to the database. + """ + img = Image.open(file) + + random_str = "".join(random.choices(string.ascii_letters + string.digits, k=5)) + + img_path = pid + str(random_str) + ".webp" + + full_img_path = os.path.join(settings.APP_DIR, "images", "playlists", img_path) + + if file.content_type == "image/gif": + frames = [] + + for frame in ImageSequence.Iterator(img): + frames.append(frame.copy()) + + frames[0].save(full_img_path, save_all=True, append_images=frames[1:]) + create_gif_thumbnail(img, img_path=img_path) + + return img_path + + img.save(full_img_path, "webp") + create_thumbnail(img, img_path=img_path) + + return img_path + + +class ValidatePlaylistThumbs: + """ + Removes all unused images in the images/playlists folder. + """ + + def __init__(self) -> None: + images = [] + playlists = Get.get_all_playlists() + + log.info("Validating playlist thumbnails") + for playlist in playlists: + if playlist.image: + img_path = playlist.image.split("/")[-1] + thumb_path = playlist.thumb.split("/")[-1] + + images.append(img_path) + images.append(thumb_path) + + p_path = os.path.join(settings.APP_DIR, "images", "playlists") + + for image in os.listdir(p_path): + if image not in images: + os.remove(os.path.join(p_path, image)) + + log.info("Validating playlist thumbnails ... ✅") + + +def create_new_date(): + return datetime.now() + + +# TODO: Fix ValidatePlaylistThumbs diff --git a/app/lib/populate.py b/app/lib/populate.py new file mode 100644 index 0000000..d542075 --- /dev/null +++ b/app/lib/populate.py @@ -0,0 +1,100 @@ +from concurrent.futures import ThreadPoolExecutor +from tqdm import tqdm + +from app import settings +from app.db.sqlite.tracks import SQLiteTrackMethods +from app.db.store import Store + +from app.lib.taglib import extract_thumb, get_tags +from app.logger import log +from app.models import Album, Artist, Track +from app.utils import run_fast_scandir + +get_all_tracks = SQLiteTrackMethods.get_all_tracks +insert_many_tracks = SQLiteTrackMethods.insert_many_tracks + + +class Populate: + """ + Populates the database with all songs in the music directory + + checks if the song is in the database, if not, it adds it + also checks if the album art exists in the image path, if not tries to extract it. + """ + + def __init__(self) -> None: + + tracks = get_all_tracks() + tracks = list(tracks) + + files = run_fast_scandir(settings.HOME_DIR, full=True)[1] + + untagged = self.filter_untagged(tracks, files) + + if len(untagged) == 0: + log.info("All clear, no unread files.") + return + + self.tag_untagged(untagged) + + @staticmethod + def filter_untagged(tracks: list[Track], files: list[str]): + tagged_files = [t.filepath for t in tracks] + return set(files) - set(tagged_files) + + @staticmethod + def tag_untagged(untagged: set[str]): + log.info("Found %s new tracks", len(untagged)) + tagged_tracks: list[dict] = [] + tagged_count = 0 + + for file in tqdm(untagged, desc="Reading files"): + tags = get_tags(file) + + if tags is not None: + tagged_tracks.append(tags) + track = Track(**tags) + + Store.add_track(track) + Store.add_folder(track.folder) + + if not Store.album_exists(track.albumhash): + Store.add_album(Store.create_album(track)) + + for artist in track.artist: + if not Store.artist_exists(artist.artisthash): + Store.add_artist(Artist(artist.name)) + + for artist in track.albumartist: + if not Store.artist_exists(artist.artisthash): + Store.add_artist(Artist(artist.name)) + + tagged_count += 1 + else: + log.warning("Could not read file: %s", file) + + if len(tagged_tracks) > 0: + insert_many_tracks(tagged_tracks) + + log.info("Added %s/%s tracks", tagged_count, len(untagged)) + + +def get_image(album: Album): + for track in Store.tracks: + if track.albumhash == album.albumhash: + extract_thumb(track.filepath, track.image) + break + + +class ProcessTrackThumbnails: + def __init__(self) -> None: + with ThreadPoolExecutor(max_workers=4) as pool: + results = list( + tqdm( + pool.map(get_image, Store.albums), + total=len(Store.albums), + desc="Extracting track images", + ) + ) + + results = [r for r in results] diff --git a/app/lib/searchlib.py b/app/lib/searchlib.py new file mode 100644 index 0000000..b77c125 --- /dev/null +++ b/app/lib/searchlib.py @@ -0,0 +1,125 @@ +""" +This library contains all the functions related to the search functionality. +""" +from typing import List + +from rapidfuzz import fuzz, process + +from app import models + +ratio = fuzz.ratio +wratio = fuzz.WRatio + + +class Cutoff: + """ + Holds all the default cutoff values. + """ + + tracks: int = 60 + albums: int = 60 + artists: int = 60 + playlists: int = 60 + + +class Limit: + """ + Holds all the default limit values. + """ + + tracks: int = 50 + albums: int = 50 + artists: int = 50 + playlists: int = 50 + + +class SearchTracks: + def __init__(self, tracks: List[models.Track], query: str) -> None: + self.query = query + self.tracks = tracks + + def __call__(self) -> List[models.Track]: + """ + Gets all songs with a given title. + """ + + tracks = [track.title for track in self.tracks] + results = process.extract( + self.query, + tracks, + scorer=fuzz.WRatio, + score_cutoff=Cutoff.tracks, + limit=Limit.tracks, + ) + + return [self.tracks[i[2]] for i in results] + + +class SearchArtists: + def __init__(self, artists: list[str], query: str) -> None: + self.query = query + self.artists = artists + + def __call__(self) -> list: + """ + Gets all artists with a given name. + """ + + results = process.extract( + self.query, + self.artists, + scorer=fuzz.WRatio, + score_cutoff=Cutoff.artists, + limit=Limit.artists, + ) + + artists = [a[0] for a in results] + return [models.Artist(a) for a in artists] + + +class SearchAlbums: + def __init__(self, albums: List[models.Album], query: str) -> None: + self.query = query + self.albums = albums + + def __call__(self) -> List[models.Album]: + """ + Gets all albums with a given title. + """ + + albums = [a.title.lower() for a in self.albums] + + results = process.extract( + self.query, + albums, + scorer=fuzz.WRatio, + score_cutoff=Cutoff.albums, + limit=Limit.albums, + ) + + return [self.albums[i[2]] for i in results] + + # get all artists that matched the query + # for get all albums from the artists + # get all albums that matched the query + # return [**artist_albums **albums] + + # recheck next and previous artist on play next or add to playlist + + +class SearchPlaylists: + def __init__(self, playlists: List[models.Playlist], query: str) -> None: + self.playlists = playlists + self.query = query + + def __call__(self) -> List[models.Playlist]: + playlists = [p.name for p in self.playlists] + results = process.extract( + self.query, + playlists, + scorer=fuzz.WRatio, + score_cutoff=Cutoff.playlists, + limit=Limit.playlists, + ) + + return [self.playlists[i[2]] for i in results] diff --git a/app/lib/taglib.py b/app/lib/taglib.py new file mode 100644 index 0000000..5d975d2 --- /dev/null +++ b/app/lib/taglib.py @@ -0,0 +1,159 @@ +import os +import datetime +from io import BytesIO + +from tinytag import TinyTag +from PIL import Image, UnidentifiedImageError + +from app import settings +from app.utils import create_hash + + + +def parse_album_art(filepath: str): + """ + Returns the album art for a given audio file. + """ + + try: + tags = TinyTag.get(filepath, image=True) + return tags.get_image() + except: # pylint: disable=bare-except + return None + + +def extract_thumb(filepath: str, webp_path: str) -> bool: + """ + Extracts the thumbnail from an audio file. Returns the path to the thumbnail. + """ + img_path = os.path.join(settings.LG_THUMBS_PATH, webp_path) + sm_img_path = os.path.join(settings.SM_THUMB_PATH, webp_path) + + tsize = settings.THUMB_SIZE + sm_tsize = settings.SM_THUMB_SIZE + + def save_image(img: Image.Image): + img.resize((sm_tsize, sm_tsize), Image.ANTIALIAS).save(sm_img_path, "webp") + img.resize((tsize, tsize), Image.ANTIALIAS).save(img_path, "webp") + + if os.path.exists(img_path): + img_size = os.path.getsize(img_path) + + if img_size > 0: + return True + + album_art = parse_album_art(filepath) + + if album_art is not None: + try: + img = Image.open(BytesIO(album_art)) + except (UnidentifiedImageError, OSError): + return False + + try: + save_image(img) + except OSError: + try: + png = img.convert("RGB") + save_image(png) + except: # pylint: disable=bare-except + return False + + return True + return False + + +def extract_date(date_str: str | None) -> int: + current_year = datetime.date.today().today().year + + if date_str is None: + return current_year + + try: + return int(date_str.split("-")[0]) + except: # pylint: disable=bare-except + return current_year + + +def get_tags(filepath: str): + filetype = filepath.split(".")[-1] + filename = (filepath.split("/")[-1]).replace(f".{filetype}", "") + + try: + tags = TinyTag.get(filepath) + except: # pylint: disable=bare-except + return None + + no_albumartist: bool = (tags.albumartist == "") or (tags.albumartist is None) + no_artist: bool = (tags.artist == "") or (tags.artist is None) + + if no_albumartist and not no_artist: + tags.albumartist = tags.artist + + if no_artist and not no_albumartist: + tags.artist = tags.albumartist + + to_filename = ["title", "album"] + for tag in to_filename: + p = getattr(tags, tag) + if p == "" or p is None: + setattr(tags, tag, filename) + + to_check = ["album", "artist", "year", "albumartist"] + for prop in to_check: + p = getattr(tags, prop) + if (p is None) or (p == ""): + setattr(tags, prop, "Unknown") + + to_round = ["bitrate", "duration"] + for prop in to_round: + try: + setattr(tags, prop, round(getattr(tags, prop))) + except TypeError: + setattr(tags, prop, 0) + + to_int = ["track", "disc"] + for prop in to_int: + try: + setattr(tags, prop, int(getattr(tags, prop))) + except (ValueError, TypeError): + setattr(tags, prop, 1) + + try: + tags.copyright = tags.extra["copyright"] + except KeyError: + tags.copyright = None + + tags.albumhash = create_hash(tags.album, tags.albumartist) + tags.trackhash = create_hash(tags.artist, tags.album, tags.title) + tags.image = f"{tags.albumhash}.webp" + tags.folder = os.path.dirname(filepath) + + tags.date = extract_date(tags.year) + tags.filepath = filepath + tags.filetype = filetype + + tags = tags.__dict__ + + # delete all tag properties that start with _ (tinytag internals) + for tag in list(tags): + if tag.startswith("_"): + del tags[tag] + + to_delete = [ + "filesize", + "audio_offset", + "channels", + "comment", + "composer", + "disc_total", + "extra", + "samplerate", + "track_total", + "year", + ] + + for tag in to_delete: + del tags[tag] + + return tags diff --git a/app/lib/trackslib.py b/app/lib/trackslib.py new file mode 100644 index 0000000..faa166b --- /dev/null +++ b/app/lib/trackslib.py @@ -0,0 +1,19 @@ +""" +This library contains all the functions related to tracks. +""" +import os + +from tqdm import tqdm + +from app.db.store import Store +from app.db.sqlite.tracks import SQLiteTrackMethods as tdb + + +def validate_tracks() -> None: + """ + Gets all songs under the ~/ directory. + """ + for track in tqdm(Store.tracks, desc="Removing deleted tracks"): + if not os.path.exists(track.filepath): + Store.tracks.remove(track) + tdb.remove_track_by_filepath(track.filepath) diff --git a/app/lib/watchdogg.py b/app/lib/watchdogg.py new file mode 100644 index 0000000..276f6ba --- /dev/null +++ b/app/lib/watchdogg.py @@ -0,0 +1,172 @@ +""" +This library contains the classes and functions related to the watchdog file watcher. +""" +import os +import time + +from watchdog.events import PatternMatchingEventHandler +from watchdog.observers import Observer + +from app.db.sqlite.tracks import SQLiteManager +from app.db.sqlite.tracks import SQLiteTrackMethods as db +from app.db.store import Store +from app.lib.taglib import get_tags +from app.logger import log +from app.models import Artist, Track + + +class Watcher: + """ + Contains the methods for initializing and starting watchdog. + """ + + home_dir = os.path.expanduser("~") + dirs = [home_dir] + observers: list[Observer] = [] + + def __init__(self): + self.observer = Observer() + + def run(self): + event_handler = Handler() + + for dir_ in self.dirs: + self.observer.schedule( + event_handler, os.path.realpath(dir_), recursive=True + ) + self.observers.append(self.observer) + + try: + self.observer.start() + except OSError: + log.error("Could not start watchdog.") + return + + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + for obsv in self.observers: + obsv.unschedule_all() + obsv.stop() + + for obsv in self.observers: + obsv.join() + + +def add_track(filepath: str) -> None: + """ + Processes the audio tags for a given file ands add them to the database and store. + + Then creates the folder, album and artist objects for the added track and adds them to the store. + """ + tags = get_tags(filepath) + + if tags is None: + return + + with SQLiteManager() as cur: + db.remove_track_by_filepath(tags["filepath"]) + db.insert_one_track(tags, cur) + + track = Track(**tags) + Store().add_track(track) + + Store.add_folder(track.folder) + + if not Store.album_exists(track.albumhash): + album = Store.create_album(track) + Store.add_album(album) + + artists: list[Artist] = track.artist + track.albumartist # type: ignore + + for artist in artists: + if not Store.artist_exists(artist.artisthash): + Store.add_artist(Artist(artist.name)) + + +def remove_track(filepath: str) -> None: + """ + Removes a track from the music dict. + """ + try: + track = Store.get_tracks_by_filepaths([filepath])[0] + except IndexError: + return + + db.remove_track_by_filepath(filepath) + Store.remove_track_by_filepath(filepath) + + empty_album = Store.count_tracks_by_hash(track.albumhash) > 0 + + if empty_album: + Store.remove_album_by_hash(track.albumhash) + + artists: list[Artist] = track.artist + track.albumartist # type: ignore + + for artist in artists: + empty_artist = not Store.artist_has_tracks(artist.artisthash) + + if empty_artist: + Store.remove_artist_by_hash(artist.artisthash) + + empty_folder = Store.is_empty_folder(track.folder) + + if empty_folder: + Store.remove_folder(track.folder) + + +class Handler(PatternMatchingEventHandler): + files_to_process = [] + + def __init__(self): + log.info("✅ started watchdog") + PatternMatchingEventHandler.__init__( + self, + patterns=["*.flac", "*.mp3"], + ignore_directories=True, + case_sensitive=False, + ) + + def on_created(self, event): + """ + Fired when a supported file is created. + """ + self.files_to_process.append(event.src_path) + + def on_deleted(self, event): + """ + Fired when a delete event occurs on a supported file. + """ + + remove_track(event.src_path) + + def on_moved(self, event): + """ + Fired when a move event occurs on a supported file. + """ + trash = "share/Trash" + + if trash in event.dest_path: + remove_track(event.src_path) + + elif trash in event.src_path: + add_track(event.dest_path) + + elif trash not in event.dest_path and trash not in event.src_path: + add_track(event.dest_path) + remove_track(event.src_path) + + def on_closed(self, event): + """ + Fired when a created file is closed. + """ + try: + self.files_to_process.remove(event.src_path) + if os.path.getsize(event.src_path) > 0: + add_track(event.src_path) + except ValueError: + pass + + +# watcher = Watcher() diff --git a/app/logger.py b/app/logger.py new file mode 100644 index 0000000..aec7263 --- /dev/null +++ b/app/logger.py @@ -0,0 +1,49 @@ +""" +Logger module +""" + +import logging + + +class CustomFormatter(logging.Formatter): + """ + Custom log formatter + """ + + grey = "\x1b[38;20m" + yellow = "\x1b[33;20m" + red = "\x1b[31;20m" + bold_red = "\x1b[31;1m" + reset = "\x1b[0m" + # format = ( + # "%(asctime)s - %(name)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)" + # ) + format_ = "[%(asctime)s]@%(name)s • %(message)s" + + FORMATS = { + logging.DEBUG: grey + format_ + reset, + logging.INFO: grey + format_ + reset, + logging.WARNING: yellow + format_ + reset, + logging.ERROR: red + format_ + reset, + logging.CRITICAL: bold_red + format_ + reset, + } + + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno) + formatter = logging.Formatter(log_fmt, "%H:%M:%S") + return formatter.format(record) + + +log = logging.getLogger("swing") +log.propagate = False +log.setLevel(logging.DEBUG) + +# create console handler with a higher log level +handler = logging.StreamHandler() +handler.setLevel(logging.DEBUG) + +handler.setFormatter(CustomFormatter()) +log.addHandler(handler) + + +# copied from: https://stackoverflow.com/a/56944256: diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..650ad8f --- /dev/null +++ b/app/models.py @@ -0,0 +1,206 @@ +""" +Contains all the models for objects generation and typing. +""" +import dataclasses +import json +from dataclasses import dataclass + +from app import utils + + +@dataclass(slots=True) +class Artist: + """ + Artist class + """ + + name: str + artisthash: str = "" + image: str = "" + trackcount: int = 0 + albumcount: int = 0 + duration: int = 0 + colors: list[str] = dataclasses.field(default_factory=list) + is_favorite: bool = False + + def __post_init__(self): + self.artisthash = utils.create_hash(self.name, decode=True) + self.image = self.artisthash + ".webp" + self.colors = json.loads(str(self.colors)) + + +@dataclass(slots=True) +class Track: + """ + Track class + """ + + album: str + albumartist: str | list[Artist] + albumhash: str + artist: str | list[Artist] + bitrate: int + copyright: str + date: str + disc: int + duration: int + filepath: str + folder: str + genre: str | list[str] + title: str + track: int + trackhash: str + + filetype: str = "" + image: str = "" + artist_hashes: list[str] = dataclasses.field(default_factory=list) + is_favorite: bool = False + + def __post_init__(self): + if self.artist is not None: + artist_str = str(self.artist).split(", ") + self.artist_hashes = [utils.create_hash(a, decode=True) for a in artist_str] + + self.artist = [Artist(a) for a in artist_str] + + albumartists = str(self.albumartist).split(", ") + self.albumartist = [Artist(a) for a in albumartists] + + self.filetype = self.filepath.rsplit(".", maxsplit=1)[-1] + self.image = self.albumhash + ".webp" + + if self.genre is not None: + self.genre = str(self.genre).replace("/", ", ") + self.genre = str(self.genre).lower().split(", ") + + +@dataclass +class Album: + """ + Creates an album object + """ + + albumhash: str + title: str + albumartists: list[Artist] + + albumartisthash: str = "" + image: str = "" + count: int = 0 + duration: int = 0 + colors: list[str] = dataclasses.field(default_factory=list) + date: str = "" + + is_soundtrack: bool = False + is_compilation: bool = False + is_single: bool = False + is_EP: bool = False + is_favorite: bool = False + genres: list[str] = dataclasses.field(default_factory=list) + + def __post_init__(self): + self.image = self.albumhash + ".webp" + self.albumartisthash = "-".join(a.artisthash for a in self.albumartists) + + def set_colors(self, colors: list[str]): + self.colors = colors + + def check_type(self): + """ + Runs all the checks to determine the type of album. + """ + self.is_soundtrack = self.check_is_soundtrack() + if self.is_soundtrack: + return + + self.is_compilation = self.check_is_compilation() + if self.is_compilation: + return + + self.is_EP = self.check_is_EP() + + def check_is_soundtrack(self) -> bool: + """ + Checks if the album is a soundtrack. + """ + keywords = ["motion picture", "soundtrack"] + for keyword in keywords: + if keyword in self.title.lower(): + return True + + return False + + def check_is_compilation(self) -> bool: + """ + Checks if the album is a compilation. + """ + artists = [a.name for a in self.albumartists] # type: ignore + artists = "".join(artists).lower() + + return "various artists" in artists + + def check_is_EP(self) -> bool: + """ + Checks if the album is an EP. + """ + return self.title.strip().endswith(" EP") + + def check_is_single(self, tracks: list[Track]): + """ + Checks if the album is a single. + """ + if ( + len(tracks) == 1 + and tracks[0].title == self.title + and tracks[0].track == 1 + and tracks[0].disc == 1 + ): + self.is_single = True + + +@dataclass +class Playlist: + """Creates playlist objects""" + + id: int + artisthashes: str | list[str] + banner_pos: int + has_gif: str | bool + image: str + last_updated: str + name: str + trackhashes: str | list[str] + + thumb: str = "" + count: int = 0 + duration: int = 0 + + def __post_init__(self): + self.trackhashes = json.loads(str(self.trackhashes)) + self.artisthashes = json.loads(str(self.artisthashes)) + + self.count = len(self.trackhashes) + self.has_gif = bool(int(self.has_gif)) + + if self.image is not None: + self.thumb = "thumb_" + self.image + else: + self.image = "None" + self.thumb = "None" + + +@dataclass +class Folder: + name: str + path: str + has_tracks: bool + is_sym: bool = False + path_hash: str = "" + + +class FavType: + """Favorite types enum""" + + track = "track" + album = "album" + artist = "artist" diff --git a/app/serializer.py b/app/serializer.py new file mode 100644 index 0000000..66ac624 --- /dev/null +++ b/app/serializer.py @@ -0,0 +1,55 @@ +from datetime import datetime + + +def date_string_to_time_passed(prev_date: str) -> str: + """ + Converts a date string to time passed. eg. 2 minutes ago, 1 hour ago, yesterday, 2 days ago, 2 weeks ago, etc. + """ + + now = datetime.now() + then = datetime.strptime(prev_date, "%Y-%m-%d %H:%M:%S") + + diff = now - then + days = diff.days + + if days < 0: + return "in the future" + + if days == 0: + seconds = diff.seconds + + if seconds < 15: + return "now" + + if seconds < 60: + return str(seconds) + " seconds ago" + + if seconds < 3600: + return str(seconds // 60) + " minutes ago" + + return str(seconds // 3600) + " hours ago" + + if days == 1: + return "yesterday" + + if days < 7: + return str(days) + " days ago" + + if days < 30: + if days < 14: + return "1 week ago" + + return str(days // 7) + " weeks ago" + if days < 365: + if days < 60: + return "1 month ago" + + return str(days // 30) + " months ago" + if days > 365: + if days < 730: + return "1 year ago" + + return str(days // 365) + " years ago" + + return "I honestly don't know" + diff --git a/app/settings.py b/app/settings.py new file mode 100644 index 0000000..3307368 --- /dev/null +++ b/app/settings.py @@ -0,0 +1,103 @@ +""" +Contains default configs +""" +import multiprocessing +import os + +APP_VERSION = "Swing v0.0.1.alpha" + +# paths +CONFIG_FOLDER = ".swing" +HOME_DIR = os.path.expanduser("~") + +APP_DIR = os.path.join(HOME_DIR, CONFIG_FOLDER) +IMG_PATH = os.path.join(APP_DIR, "images") + +ARTIST_IMG_PATH = os.path.join(IMG_PATH, "artists") +ARTIST_IMG_SM_PATH = os.path.join(ARTIST_IMG_PATH, "small") +ARTIST_IMG_LG_PATH = os.path.join(ARTIST_IMG_PATH, "large") + +THUMBS_PATH = os.path.join(IMG_PATH, "thumbnails") +SM_THUMB_PATH = os.path.join(THUMBS_PATH, "small") +LG_THUMBS_PATH = os.path.join(THUMBS_PATH, "large") + + +# TEST_DIR = "/home/cwilvx/Downloads/Telegram Desktop" +# TEST_DIR = "/mnt/dfc48e0f-103b-426e-9bf9-f25d3743bc96/Music/Chill/Wolftyla Radio" +# HOME_DIR = TEST_DIR + +# URLS +IMG_BASE_URI = "http://127.0.0.1:8900/images/" +IMG_ARTIST_URI = IMG_BASE_URI + "artists/" +IMG_THUMB_URI = IMG_BASE_URI + "thumbnails/" +IMG_PLAYLIST_URI = IMG_BASE_URI + "playlists/" + +# defaults +DEFAULT_ARTIST_IMG = IMG_ARTIST_URI + "0.webp" + +LAST_FM_API_KEY = "762db7a44a9e6fb5585661f5f2bdf23a" + +CPU_COUNT = multiprocessing.cpu_count() + +THUMB_SIZE = 400 +SM_THUMB_SIZE = 64 +SM_ARTIST_IMG_SIZE = 64 +""" +The size of extracted images in pixels +""" + +LOGGER_ENABLE: bool = True + +FILES = ["flac", "mp3", "wav", "m4a"] +SUPPORTED_FILES = tuple(f".{file}" for file in FILES) + +SUPPORTED_IMAGES = (".jpg", ".png", ".webp", ".jpeg") + +SUPPORTED_DIR_IMAGES = [ + "folder", + "cover", + "album", + "front", +] + +# ===== DB ========= +USE_MONGO = False + + +# ===== SQLite ===== +APP_DB_NAME = "swing.db" +USER_DATA_DB_NAME = "userdata.db" +APP_DB_PATH = os.path.join(APP_DIR, APP_DB_NAME) +USERDATA_DB_PATH = os.path.join(APP_DIR, USER_DATA_DB_NAME) + + +# ===== Store ===== +USE_STORE = True + +HELP_MESSAGE = """ +Usage: swing [options] + +Options: + --build: Build the application + --host: Set the host + --port: Set the port + --help, -h: Show this help message + --version, -v: Show the version +""" + + +class TCOLOR: + """ + Terminal colors + """ + + HEADER = "\033[95m" + OKBLUE = "\033[94m" + OKCYAN = "\033[96m" + OKGREEN = "\033[92m" + WARNING = "\033[93m" + FAIL = "\033[91m" + ENDC = "\033[0m" + BOLD = "\033[1m" + UNDERLINE = "\033[4m" + # credits: https://stackoverflow.com/a/287944 diff --git a/app/setup/__init__.py b/app/setup/__init__.py new file mode 100644 index 0000000..9e3c9ff --- /dev/null +++ b/app/setup/__init__.py @@ -0,0 +1,128 @@ +""" +Contains the functions to prepare the server for use. +""" +import os +import shutil +from configparser import ConfigParser + +from app import settings +from app.db.sqlite import create_connection, create_tables, queries +from app.db.store import Store +from app.settings import APP_DB_PATH, USERDATA_DB_PATH +from app.utils import get_home_res_path + + +config = ConfigParser() + +config_path = get_home_res_path("pyinstaller.config.ini") +config.read(config_path) + + +try: + IS_BUILD = config["DEFAULT"]["BUILD"] == "True" +except KeyError: + # If the key doesn't exist, it means that the app is being executed in dev mode. + IS_BUILD = False + + +class CopyFiles: + """Copies assets to the app directory.""" + + def __init__(self) -> None: + assets_dir = "assets" + + if IS_BUILD: + assets_dir = get_home_res_path("assets") + + files = [ + { + "src": assets_dir, + "dest": os.path.join(settings.APP_DIR, "assets"), + "is_dir": True, + } + ] + + for entry in files: + src = os.path.join(os.getcwd(), entry["src"]) + + if entry["is_dir"]: + shutil.copytree( + src, + entry["dest"], + ignore=shutil.ignore_patterns( + "*.pyc", + ), + copy_function=shutil.copy2, + dirs_exist_ok=True, + ) + break + + shutil.copy2(src, entry["dest"]) + + +def create_config_dir() -> None: + """ + Creates the config directory if it doesn't exist. + """ + + home_dir = os.path.expanduser("~") + config_folder = os.path.join(home_dir, settings.CONFIG_FOLDER) + + thumb_path = os.path.join("images", "thumbnails") + small_thumb_path = os.path.join(thumb_path, "small") + large_thumb_path = os.path.join(thumb_path, "large") + + artist_img_path = os.path.join("images", "artists") + small_artist_img_path = os.path.join(artist_img_path, "small") + large_artist_img_path = os.path.join(artist_img_path, "large") + + playlist_img_path = os.path.join("images", "playlists") + + dirs = [ + "", # creates the config folder + "images", + thumb_path, + small_thumb_path, + large_thumb_path, + artist_img_path, + small_artist_img_path, + large_artist_img_path, + playlist_img_path, + ] + + for _dir in dirs: + path = os.path.join(config_folder, _dir) + exists = os.path.exists(path) + + if not exists: + os.makedirs(path) + os.chmod(path, 0o755) + + CopyFiles() + + +def setup_sqlite(): + """ + Create Sqlite databases and tables. + """ + # if os.path.exists(DB_PATH): + # os.remove(DB_PATH) + + app_db_conn = create_connection(APP_DB_PATH) + playlist_db_conn = create_connection(USERDATA_DB_PATH) + + create_tables(app_db_conn, queries.CREATE_APPDB_TABLES) + create_tables(playlist_db_conn, queries.CREATE_USERDATA_TABLES) + + app_db_conn.close() + playlist_db_conn.close() + + Store.load_all_tracks() + Store.process_folders() + Store.load_albums() + Store.load_artists() + + +def run_setup(): + create_config_dir() + setup_sqlite() diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..1f518b0 --- /dev/null +++ b/app/utils.py @@ -0,0 +1,226 @@ +""" +This module contains mini functions for the server. +""" +import os +import hashlib +from pathlib import Path +import threading +from datetime import datetime +from unidecode import unidecode +import requests + +from app import models +from app.settings import SUPPORTED_FILES + +CWD = Path(__file__).parent.resolve() + + +def background(func): + """ + a threading decorator + use @background above the function you want to run in the background + """ + + def background_func(*a, **kw): + threading.Thread(target=func, args=a, kwargs=kw).start() + + return background_func + + +def run_fast_scandir(__dir: str, full=False) -> tuple[list[str], list[str]]: + """ + Scans a directory for files with a specific extension. Returns a list of files and folders in the directory. + """ + + subfolders = [] + files = [] + + for f in os.scandir(__dir): + if f.is_dir() and not f.name.startswith("."): + subfolders.append(f.path) + if f.is_file(): + ext = os.path.splitext(f.name)[1].lower() + if ext in SUPPORTED_FILES: + files.append(f.path) + + if full or len(files) == 0: + for _dir in list(subfolders): + sf, f = run_fast_scandir(_dir, full=True) + subfolders.extend(sf) + files.extend(f) + + return subfolders, files + + +def remove_duplicates(tracks: list[models.Track]) -> list[models.Track]: + """ + Removes duplicate tracks from a list of tracks. + """ + hashes = [] + + for track in tracks: + if track.trackhash not in hashes: + hashes.append(track.trackhash) + + tracks = sorted(tracks, key=lambda x: x.trackhash) + tracks = UseBisection(tracks, "trackhash", hashes)() + + return [t for t in tracks if t is not None] + + +def create_hash(*args: str, decode=False, limit=7) -> str: + """ + Creates a simple hash for an album + """ + string = "".join(args) + + if decode: + string = unidecode(string) + + string = string.lower().strip().replace(" ", "") + string = "".join(t for t in string if t.isalnum()) + string = string.encode("utf-8") + string = hashlib.sha256(string).hexdigest() + return string[-limit:] + + +def create_folder_hash(*args: str, limit=7) -> str: + """ + Creates a simple hash for an album + """ + strings = [s.lower().strip().replace(" ", "") for s in args] + + strings = ["".join([t for t in s if t.isalnum()]) for s in strings] + strings = [s.encode("utf-8") for s in strings] + strings = [hashlib.sha256(s).hexdigest()[-limit:] for s in strings] + return "".join(strings) + + +def create_new_date(): + """ + It creates a new date and time string in the format of "YYYY-MM-DD HH:MM:SS" + :return: A string of the current date and time. + """ + now = datetime.now() + return now.strftime("%Y-%m-%d %H:%M:%S") + + +class UseBisection: + """ + Uses bisection to find a list of items in another list. + + returns a list of found items with `None` items being not found + items. + """ + + def __init__(self, source: list, search_from: str, queries: list[str]) -> None: + self.source_list = source + self.queries_list = queries + self.attr = search_from + + def find(self, query: str): + left = 0 + right = len(self.source_list) - 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: + right = mid - 1 + else: + left = mid + 1 + + return None + + def __call__(self) -> list: + if len(self.source_list) == 0: + return [None] + + return [self.find(query) for query in self.queries_list] + + +class Ping: + """ + Checks if there is a connection to the internet by pinging google.com + """ + + @staticmethod + def __call__() -> bool: + try: + requests.get("https://google.com", timeout=10) + return True + except (requests.exceptions.ConnectionError, requests.Timeout): + return False + + +def get_artists_from_tracks(tracks: list[models.Track]) -> set[str]: + """ + Extracts all artists from a list of tracks. Returns a list of Artists. + """ + artists = set() + + master_artist_list = [[x.name for x in t.artist] for t in tracks] # type: ignore + artists = artists.union(*master_artist_list) + + return artists + + +def get_albumartists(albums: list[models.Album]) -> set[str]: + artists = set() + + # master_artist_list = [a.albumartists for a in albums] + for album in albums: + albumartists = [a.name for a in album.albumartists] # type: ignore + + artists.update(albumartists) + + # return [models.Artist(a) for a in artists] + return artists + + +def get_all_artists( + tracks: list[models.Track], albums: list[models.Album] +) -> list[models.Artist]: + artists_from_tracks = get_artists_from_tracks(tracks) + artist_from_albums = get_albumartists(albums) + + artists = list(artists_from_tracks.union(artist_from_albums)) + artists = sorted(artists) + + lower_artists = set(a.lower().strip() for a in artists) + indices = [[ar.lower().strip() for ar in artists].index(a) for a in lower_artists] + artists = [artists[i] for i in indices] + + return [models.Artist(a) for a in artists] + + +def bisection_search_string(strings: list[str], target: str) -> str | None: + """ + Finds a string in a list of strings using bisection search. + """ + if not strings: + return None + + strings = sorted(strings) + + left = 0 + right = len(strings) - 1 + while left <= right: + middle = (left + right) // 2 + if strings[middle] == target: + return strings[middle] + + if strings[middle] < target: + left = middle + 1 + else: + right = middle - 1 + + return None + + +def get_home_res_path(filename: str): + """ + Returns a path to resources in the home directory of this project. Used to resolve resources in builds. + """ + return (CWD / ".." / filename).resolve() diff --git a/assets/album.svg b/assets/album.svg new file mode 100644 index 0000000..18da473 --- /dev/null +++ b/assets/album.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/artist.webp b/assets/artist.webp new file mode 100644 index 0000000..e9a360f Binary files /dev/null and b/assets/artist.webp differ diff --git a/assets/default.webp b/assets/default.webp new file mode 100644 index 0000000..53a312e Binary files /dev/null and b/assets/default.webp differ diff --git a/assets/playlist.svg b/assets/playlist.svg new file mode 100644 index 0000000..bb5a971 --- /dev/null +++ b/assets/playlist.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/index.html b/index.html deleted file mode 100644 index 91414af..0000000 --- a/index.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - Alice - - - -
- - - - diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..e8efa46 --- /dev/null +++ b/manage.py @@ -0,0 +1,183 @@ +""" +This file is used to run the application. +""" +import logging +import os +import sys +from configparser import ConfigParser + +import PyInstaller.__main__ as bundler + +from app.api import create_api +from app.functions import run_periodic_checks +from app.lib.watchdogg import Watcher as WatchDog +from app.settings import APP_VERSION, HELP_MESSAGE, TCOLOR +from app.setup import run_setup +from app.utils import background, get_home_res_path + +werkzeug = logging.getLogger("werkzeug") +werkzeug.setLevel(logging.ERROR) + + +class Variables: + FLASK_PORT = 1970 + FLASK_HOST = "localhost" + + +app = create_api() +app.static_folder = get_home_res_path("client") + +config = ConfigParser() +config.read("pyinstaller.config.ini") + + +@app.route("/") +def serve_client_files(path): + """ + Serves the static files in the client folder. + """ + return app.send_static_file(path) + + +@app.route("/") +def serve_client(): + """ + Serves the index.html file at client/index.html. + """ + return app.send_static_file("index.html") + + +ARGS = sys.argv[1:] + + +class ArgsEnum: + """ + Enumerates the possible file arguments. + """ + + build = "--build" + port = "--port" + host = "--host" + help = ["--help", "-h"] + version = ["--version", "-v"] + + +class HandleArgs: + def __init__(self) -> None: + self.handle_build() + self.handle_host() + self.handle_port() + self.handle_help() + self.handle_version() + + @staticmethod + def handle_build(): + """ + Runs Pyinstaller. + """ + if ArgsEnum.build in ARGS: + with open("pyinstaller.config.ini", "w", encoding="utf-8") as file: + config["DEFAULT"]["BUILD"] = "True" + config.write(file) + + bundler.run( + [ + "manage.py", + "--onefile", + "--name", + "swing", + "--clean", + "--add-data=assets:assets", + "--add-data=client:client", + "--add-data=pyinstaller.config.ini:.", + "-y", + ] + ) + + with open("pyinstaller.config.ini", "w", encoding="utf-8") as file: + config["DEFAULT"]["BUILD"] = "False" + config.write(file) + + sys.exit(0) + + @staticmethod + def handle_port(): + if ArgsEnum.port in ARGS: + index = ARGS.index(ArgsEnum.port) + try: + port = ARGS[index + 1] + except IndexError: + print("ERROR: Port not specified") + sys.exit(0) + + try: + Variables.FLASK_PORT = int(port) # type: ignore + except ValueError: + print("ERROR: Port should be a number") + sys.exit(0) + + @staticmethod + def handle_host(): + if ArgsEnum.host in ARGS: + index = ARGS.index(ArgsEnum.host) + + try: + host = ARGS[index + 1] + except IndexError: + print("ERROR: Host not specified") + sys.exit(0) + + Variables.FLASK_HOST = host # type: ignore + + @staticmethod + def handle_help(): + if any((a in ARGS for a in ArgsEnum.help)): + print(HELP_MESSAGE) + sys.exit(0) + + @staticmethod + def handle_version(): + if any((a in ARGS for a in ArgsEnum.version)): + print(APP_VERSION) + sys.exit(0) + + +@background +def run_bg_checks() -> None: + run_setup() + run_periodic_checks() + + +@background +def start_watchdog(): + WatchDog().run() + + +def log_info(): + lines = " -------------------------------------" + os.system("cls" if os.name == "nt" else "echo -e \\\\033c") + print(lines) + print(f" {TCOLOR.HEADER}{APP_VERSION} {TCOLOR.ENDC}") + print( + f" Started app on: {TCOLOR.OKGREEN}http://{Variables.FLASK_HOST}:{Variables.FLASK_PORT}{TCOLOR.ENDC}" + ) + print(lines) + print("\n") + + +if __name__ == "__main__": + HandleArgs() + log_info() + run_bg_checks() + start_watchdog() + app.run( + debug=True, + threaded=True, + host=Variables.FLASK_HOST, + port=Variables.FLASK_PORT, + use_reloader=False, + ) + +# TODO: Find out how to print in color: red for errors, etc. +# TODO: Find a way to verify the host string +# TODO: Organize code in this file: move args to new file, etc. \ No newline at end of file diff --git a/manage.spec b/manage.spec new file mode 100644 index 0000000..69183a7 --- /dev/null +++ b/manage.spec @@ -0,0 +1,44 @@ +# -*- mode: python ; coding: utf-8 -*- + + +block_cipher = None + + +a = Analysis( + ['manage.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='manage', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/package.json b/package.json deleted file mode 100644 index 2b7363a..0000000 --- a/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "swing_music_client", - "version": "0.1.0.alpha", - "private": true, - "scripts": { - "dev": "vite", - "serve": "vite preview", - "build": "vite build --emptyOutDir --outDir ../swing-core/client", - "lint": "eslint --ext .js,.vue --ignore-path .gitignore --fix src" - }, - "dependencies": { - "@formkit/auto-animate": "^1.0.0-beta.3", - "@popperjs/core": "^2.11.6", - "@vueuse/components": "^9.2.0", - "@vueuse/core": "^8.5.0", - "@vueuse/integrations": "^9.2.0", - "axios": "^0.26.1", - "fuse.js": "^6.6.2", - "pinia": "^2.0.17", - "pinia-plugin-persistedstate": "^2.1.1", - "sass": "^1.56.1", - "sass-loader": "^13.2.0", - "vite-svg-loader": "^3.4.0", - "vue": "^3.2.37", - "vue-debounce": "^3.0.2", - "vue-router": "^4.1.3", - "vue-template-compiler": "^2.0.0", - "vue-virtual-scroller": "^2.0.0-alpha.1", - "webpack": "^5.74.0" - }, - "devDependencies": { - "@vitejs/plugin-vue": "^3.2.0", - "eslint": "^8.7.0", - "eslint-plugin-vue": "^8.3.0", - "vite": "^3.0.4", - "vue-svg-loader": "^0.16.0" - }, - "packageManager": "yarn@3.1.1" -} diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..a21d58c --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1135 @@ +[[package]] +name = "altgraph" +version = "0.17.3" +description = "Python graph (network) package" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "astroid" +version = "2.12.12" +description = "An abstract syntax tree for Python with inference support." +category = "main" +optional = false +python-versions = ">=3.7.2" + +[package.dependencies] +lazy-object-proxy = ">=1.4.0" +wrapt = [ + {version = ">=1.11,<2", markers = "python_version < \"3.11\""}, + {version = ">=1.14,<2", markers = "python_version >= \"3.11\""}, +] + +[[package]] +name = "attrs" +version = "22.1.0" +description = "Classes Without Boilerplate" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"] + +[[package]] +name = "black" +version = "22.6.0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "certifi" +version = "2022.5.18.1" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "charset-normalizer" +version = "2.0.12" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.5.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" + +[[package]] +name = "colorgram.py" +version = "1.2.0" +description = "A Python module for extracting colors from images. Get a palette of any picture!" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pillow = ">=3.3.1" + +[[package]] +name = "dill" +version = "0.3.6" +description = "serialize all of python" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +graph = ["objgraph (>=1.7.2)"] + +[[package]] +name = "exceptiongroup" +version = "1.0.0rc9" +description = "Backport of PEP 654 (exception groups)" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "flask" +version = "2.1.2" +description = "A simple framework for building complex web applications." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +click = ">=8.0" +itsdangerous = ">=2.0" +Jinja2 = ">=3.0" +Werkzeug = ">=2.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + +[[package]] +name = "flask-cors" +version = "3.0.10" +description = "A Flask extension adding a decorator for CORS support" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +Flask = ">=0.9" +Six = "*" + +[[package]] +name = "future" +version = "0.18.2" +description = "Clean single-source support for Python 3 and 2" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "gunicorn" +version = "20.1.0" +description = "WSGI HTTP Server for UNIX" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +eventlet = ["eventlet (>=0.24.1)"] +gevent = ["gevent (>=1.4.0)"] +setproctitle = ["setproctitle"] +tornado = ["tornado (>=0.2)"] + +[[package]] +name = "hypothesis" +version = "6.56.3" +description = "A library for property-based testing" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +attrs = ">=19.2.0" +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +sortedcontainers = ">=2.1.0,<3.0.0" + +[package.extras] +all = ["black (>=19.10b0)", "click (>=7.0)", "django (>=3.2)", "dpcontracts (>=0.4)", "lark-parser (>=0.6.5)", "libcst (>=0.3.16)", "numpy (>=1.9.0)", "pandas (>=1.0)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "importlib-metadata (>=3.6)", "backports.zoneinfo (>=0.2.1)", "tzdata (>=2022.5)"] +cli = ["click (>=7.0)", "black (>=19.10b0)", "rich (>=9.0.0)"] +codemods = ["libcst (>=0.3.16)"] +dateutil = ["python-dateutil (>=1.4)"] +django = ["django (>=3.2)"] +dpcontracts = ["dpcontracts (>=0.4)"] +ghostwriter = ["black (>=19.10b0)"] +lark = ["lark-parser (>=0.6.5)"] +numpy = ["numpy (>=1.9.0)"] +pandas = ["pandas (>=1.0)"] +pytest = ["pytest (>=4.6)"] +pytz = ["pytz (>=2014.1)"] +redis = ["redis (>=3.0.0)"] +zoneinfo = ["backports.zoneinfo (>=0.2.1)", "tzdata (>=2022.5)"] + +[[package]] +name = "idna" +version = "3.3" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "isort" +version = "5.10.1" +description = "A Python utility / library to sort Python imports." +category = "main" +optional = false +python-versions = ">=3.6.1,<4.0" + +[package.extras] +pipfile_deprecated_finder = ["pipreqs", "requirementslib"] +requirements_deprecated_finder = ["pipreqs", "pip-api"] +colors = ["colorama (>=0.4.3,<0.5.0)"] +plugins = ["setuptools"] + +[[package]] +name = "itsdangerous" +version = "2.1.2" +description = "Safely pass data to untrusted environments and back." +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "lazy-object-proxy" +version = "1.8.0" +description = "A fast and thorough lazy object proxy." +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "macholib" +version = "1.16.2" +description = "Mach-O header analysis and editing" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +altgraph = ">=0.17" + +[[package]] +name = "markupsafe" +version = "2.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "pathspec" +version = "0.9.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[[package]] +name = "pefile" +version = "2022.5.30" +description = "Python PE parsing module" +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.dependencies] +future = "*" + +[[package]] +name = "pillow" +version = "9.2.0" +description = "Python Imaging Library (Fork)" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +tests = ["pytest-timeout", "pytest-cov", "pytest", "pyroma", "packaging", "olefile", "markdown2", "defusedxml", "coverage", "check-manifest"] +docs = ["sphinxext-opengraph", "sphinx-removed-in", "sphinx-issues (>=3.0.1)", "sphinx-copybutton", "sphinx (>=2.4)", "olefile", "furo"] + +[[package]] +name = "platformdirs" +version = "2.5.2" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] +test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +testing = ["pytest-benchmark", "pytest"] +dev = ["tox", "pre-commit"] + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pyinstaller" +version = "5.7.0" +description = "PyInstaller bundles a Python application and all its dependencies into a single package." +category = "main" +optional = false +python-versions = "<3.12,>=3.7" + +[package.dependencies] +altgraph = "*" +macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} +pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""} +pyinstaller-hooks-contrib = ">=2021.4" +pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} + +[package.extras] +encryption = ["tinyaes (>=1.0.0)"] +hook_testing = ["pytest (>=2.7.3)", "execnet (>=1.5.0)", "psutil"] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2022.14" +description = "Community maintained hooks for PyInstaller" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "pylint" +version = "2.15.5" +description = "python code static checker" +category = "main" +optional = false +python-versions = ">=3.7.2" + +[package.dependencies] +astroid = ">=2.12.12,<=2.14.0-dev0" +colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} +dill = ">=0.2" +isort = ">=4.2.5,<6" +mccabe = ">=0.6,<0.8" +platformdirs = ">=2.2.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +tomlkit = ">=0.10.1" + +[package.extras] +spelling = ["pyenchant (>=3.2,<4.0)"] +testutils = ["gitpython (>3)"] + +[[package]] +name = "pyparsing" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "main" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["railroad-diagrams", "jinja2"] + +[[package]] +name = "pytest" +version = "7.1.3" +description = "pytest: simple powerful testing with Python" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +tomli = ">=1.0.0" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.0" +description = "" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "rapidfuzz" +version = "2.13.7" +description = "rapid fuzzy string matching" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +full = ["numpy"] + +[[package]] +name = "requests" +version = "2.27.1" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} +idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "tinytag" +version = "1.8.1" +description = "Read music meta data and length of MP3, OGG, OPUS, MP4, M4A, FLAC, WMA and Wave files" +category = "main" +optional = false +python-versions = ">=2.7" + +[package.extras] +tests = ["pytest", "pytest-cov", "flake8"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "tomlkit" +version = "0.11.6" +description = "Style preserving TOML library" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "tqdm" +version = "4.64.0" +description = "Fast, Extensible Progress Meter" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["py-make (>=0.1.0)", "twine", "wheel"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + +[[package]] +name = "unidecode" +version = "1.3.6" +description = "ASCII transliterations of Unicode text" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "urllib3" +version = "1.26.9" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "watchdog" +version = "2.2.0" +description = "Filesystem events monitoring" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + +[[package]] +name = "werkzeug" +version = "2.1.2" +description = "The comprehensive WSGI web application library." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +watchdog = ["watchdog"] + +[[package]] +name = "wrapt" +version = "1.14.1" +description = "Module for decorators, wrappers and monkey patching." +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[metadata] +lock-version = "1.1" +python-versions = ">=3.10" +content-hash = "950730e47c15dd241301184fe9a8ff302d7f4411589d5d0b27067cdf0a7d46f5" + +[metadata.files] +altgraph = [ + {file = "altgraph-0.17.3-py2.py3-none-any.whl", hash = "sha256:c8ac1ca6772207179ed8003ce7687757c04b0b71536f81e2ac5755c6226458fe"}, + {file = "altgraph-0.17.3.tar.gz", hash = "sha256:ad33358114df7c9416cdb8fa1eaa5852166c505118717021c6a8c7c7abbd03dd"}, +] +astroid = [ + {file = "astroid-2.12.12-py3-none-any.whl", hash = "sha256:72702205200b2a638358369d90c222d74ebc376787af8fb2f7f2a86f7b5cc85f"}, + {file = "astroid-2.12.12.tar.gz", hash = "sha256:1c00a14f5a3ed0339d38d2e2e5b74ea2591df5861c0936bb292b84ccf3a78d83"}, +] +attrs = [ + {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, + {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, +] +black = [ + {file = "black-22.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69"}, + {file = "black-22.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b270a168d69edb8b7ed32c193ef10fd27844e5c60852039599f9184460ce0807"}, + {file = "black-22.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6797f58943fceb1c461fb572edbe828d811e719c24e03375fd25170ada53825e"}, + {file = "black-22.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c85928b9d5f83b23cee7d0efcb310172412fbf7cb9d9ce963bd67fd141781def"}, + {file = "black-22.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f6fe02afde060bbeef044af7996f335fbe90b039ccf3f5eb8f16df8b20f77666"}, + {file = "black-22.6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cfaf3895a9634e882bf9d2363fed5af8888802d670f58b279b0bece00e9a872d"}, + {file = "black-22.6.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94783f636bca89f11eb5d50437e8e17fbc6a929a628d82304c80fa9cd945f256"}, + {file = "black-22.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2ea29072e954a4d55a2ff58971b83365eba5d3d357352a07a7a4df0d95f51c78"}, + {file = "black-22.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e439798f819d49ba1c0bd9664427a05aab79bfba777a6db94fd4e56fae0cb849"}, + {file = "black-22.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187d96c5e713f441a5829e77120c269b6514418f4513a390b0499b0987f2ff1c"}, + {file = "black-22.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:074458dc2f6e0d3dab7928d4417bb6957bb834434516f21514138437accdbe90"}, + {file = "black-22.6.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a218d7e5856f91d20f04e931b6f16d15356db1c846ee55f01bac297a705ca24f"}, + {file = "black-22.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:568ac3c465b1c8b34b61cd7a4e349e93f91abf0f9371eda1cf87194663ab684e"}, + {file = "black-22.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6c1734ab264b8f7929cef8ae5f900b85d579e6cbfde09d7387da8f04771b51c6"}, + {file = "black-22.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9a3ac16efe9ec7d7381ddebcc022119794872abce99475345c5a61aa18c45ad"}, + {file = "black-22.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:b9fd45787ba8aa3f5e0a0a98920c1012c884622c6c920dbe98dbd05bc7c70fbf"}, + {file = "black-22.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ba9be198ecca5031cd78745780d65a3f75a34b2ff9be5837045dce55db83d1c"}, + {file = "black-22.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3db5b6409b96d9bd543323b23ef32a1a2b06416d525d27e0f67e74f1446c8f2"}, + {file = "black-22.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:560558527e52ce8afba936fcce93a7411ab40c7d5fe8c2463e279e843c0328ee"}, + {file = "black-22.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b154e6bbde1e79ea3260c4b40c0b7b3109ffcdf7bc4ebf8859169a6af72cd70b"}, + {file = "black-22.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:4af5bc0e1f96be5ae9bd7aaec219c901a94d6caa2484c21983d043371c733fc4"}, + {file = "black-22.6.0-py3-none-any.whl", hash = "sha256:ac609cf8ef5e7115ddd07d85d988d074ed00e10fbc3445aee393e70164a2219c"}, + {file = "black-22.6.0.tar.gz", hash = "sha256:6c6d39e28aed379aec40da1c65434c77d75e65bb59a1e1c283de545fb4e7c6c9"}, +] +certifi = [ + {file = "certifi-2022.5.18.1-py3-none-any.whl", hash = "sha256:f1d53542ee8cbedbe2118b5686372fb33c297fcd6379b050cca0ef13a597382a"}, + {file = "certifi-2022.5.18.1.tar.gz", hash = "sha256:9c5705e395cd70084351dd8ad5c41e65655e08ce46f2ec9cf6c2c08390f71eb7"}, +] +charset-normalizer = [ + {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, + {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, +] +click = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] +colorama = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +"colorgram.py" = [ + {file = "colorgram.py-1.2.0-py2.py3-none-any.whl", hash = "sha256:e990769fa6df7261a450c7d5bef3a1a062f09ba1214bff67b4d6f02970a1a27b"}, + {file = "colorgram.py-1.2.0.tar.gz", hash = "sha256:e77766a5f9de7207bdef8f1c22a702cbf09630eae3bc46a450b9d9f12a7bfdbf"}, +] +dill = [ + {file = "dill-0.3.6-py3-none-any.whl", hash = "sha256:a07ffd2351b8c678dfc4a856a3005f8067aea51d6ba6c700796a4d9e280f39f0"}, + {file = "dill-0.3.6.tar.gz", hash = "sha256:e5db55f3687856d8fbdab002ed78544e1c4559a130302693d839dfe8f93f2373"}, +] +exceptiongroup = [ + {file = "exceptiongroup-1.0.0rc9-py3-none-any.whl", hash = "sha256:2e3c3fc1538a094aab74fad52d6c33fc94de3dfee3ee01f187c0e0c72aec5337"}, + {file = "exceptiongroup-1.0.0rc9.tar.gz", hash = "sha256:9086a4a21ef9b31c72181c77c040a074ba0889ee56a7b289ff0afb0d97655f96"}, +] +flask = [ + {file = "Flask-2.1.2-py3-none-any.whl", hash = "sha256:fad5b446feb0d6db6aec0c3184d16a8c1f6c3e464b511649c8918a9be100b4fe"}, + {file = "Flask-2.1.2.tar.gz", hash = "sha256:315ded2ddf8a6281567edb27393010fe3406188bafbfe65a3339d5787d89e477"}, +] +flask-cors = [ + {file = "Flask-Cors-3.0.10.tar.gz", hash = "sha256:b60839393f3b84a0f3746f6cdca56c1ad7426aa738b70d6c61375857823181de"}, + {file = "Flask_Cors-3.0.10-py2.py3-none-any.whl", hash = "sha256:74efc975af1194fc7891ff5cd85b0f7478be4f7f59fe158102e91abb72bb4438"}, +] +future = [ + {file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"}, +] +gunicorn = [ + {file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"}, + {file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"}, +] +hypothesis = [ + {file = "hypothesis-6.56.3-py3-none-any.whl", hash = "sha256:802d236d03dbd54e0e1c55c0daa2ec41aeaadc87a4dcbb41421b78bf3f7a7789"}, + {file = "hypothesis-6.56.3.tar.gz", hash = "sha256:15dae5d993339aefa57e00f5cb5a5817ff300eeb661d96d1c9d094eb62b04c9a"}, +] +idna = [ + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +isort = [ + {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, + {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, +] +itsdangerous = [ + {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, + {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, +] +jinja2 = [ + {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, + {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, +] +lazy-object-proxy = [ + {file = "lazy-object-proxy-1.8.0.tar.gz", hash = "sha256:c219a00245af0f6fa4e95901ed28044544f50152840c5b6a3e7b2568db34d156"}, + {file = "lazy_object_proxy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4fd031589121ad46e293629b39604031d354043bb5cdf83da4e93c2d7f3389fe"}, + {file = "lazy_object_proxy-1.8.0-cp310-cp310-win32.whl", hash = "sha256:b70d6e7a332eb0217e7872a73926ad4fdc14f846e85ad6749ad111084e76df25"}, + {file = "lazy_object_proxy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:eb329f8d8145379bf5dbe722182410fe8863d186e51bf034d2075eb8d85ee25b"}, + {file = "lazy_object_proxy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4e2d9f764f1befd8bdc97673261b8bb888764dfdbd7a4d8f55e4fbcabb8c3fb7"}, + {file = "lazy_object_proxy-1.8.0-cp311-cp311-win32.whl", hash = "sha256:e20bfa6db17a39c706d24f82df8352488d2943a3b7ce7d4c22579cb89ca8896e"}, + {file = "lazy_object_proxy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:14010b49a2f56ec4943b6cf925f597b534ee2fe1f0738c84b3bce0c1a11ff10d"}, + {file = "lazy_object_proxy-1.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6850e4aeca6d0df35bb06e05c8b934ff7c533734eb51d0ceb2d63696f1e6030c"}, + {file = "lazy_object_proxy-1.8.0-cp37-cp37m-win32.whl", hash = "sha256:5b51d6f3bfeb289dfd4e95de2ecd464cd51982fe6f00e2be1d0bf94864d58acd"}, + {file = "lazy_object_proxy-1.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:6f593f26c470a379cf7f5bc6db6b5f1722353e7bf937b8d0d0b3fba911998858"}, + {file = "lazy_object_proxy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c1c7c0433154bb7c54185714c6929acc0ba04ee1b167314a779b9025517eada"}, + {file = "lazy_object_proxy-1.8.0-cp38-cp38-win32.whl", hash = "sha256:d176f392dbbdaacccf15919c77f526edf11a34aece58b55ab58539807b85436f"}, + {file = "lazy_object_proxy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:afcaa24e48bb23b3be31e329deb3f1858f1f1df86aea3d70cb5c8578bfe5261c"}, + {file = "lazy_object_proxy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:71d9ae8a82203511a6f60ca5a1b9f8ad201cac0fc75038b2dc5fa519589c9288"}, + {file = "lazy_object_proxy-1.8.0-cp39-cp39-win32.whl", hash = "sha256:8f6ce2118a90efa7f62dd38c7dbfffd42f468b180287b748626293bf12ed468f"}, + {file = "lazy_object_proxy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:eac3a9a5ef13b332c059772fd40b4b1c3d45a3a2b05e33a361dee48e54a4dad0"}, + {file = "lazy_object_proxy-1.8.0-pp37-pypy37_pp73-any.whl", hash = "sha256:ae032743794fba4d171b5b67310d69176287b5bf82a21f588282406a79498891"}, + {file = "lazy_object_proxy-1.8.0-pp38-pypy38_pp73-any.whl", hash = "sha256:7e1561626c49cb394268edd00501b289053a652ed762c58e1081224c8d881cec"}, + {file = "lazy_object_proxy-1.8.0-pp39-pypy39_pp73-any.whl", hash = "sha256:ce58b2b3734c73e68f0e30e4e725264d4d6be95818ec0a0be4bb6bf9a7e79aa8"}, +] +macholib = [ + {file = "macholib-1.16.2-py2.py3-none-any.whl", hash = "sha256:44c40f2cd7d6726af8fa6fe22549178d3a4dfecc35a9cd15ea916d9c83a688e0"}, + {file = "macholib-1.16.2.tar.gz", hash = "sha256:557bbfa1bb255c20e9abafe7ed6cd8046b48d9525db2f9b77d3122a63a2a8bf8"}, +] +markupsafe = [ + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, + {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, +] +mccabe = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +pathspec = [ + {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, + {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, +] +pefile = [ + {file = "pefile-2022.5.30.tar.gz", hash = "sha256:a5488a3dd1fd021ce33f969780b88fe0f7eebb76eb20996d7318f307612a045b"}, +] +pillow = [ + {file = "Pillow-9.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a9c9bc489f8ab30906d7a85afac4b4944a572a7432e00698a7239f44a44e6efb"}, + {file = "Pillow-9.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:510cef4a3f401c246cfd8227b300828715dd055463cdca6176c2e4036df8bd4f"}, + {file = "Pillow-9.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7888310f6214f19ab2b6df90f3f06afa3df7ef7355fc025e78a3044737fab1f5"}, + {file = "Pillow-9.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:831e648102c82f152e14c1a0938689dbb22480c548c8d4b8b248b3e50967b88c"}, + {file = "Pillow-9.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1cc1d2451e8a3b4bfdb9caf745b58e6c7a77d2e469159b0d527a4554d73694d1"}, + {file = "Pillow-9.2.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:136659638f61a251e8ed3b331fc6ccd124590eeff539de57c5f80ef3a9594e58"}, + {file = "Pillow-9.2.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6e8c66f70fb539301e064f6478d7453e820d8a2c631da948a23384865cd95544"}, + {file = "Pillow-9.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:37ff6b522a26d0538b753f0b4e8e164fdada12db6c6f00f62145d732d8a3152e"}, + {file = "Pillow-9.2.0-cp310-cp310-win32.whl", hash = "sha256:c79698d4cd9318d9481d89a77e2d3fcaeff5486be641e60a4b49f3d2ecca4e28"}, + {file = "Pillow-9.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:254164c57bab4b459f14c64e93df11eff5ded575192c294a0c49270f22c5d93d"}, + {file = "Pillow-9.2.0-cp311-cp311-macosx_10_10_universal2.whl", hash = "sha256:408673ed75594933714482501fe97e055a42996087eeca7e5d06e33218d05aa8"}, + {file = "Pillow-9.2.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:727dd1389bc5cb9827cbd1f9d40d2c2a1a0c9b32dd2261db522d22a604a6eec9"}, + {file = "Pillow-9.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50dff9cc21826d2977ef2d2a205504034e3a4563ca6f5db739b0d1026658e004"}, + {file = "Pillow-9.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb6259196a589123d755380b65127ddc60f4c64b21fc3bb46ce3a6ea663659b0"}, + {file = "Pillow-9.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b0554af24df2bf96618dac71ddada02420f946be943b181108cac55a7a2dcd4"}, + {file = "Pillow-9.2.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:15928f824870535c85dbf949c09d6ae7d3d6ac2d6efec80f3227f73eefba741c"}, + {file = "Pillow-9.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:bdd0de2d64688ecae88dd8935012c4a72681e5df632af903a1dca8c5e7aa871a"}, + {file = "Pillow-9.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5b87da55a08acb586bad5c3aa3b86505f559b84f39035b233d5bf844b0834b1"}, + {file = "Pillow-9.2.0-cp311-cp311-win32.whl", hash = "sha256:b6d5e92df2b77665e07ddb2e4dbd6d644b78e4c0d2e9272a852627cdba0d75cf"}, + {file = "Pillow-9.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6bf088c1ce160f50ea40764f825ec9b72ed9da25346216b91361eef8ad1b8f8c"}, + {file = "Pillow-9.2.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:2c58b24e3a63efd22554c676d81b0e57f80e0a7d3a5874a7e14ce90ec40d3069"}, + {file = "Pillow-9.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eef7592281f7c174d3d6cbfbb7ee5984a671fcd77e3fc78e973d492e9bf0eb3f"}, + {file = "Pillow-9.2.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd7b9c7139dc8258d164b55696ecd16c04607f1cc33ba7af86613881ffe4ac8"}, + {file = "Pillow-9.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a138441e95562b3c078746a22f8fca8ff1c22c014f856278bdbdd89ca36cff1b"}, + {file = "Pillow-9.2.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:93689632949aff41199090eff5474f3990b6823404e45d66a5d44304e9cdc467"}, + {file = "Pillow-9.2.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:f3fac744f9b540148fa7715a435d2283b71f68bfb6d4aae24482a890aed18b59"}, + {file = "Pillow-9.2.0-cp37-cp37m-win32.whl", hash = "sha256:fa768eff5f9f958270b081bb33581b4b569faabf8774726b283edb06617101dc"}, + {file = "Pillow-9.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:69bd1a15d7ba3694631e00df8de65a8cb031911ca11f44929c97fe05eb9b6c1d"}, + {file = "Pillow-9.2.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:030e3460861488e249731c3e7ab59b07c7853838ff3b8e16aac9561bb345da14"}, + {file = "Pillow-9.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:74a04183e6e64930b667d321524e3c5361094bb4af9083db5c301db64cd341f3"}, + {file = "Pillow-9.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d33a11f601213dcd5718109c09a52c2a1c893e7461f0be2d6febc2879ec2402"}, + {file = "Pillow-9.2.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fd6f5e3c0e4697fa7eb45b6e93996299f3feee73a3175fa451f49a74d092b9f"}, + {file = "Pillow-9.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a647c0d4478b995c5e54615a2e5360ccedd2f85e70ab57fbe817ca613d5e63b8"}, + {file = "Pillow-9.2.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:4134d3f1ba5f15027ff5c04296f13328fecd46921424084516bdb1b2548e66ff"}, + {file = "Pillow-9.2.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:bc431b065722a5ad1dfb4df354fb9333b7a582a5ee39a90e6ffff688d72f27a1"}, + {file = "Pillow-9.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:1536ad017a9f789430fb6b8be8bf99d2f214c76502becc196c6f2d9a75b01b76"}, + {file = "Pillow-9.2.0-cp38-cp38-win32.whl", hash = "sha256:2ad0d4df0f5ef2247e27fc790d5c9b5a0af8ade9ba340db4a73bb1a4a3e5fb4f"}, + {file = "Pillow-9.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:ec52c351b35ca269cb1f8069d610fc45c5bd38c3e91f9ab4cbbf0aebc136d9c8"}, + {file = "Pillow-9.2.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ed2c4ef2451de908c90436d6e8092e13a43992f1860275b4d8082667fbb2ffc"}, + {file = "Pillow-9.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ad2f835e0ad81d1689f1b7e3fbac7b01bb8777d5a985c8962bedee0cc6d43da"}, + {file = "Pillow-9.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea98f633d45f7e815db648fd7ff0f19e328302ac36427343e4432c84432e7ff4"}, + {file = "Pillow-9.2.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7761afe0126d046974a01e030ae7529ed0ca6a196de3ec6937c11df0df1bc91c"}, + {file = "Pillow-9.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a54614049a18a2d6fe156e68e188da02a046a4a93cf24f373bffd977e943421"}, + {file = "Pillow-9.2.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:5aed7dde98403cd91d86a1115c78d8145c83078e864c1de1064f52e6feb61b20"}, + {file = "Pillow-9.2.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:13b725463f32df1bfeacbf3dd197fb358ae8ebcd8c5548faa75126ea425ccb60"}, + {file = "Pillow-9.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:808add66ea764ed97d44dda1ac4f2cfec4c1867d9efb16a33d158be79f32b8a4"}, + {file = "Pillow-9.2.0-cp39-cp39-win32.whl", hash = "sha256:337a74fd2f291c607d220c793a8135273c4c2ab001b03e601c36766005f36885"}, + {file = "Pillow-9.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:fac2d65901fb0fdf20363fbd345c01958a742f2dc62a8dd4495af66e3ff502a4"}, + {file = "Pillow-9.2.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:ad2277b185ebce47a63f4dc6302e30f05762b688f8dc3de55dbae4651872cdf3"}, + {file = "Pillow-9.2.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c7b502bc34f6e32ba022b4a209638f9e097d7a9098104ae420eb8186217ebbb"}, + {file = "Pillow-9.2.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d1f14f5f691f55e1b47f824ca4fdcb4b19b4323fe43cc7bb105988cad7496be"}, + {file = "Pillow-9.2.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:dfe4c1fedfde4e2fbc009d5ad420647f7730d719786388b7de0999bf32c0d9fd"}, + {file = "Pillow-9.2.0-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:f07f1f00e22b231dd3d9b9208692042e29792d6bd4f6639415d2f23158a80013"}, + {file = "Pillow-9.2.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1802f34298f5ba11d55e5bb09c31997dc0c6aed919658dfdf0198a2fe75d5490"}, + {file = "Pillow-9.2.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17d4cafe22f050b46d983b71c707162d63d796a1235cdf8b9d7a112e97b15bac"}, + {file = "Pillow-9.2.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96b5e6874431df16aee0c1ba237574cb6dff1dcb173798faa6a9d8b399a05d0e"}, + {file = "Pillow-9.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:0030fdbd926fb85844b8b92e2f9449ba89607231d3dd597a21ae72dc7fe26927"}, + {file = "Pillow-9.2.0.tar.gz", hash = "sha256:75e636fd3e0fb872693f23ccb8a5ff2cd578801251f3a4f6854c6a5d437d3c04"}, +] +platformdirs = [ + {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, + {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, +] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] +py = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] +pyinstaller = [ + {file = "pyinstaller-5.7.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:b967ae71ab7b05e18608dbb4518da5afa54f0835927cb7a5ce52ab8fffed03b6"}, + {file = "pyinstaller-5.7.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:3180b9bf22263380adc5e2ee051b7c21463292877215bbe70c9155dc76f4b966"}, + {file = "pyinstaller-5.7.0-py3-none-manylinux2014_i686.whl", hash = "sha256:0f80e2403e76630ad3392c71f09c1a4284e8d8a8a99fb55ff3a0aba0e06300ed"}, + {file = "pyinstaller-5.7.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:2c1dd9d11cfc48bab61eeb06de69a3d1ad742bbb2ef14716965ca0333dd43a5b"}, + {file = "pyinstaller-5.7.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:dfc12e92fe10ae645dd0dd1fcfa4cd7677b2e96119e3cd4980d742e09bb78925"}, + {file = "pyinstaller-5.7.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f35f06d48faea0ad738429c009941059beebaa306e9d9ead95f1df4b441de2aa"}, + {file = "pyinstaller-5.7.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:28a8a0da656493aa32d9665e2f6f84775da0f23174859ed8facaa4226fe77a17"}, + {file = "pyinstaller-5.7.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:1ac3f09b838710c43e34b0a7ad003bd168a754b0b786c561b47baf1af9104354"}, + {file = "pyinstaller-5.7.0-py3-none-win32.whl", hash = "sha256:9cdb8ee8622ee8d2c6cd67f001b610019d4371a8bf3f7850562640ce786894d7"}, + {file = "pyinstaller-5.7.0-py3-none-win_amd64.whl", hash = "sha256:9b47c10fbefac6f6493266f8b1689109b2b14efa9142dbd2cd7549226a4568b7"}, + {file = "pyinstaller-5.7.0-py3-none-win_arm64.whl", hash = "sha256:3e51e18a16dec0414079762843cf892a5d70749ad56ca7b3c7b5f8367dc50b1e"}, + {file = "pyinstaller-5.7.0.tar.gz", hash = "sha256:0e5953937d35f0b37543cc6915dacaf3239bcbdf3fd3ecbb7866645468a16775"}, +] +pyinstaller-hooks-contrib = [ + {file = "pyinstaller-hooks-contrib-2022.14.tar.gz", hash = "sha256:5ae8da3a92cf20e37b3e00604d0c3468896e7d746e5c1449473597a724331b0b"}, + {file = "pyinstaller_hooks_contrib-2022.14-py2.py3-none-any.whl", hash = "sha256:1a125838a22d7b35a18993c6e56d3c5cc3ad7da00954f95bc5606523939203f2"}, +] +pylint = [ + {file = "pylint-2.15.5-py3-none-any.whl", hash = "sha256:c2108037eb074334d9e874dc3c783752cc03d0796c88c9a9af282d0f161a1004"}, + {file = "pylint-2.15.5.tar.gz", hash = "sha256:3b120505e5af1d06a5ad76b55d8660d44bf0f2fc3c59c2bdd94e39188ee3a4df"}, +] +pyparsing = [ + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, +] +pytest = [ + {file = "pytest-7.1.3-py3-none-any.whl", hash = "sha256:1377bda3466d70b55e3f5cecfa55bb7cfcf219c7964629b967c37cf0bda818b7"}, + {file = "pytest-7.1.3.tar.gz", hash = "sha256:4f365fec2dff9c1162f834d9f18af1ba13062db0c708bf7b946f8a5c76180c39"}, +] +pywin32-ctypes = [ + {file = "pywin32-ctypes-0.2.0.tar.gz", hash = "sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942"}, + {file = "pywin32_ctypes-0.2.0-py2.py3-none-any.whl", hash = "sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98"}, +] +rapidfuzz = [ + {file = "rapidfuzz-2.13.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b75dd0928ce8e216f88660ab3d5c5ffe990f4dd682fd1709dba29d5dafdde6de"}, + {file = "rapidfuzz-2.13.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:24d3fea10680d085fd0a4d76e581bfb2b1074e66e78fd5964d4559e1fcd2a2d4"}, + {file = "rapidfuzz-2.13.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8109e0324d21993d5b2d111742bf5958f3516bf8c59f297c5d1cc25a2342eb66"}, + {file = "rapidfuzz-2.13.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5f705652360d520c2de52bee11100c92f59b3e3daca308ebb150cbc58aecdad"}, + {file = "rapidfuzz-2.13.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7496e8779905b02abc0ab4ba2a848e802ab99a6e20756ffc967a0de4900bd3da"}, + {file = "rapidfuzz-2.13.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24eb6b843492bdc63c79ee4b2f104059b7a2201fef17f25177f585d3be03405a"}, + {file = "rapidfuzz-2.13.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:467c1505362823a5af12b10234cb1c4771ccf124c00e3fc9a43696512bd52293"}, + {file = "rapidfuzz-2.13.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53dcae85956853b787c27c1cb06f18bb450e22cf57a4ad3444cf03b8ff31724a"}, + {file = "rapidfuzz-2.13.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:46b9b8aa09998bc48dd800854e8d9b74bc534d7922c1d6e1bbf783e7fa6ac29c"}, + {file = "rapidfuzz-2.13.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1fbad8fb28d98980f5bff33c7842efef0315d42f0cd59082108482a7e6b61410"}, + {file = "rapidfuzz-2.13.7-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:43fb8cb030f888c3f076d40d428ed5eb4331f5dd6cf1796cfa39c67bf0f0fc1e"}, + {file = "rapidfuzz-2.13.7-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:b6bad92de071cbffa2acd4239c1779f66851b60ffbbda0e4f4e8a2e9b17e7eef"}, + {file = "rapidfuzz-2.13.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d00df2e4a81ffa56a6b1ec4d2bc29afdcb7f565e0b8cd3092fece2290c4c7a79"}, + {file = "rapidfuzz-2.13.7-cp310-cp310-win32.whl", hash = "sha256:2c836f0f2d33d4614c3fbaf9a1eb5407c0fe23f8876f47fd15b90f78daa64c34"}, + {file = "rapidfuzz-2.13.7-cp310-cp310-win_amd64.whl", hash = "sha256:c36fd260084bb636b9400bb92016c6bd81fd80e59ed47f2466f85eda1fc9f782"}, + {file = "rapidfuzz-2.13.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b34e8c0e492949ecdd5da46a1cfc856a342e2f0389b379b1a45a3cdcd3176a6e"}, + {file = "rapidfuzz-2.13.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:875d51b3497439a72e2d76183e1cb5468f3f979ab2ddfc1d1f7dde3b1ecfb42f"}, + {file = "rapidfuzz-2.13.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ae33a72336059213996fe4baca4e0e4860913905c2efb7c991eab33b95a98a0a"}, + {file = "rapidfuzz-2.13.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5585189b3d90d81ccd62d4f18530d5ac8972021f0aaaa1ffc6af387ff1dce75"}, + {file = "rapidfuzz-2.13.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:42085d4b154a8232767de8296ac39c8af5bccee6b823b0507de35f51c9cbc2d7"}, + {file = "rapidfuzz-2.13.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:585206112c294e335d84de5d5f179c0f932837752d7420e3de21db7fdc476278"}, + {file = "rapidfuzz-2.13.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f891b98f8bc6c9d521785816085e9657212621e93f223917fb8e32f318b2957e"}, + {file = "rapidfuzz-2.13.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08590905a95ccfa43f4df353dcc5d28c15d70664299c64abcad8721d89adce4f"}, + {file = "rapidfuzz-2.13.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b5dd713a1734574c2850c566ac4286594bacbc2d60b9170b795bee4b68656625"}, + {file = "rapidfuzz-2.13.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:988f8f6abfba7ee79449f8b50687c174733b079521c3cc121d65ad2d38831846"}, + {file = "rapidfuzz-2.13.7-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:b3210869161a864f3831635bb13d24f4708c0aa7208ef5baac1ac4d46e9b4208"}, + {file = "rapidfuzz-2.13.7-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:f6fe570e20e293eb50491ae14ddeef71a6a7e5f59d7e791393ffa99b13f1f8c2"}, + {file = "rapidfuzz-2.13.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6120f2995f5154057454c5de99d86b4ef3b38397899b5da1265467e8980b2f60"}, + {file = "rapidfuzz-2.13.7-cp311-cp311-win32.whl", hash = "sha256:b20141fa6cee041917801de0bab503447196d372d4c7ee9a03721b0a8edf5337"}, + {file = "rapidfuzz-2.13.7-cp311-cp311-win_amd64.whl", hash = "sha256:ec55a81ac2b0f41b8d6fb29aad16e55417036c7563bad5568686931aa4ff08f7"}, + {file = "rapidfuzz-2.13.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7d005e058d86f2a968a8d28ca6f2052fab1f124a39035aa0523261d6baf21e1f"}, + {file = "rapidfuzz-2.13.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe59a0c21a032024edb0c8e43f5dee5623fef0b65a1e3c1281836d9ce199af3b"}, + {file = "rapidfuzz-2.13.7-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cdfc04f7647c29fb48da7a04082c34cdb16f878d3c6d098d62d5715c0ad3000c"}, + {file = "rapidfuzz-2.13.7-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:68a89bb06d5a331511961f4d3fa7606f8e21237467ba9997cae6f67a1c2c2b9e"}, + {file = "rapidfuzz-2.13.7-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:effe182767d102cb65dfbbf74192237dbd22d4191928d59415aa7d7c861d8c88"}, + {file = "rapidfuzz-2.13.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25b4cedf2aa19fb7212894ce5f5219010cce611b60350e9a0a4d492122e7b351"}, + {file = "rapidfuzz-2.13.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:3a9bd02e1679c0fd2ecf69b72d0652dbe2a9844eaf04a36ddf4adfbd70010e95"}, + {file = "rapidfuzz-2.13.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:5e2b3d020219baa75f82a4e24b7c8adcb598c62f0e54e763c39361a9e5bad510"}, + {file = "rapidfuzz-2.13.7-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:cf62dacb3f9234f3fddd74e178e6d25c68f2067fde765f1d95f87b1381248f58"}, + {file = "rapidfuzz-2.13.7-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:fa263135b892686e11d5b84f6a1892523123a00b7e5882eff4fbdabb38667347"}, + {file = "rapidfuzz-2.13.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:fa4c598ed77f74ec973247ca776341200b0f93ec3883e34c222907ce72cb92a4"}, + {file = "rapidfuzz-2.13.7-cp37-cp37m-win32.whl", hash = "sha256:c2523f8180ebd9796c18d809e9a19075a1060b1a170fde3799e83db940c1b6d5"}, + {file = "rapidfuzz-2.13.7-cp37-cp37m-win_amd64.whl", hash = "sha256:5ada0a14c67452358c1ee52ad14b80517a87b944897aaec3e875279371a9cb96"}, + {file = "rapidfuzz-2.13.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ca8a23097c1f50e0fdb4de9e427537ca122a18df2eead06ed39c3a0bef6d9d3a"}, + {file = "rapidfuzz-2.13.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9be02162af0376d64b840f2fc8ee3366794fc149f1e06d095a6a1d42447d97c5"}, + {file = "rapidfuzz-2.13.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:af4f7c3c904ca709493eb66ca9080b44190c38e9ecb3b48b96d38825d5672559"}, + {file = "rapidfuzz-2.13.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f50d1227e6e2a0e3ae1fb1c9a2e1c59577d3051af72c7cab2bcc430cb5e18da"}, + {file = "rapidfuzz-2.13.7-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c71d9d512b76f05fa00282227c2ae884abb60e09f08b5ca3132b7e7431ac7f0d"}, + {file = "rapidfuzz-2.13.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b52ac2626945cd21a2487aeefed794c14ee31514c8ae69b7599170418211e6f6"}, + {file = "rapidfuzz-2.13.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca00fafd2756bc9649bf80f1cf72c647dce38635f0695d7ce804bc0f759aa756"}, + {file = "rapidfuzz-2.13.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d248a109699ce9992304e79c1f8735c82cc4c1386cd8e27027329c0549f248a2"}, + {file = "rapidfuzz-2.13.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:c88adbcb933f6b8612f6c593384bf824e562bb35fc8a0f55fac690ab5b3486e5"}, + {file = "rapidfuzz-2.13.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c8601a66fbfc0052bb7860d2eacd303fcde3c14e87fdde409eceff516d659e77"}, + {file = "rapidfuzz-2.13.7-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:27be9c63215d302ede7d654142a2e21f0d34ea6acba512a4ae4cfd52bbaa5b59"}, + {file = "rapidfuzz-2.13.7-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:3dcffe1f3cbda0dc32133a2ae2255526561ca594f15f9644384549037b355245"}, + {file = "rapidfuzz-2.13.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8450d15f7765482e86ef9be2ad1a05683cd826f59ad236ef7b9fb606464a56aa"}, + {file = "rapidfuzz-2.13.7-cp38-cp38-win32.whl", hash = "sha256:460853983ab88f873173e27cc601c5276d469388e6ad6e08c4fd57b2a86f1064"}, + {file = "rapidfuzz-2.13.7-cp38-cp38-win_amd64.whl", hash = "sha256:424f82c35dbe4f83bdc3b490d7d696a1dc6423b3d911460f5493b7ffae999fd2"}, + {file = "rapidfuzz-2.13.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c3fbe449d869ea4d0909fc9d862007fb39a584fb0b73349a6aab336f0d90eaed"}, + {file = "rapidfuzz-2.13.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:16080c05a63d6042643ae9b6cfec1aefd3e61cef53d0abe0df3069b9d4b72077"}, + {file = "rapidfuzz-2.13.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dbcf5371ea704759fcce772c66a07647751d1f5dbdec7818331c9b31ae996c77"}, + {file = "rapidfuzz-2.13.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:114810491efb25464016fd554fdf1e20d390309cecef62587494fc474d4b926f"}, + {file = "rapidfuzz-2.13.7-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99a84ab9ac9a823e7e93b4414f86344052a5f3e23b23aa365cda01393ad895bd"}, + {file = "rapidfuzz-2.13.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:81642a24798851b118f82884205fc1bd9ff70b655c04018c467824b6ecc1fabc"}, + {file = "rapidfuzz-2.13.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3741cb0bf9794783028e8b0cf23dab917fa5e37a6093b94c4c2f805f8e36b9f"}, + {file = "rapidfuzz-2.13.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:759a3361711586a29bc753d3d1bdb862983bd9b9f37fbd7f6216c24f7c972554"}, + {file = "rapidfuzz-2.13.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1333fb3d603d6b1040e365dca4892ba72c7e896df77a54eae27dc07db90906e3"}, + {file = "rapidfuzz-2.13.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:916bc2e6cf492c77ad6deb7bcd088f0ce9c607aaeabc543edeb703e1fbc43e31"}, + {file = "rapidfuzz-2.13.7-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:23524635840500ce6f4d25005c9529a97621689c85d2f727c52eed1782839a6a"}, + {file = "rapidfuzz-2.13.7-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:ebe303cd9839af69dd1f7942acaa80b1ba90bacef2e7ded9347fbed4f1654672"}, + {file = "rapidfuzz-2.13.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fe56659ccadbee97908132135de4b875543353351e0c92e736b7c57aee298b5a"}, + {file = "rapidfuzz-2.13.7-cp39-cp39-win32.whl", hash = "sha256:3f11a7eff7bc6301cd6a5d43f309e22a815af07e1f08eeb2182892fca04c86cb"}, + {file = "rapidfuzz-2.13.7-cp39-cp39-win_amd64.whl", hash = "sha256:e8914dad106dacb0775718e54bf15e528055c4e92fb2677842996f2d52da5069"}, + {file = "rapidfuzz-2.13.7-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f7930adf84301797c3f09c94b9c5a9ed90a9e8b8ed19b41d2384937e0f9f5bd"}, + {file = "rapidfuzz-2.13.7-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c31022d9970177f6affc6d5dd757ed22e44a10890212032fabab903fdee3bfe7"}, + {file = "rapidfuzz-2.13.7-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f42b82f268689f429def9ecfb86fa65ceea0eaf3fed408b570fe113311bf5ce7"}, + {file = "rapidfuzz-2.13.7-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b477b43ced896301665183a5e0faec0f5aea2373005648da8bdcb3c4b73f280"}, + {file = "rapidfuzz-2.13.7-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:d63def9bbc6b35aef4d76dc740301a4185867e8870cbb8719ec9de672212fca8"}, + {file = "rapidfuzz-2.13.7-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c66546e30addb04a16cd864f10f5821272a1bfe6462ee5605613b4f1cb6f7b48"}, + {file = "rapidfuzz-2.13.7-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f799d1d6c33d81e983d3682571cc7d993ae7ff772c19b3aabb767039c33f6d1e"}, + {file = "rapidfuzz-2.13.7-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d82f20c0060ffdaadaf642b88ab0aa52365b56dffae812e188e5bdb998043588"}, + {file = "rapidfuzz-2.13.7-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:042644133244bfa7b20de635d500eb9f46af7097f3d90b1724f94866f17cb55e"}, + {file = "rapidfuzz-2.13.7-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:75c45dcd595f8178412367e302fd022860ea025dc4a78b197b35428081ed33d5"}, + {file = "rapidfuzz-2.13.7-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3d8b081988d0a49c486e4e845a547565fee7c6e7ad8be57ff29c3d7c14c6894c"}, + {file = "rapidfuzz-2.13.7-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16ffad751f43ab61001187b3fb4a9447ec2d1aedeff7c5bac86d3b95f9980cc3"}, + {file = "rapidfuzz-2.13.7-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:020858dd89b60ce38811cd6e37875c4c3c8d7fcd8bc20a0ad2ed1f464b34dc4e"}, + {file = "rapidfuzz-2.13.7-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cda1e2f66bb4ba7261a0f4c2d052d5d909798fca557cbff68f8a79a87d66a18f"}, + {file = "rapidfuzz-2.13.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:b6389c50d8d214c9cd11a77f6d501529cb23279a9c9cafe519a3a4b503b5f72a"}, + {file = "rapidfuzz-2.13.7.tar.gz", hash = "sha256:8d3e252d4127c79b4d7c2ae47271636cbaca905c8bb46d80c7930ab906cf4b5c"}, +] +requests = [ + {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, + {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +sortedcontainers = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] +tinytag = [ + {file = "tinytag-1.8.1.tar.gz", hash = "sha256:363ab3107831a5598b68aaa061aba915fb1c7b4254d770232e65d5db8487636d"}, +] +tomli = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] +tomlkit = [ + {file = "tomlkit-0.11.6-py3-none-any.whl", hash = "sha256:07de26b0d8cfc18f871aec595fda24d95b08fef89d147caa861939f37230bf4b"}, + {file = "tomlkit-0.11.6.tar.gz", hash = "sha256:71b952e5721688937fb02cf9d354dbcf0785066149d2855e44531ebdd2b65d73"}, +] +tqdm = [ + {file = "tqdm-4.64.0-py2.py3-none-any.whl", hash = "sha256:74a2cdefe14d11442cedf3ba4e21a3b84ff9a2dbdc6cfae2c34addb2a14a5ea6"}, + {file = "tqdm-4.64.0.tar.gz", hash = "sha256:40be55d30e200777a307a7585aee69e4eabb46b4ec6a4b4a5f2d9f11e7d5408d"}, +] +unidecode = [ + {file = "Unidecode-1.3.6-py3-none-any.whl", hash = "sha256:547d7c479e4f377b430dd91ac1275d593308dce0fc464fb2ab7d41f82ec653be"}, + {file = "Unidecode-1.3.6.tar.gz", hash = "sha256:fed09cf0be8cf415b391642c2a5addfc72194407caee4f98719e40ec2a72b830"}, +] +urllib3 = [ + {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, + {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, +] +watchdog = [ + {file = "watchdog-2.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ed91c3ccfc23398e7aa9715abf679d5c163394b8cad994f34f156d57a7c163dc"}, + {file = "watchdog-2.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:76a2743402b794629a955d96ea2e240bd0e903aa26e02e93cd2d57b33900962b"}, + {file = "watchdog-2.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:920a4bda7daa47545c3201a3292e99300ba81ca26b7569575bd086c865889090"}, + {file = "watchdog-2.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ceaa9268d81205876bedb1069f9feab3eccddd4b90d9a45d06a0df592a04cae9"}, + {file = "watchdog-2.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1893d425ef4fb4f129ee8ef72226836619c2950dd0559bba022b0818c63a7b60"}, + {file = "watchdog-2.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9e99c1713e4436d2563f5828c8910e5ff25abd6ce999e75f15c15d81d41980b6"}, + {file = "watchdog-2.2.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a5bd9e8656d07cae89ac464ee4bcb6f1b9cecbedc3bf1334683bed3d5afd39ba"}, + {file = "watchdog-2.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3a048865c828389cb06c0bebf8a883cec3ae58ad3e366bcc38c61d8455a3138f"}, + {file = "watchdog-2.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e722755d995035dd32177a9c633d158f2ec604f2a358b545bba5bed53ab25bca"}, + {file = "watchdog-2.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:af4b5c7ba60206759a1d99811b5938ca666ea9562a1052b410637bb96ff97512"}, + {file = "watchdog-2.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:619d63fa5be69f89ff3a93e165e602c08ed8da402ca42b99cd59a8ec115673e1"}, + {file = "watchdog-2.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1f2b0665c57358ce9786f06f5475bc083fea9d81ecc0efa4733fd0c320940a37"}, + {file = "watchdog-2.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:441024df19253bb108d3a8a5de7a186003d68564084576fecf7333a441271ef7"}, + {file = "watchdog-2.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1a410dd4d0adcc86b4c71d1317ba2ea2c92babaf5b83321e4bde2514525544d5"}, + {file = "watchdog-2.2.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:28704c71afdb79c3f215c90231e41c52b056ea880b6be6cee035c6149d658ed1"}, + {file = "watchdog-2.2.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2ac0bd7c206bb6df78ef9e8ad27cc1346f2b41b1fef610395607319cdab89bc1"}, + {file = "watchdog-2.2.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:27e49268735b3c27310883012ab3bd86ea0a96dcab90fe3feb682472e30c90f3"}, + {file = "watchdog-2.2.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:2af1a29fd14fc0a87fb6ed762d3e1ae5694dcde22372eebba50e9e5be47af03c"}, + {file = "watchdog-2.2.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:c7bd98813d34bfa9b464cf8122e7d4bec0a5a427399094d2c17dd5f70d59bc61"}, + {file = "watchdog-2.2.0-py3-none-manylinux2014_i686.whl", hash = "sha256:56fb3f40fc3deecf6e518303c7533f5e2a722e377b12507f6de891583f1b48aa"}, + {file = "watchdog-2.2.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:74535e955359d79d126885e642d3683616e6d9ab3aae0e7dcccd043bd5a3ff4f"}, + {file = "watchdog-2.2.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:cf05e6ff677b9655c6e9511d02e9cc55e730c4e430b7a54af9c28912294605a4"}, + {file = "watchdog-2.2.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:d6ae890798a3560688b441ef086bb66e87af6b400a92749a18b856a134fc0318"}, + {file = "watchdog-2.2.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e5aed2a700a18c194c39c266900d41f3db0c1ebe6b8a0834b9995c835d2ca66e"}, + {file = "watchdog-2.2.0-py3-none-win32.whl", hash = "sha256:d0fb5f2b513556c2abb578c1066f5f467d729f2eb689bc2db0739daf81c6bb7e"}, + {file = "watchdog-2.2.0-py3-none-win_amd64.whl", hash = "sha256:1f8eca9d294a4f194ce9df0d97d19b5598f310950d3ac3dd6e8d25ae456d4c8a"}, + {file = "watchdog-2.2.0-py3-none-win_ia64.whl", hash = "sha256:ad0150536469fa4b693531e497ffe220d5b6cd76ad2eda474a5e641ee204bbb6"}, + {file = "watchdog-2.2.0.tar.gz", hash = "sha256:83cf8bc60d9c613b66a4c018051873d6273d9e45d040eed06d6a96241bd8ec01"}, +] +werkzeug = [ + {file = "Werkzeug-2.1.2-py3-none-any.whl", hash = "sha256:72a4b735692dd3135217911cbeaa1be5fa3f62bffb8745c5215420a03dc55255"}, + {file = "Werkzeug-2.1.2.tar.gz", hash = "sha256:1ce08e8093ed67d638d63879fd1ba3735817f7a80de3674d293f5984f25fb6e6"}, +] +wrapt = [ + {file = "wrapt-1.14.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1b376b3f4896e7930f1f772ac4b064ac12598d1c38d04907e696cc4d794b43d3"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:903500616422a40a98a5a3c4ff4ed9d0066f3b4c951fa286018ecdf0750194ef"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5a9a0d155deafd9448baff28c08e150d9b24ff010e899311ddd63c45c2445e28"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ddaea91abf8b0d13443f6dac52e89051a5063c7d014710dcb4d4abb2ff811a59"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:36f582d0c6bc99d5f39cd3ac2a9062e57f3cf606ade29a0a0d6b323462f4dd87"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7ef58fb89674095bfc57c4069e95d7a31cfdc0939e2a579882ac7d55aadfd2a1"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e2f83e18fe2f4c9e7db597e988f72712c0c3676d337d8b101f6758107c42425b"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:ee2b1b1769f6707a8a445162ea16dddf74285c3964f605877a20e38545c3c462"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:833b58d5d0b7e5b9832869f039203389ac7cbf01765639c7309fd50ef619e0b1"}, + {file = "wrapt-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:80bb5c256f1415f747011dc3604b59bc1f91c6e7150bd7db03b19170ee06b320"}, + {file = "wrapt-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07f7a7d0f388028b2df1d916e94bbb40624c59b48ecc6cbc232546706fac74c2"}, + {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02b41b633c6261feff8ddd8d11c711df6842aba629fdd3da10249a53211a72c4"}, + {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fe803deacd09a233e4762a1adcea5db5d31e6be577a43352936179d14d90069"}, + {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:257fd78c513e0fb5cdbe058c27a0624c9884e735bbd131935fd49e9fe719d310"}, + {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4fcc4649dc762cddacd193e6b55bc02edca674067f5f98166d7713b193932b7f"}, + {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:11871514607b15cfeb87c547a49bca19fde402f32e2b1c24a632506c0a756656"}, + {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8ad85f7f4e20964db4daadcab70b47ab05c7c1cf2a7c1e51087bfaa83831854c"}, + {file = "wrapt-1.14.1-cp310-cp310-win32.whl", hash = "sha256:a9a52172be0b5aae932bef82a79ec0a0ce87288c7d132946d645eba03f0ad8a8"}, + {file = "wrapt-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:6d323e1554b3d22cfc03cd3243b5bb815a51f5249fdcbb86fda4bf62bab9e164"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:43ca3bbbe97af00f49efb06e352eae40434ca9d915906f77def219b88e85d907"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6b1a564e6cb69922c7fe3a678b9f9a3c54e72b469875aa8018f18b4d1dd1adf3"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:00b6d4ea20a906c0ca56d84f93065b398ab74b927a7a3dbd470f6fc503f95dc3"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:a85d2b46be66a71bedde836d9e41859879cc54a2a04fad1191eb50c2066f6e9d"}, + {file = "wrapt-1.14.1-cp35-cp35m-win32.whl", hash = "sha256:dbcda74c67263139358f4d188ae5faae95c30929281bc6866d00573783c422b7"}, + {file = "wrapt-1.14.1-cp35-cp35m-win_amd64.whl", hash = "sha256:b21bb4c09ffabfa0e85e3a6b623e19b80e7acd709b9f91452b8297ace2a8ab00"}, + {file = "wrapt-1.14.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9e0fd32e0148dd5dea6af5fee42beb949098564cc23211a88d799e434255a1f4"}, + {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9736af4641846491aedb3c3f56b9bc5568d92b0692303b5a305301a95dfd38b1"}, + {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b02d65b9ccf0ef6c34cba6cf5bf2aab1bb2f49c6090bafeecc9cd81ad4ea1c1"}, + {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21ac0156c4b089b330b7666db40feee30a5d52634cc4560e1905d6529a3897ff"}, + {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:9f3e6f9e05148ff90002b884fbc2a86bd303ae847e472f44ecc06c2cd2fcdb2d"}, + {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:6e743de5e9c3d1b7185870f480587b75b1cb604832e380d64f9504a0535912d1"}, + {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:d79d7d5dc8a32b7093e81e97dad755127ff77bcc899e845f41bf71747af0c569"}, + {file = "wrapt-1.14.1-cp36-cp36m-win32.whl", hash = "sha256:81b19725065dcb43df02b37e03278c011a09e49757287dca60c5aecdd5a0b8ed"}, + {file = "wrapt-1.14.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b014c23646a467558be7da3d6b9fa409b2c567d2110599b7cf9a0c5992b3b471"}, + {file = "wrapt-1.14.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88bd7b6bd70a5b6803c1abf6bca012f7ed963e58c68d76ee20b9d751c74a3248"}, + {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5901a312f4d14c59918c221323068fad0540e34324925c8475263841dbdfe68"}, + {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d77c85fedff92cf788face9bfa3ebaa364448ebb1d765302e9af11bf449ca36d"}, + {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d649d616e5c6a678b26d15ece345354f7c2286acd6db868e65fcc5ff7c24a77"}, + {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7d2872609603cb35ca513d7404a94d6d608fc13211563571117046c9d2bcc3d7"}, + {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ee6acae74a2b91865910eef5e7de37dc6895ad96fa23603d1d27ea69df545015"}, + {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2b39d38039a1fdad98c87279b48bc5dce2c0ca0d73483b12cb72aa9609278e8a"}, + {file = "wrapt-1.14.1-cp37-cp37m-win32.whl", hash = "sha256:60db23fa423575eeb65ea430cee741acb7c26a1365d103f7b0f6ec412b893853"}, + {file = "wrapt-1.14.1-cp37-cp37m-win_amd64.whl", hash = "sha256:709fe01086a55cf79d20f741f39325018f4df051ef39fe921b1ebe780a66184c"}, + {file = "wrapt-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c0ce1e99116d5ab21355d8ebe53d9460366704ea38ae4d9f6933188f327b456"}, + {file = "wrapt-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e3fb1677c720409d5f671e39bac6c9e0e422584e5f518bfd50aa4cbbea02433f"}, + {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:642c2e7a804fcf18c222e1060df25fc210b9c58db7c91416fb055897fc27e8cc"}, + {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b7c050ae976e286906dd3f26009e117eb000fb2cf3533398c5ad9ccc86867b1"}, + {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef3f72c9666bba2bab70d2a8b79f2c6d2c1a42a7f7e2b0ec83bb2f9e383950af"}, + {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:01c205616a89d09827986bc4e859bcabd64f5a0662a7fe95e0d359424e0e071b"}, + {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5a0f54ce2c092aaf439813735584b9537cad479575a09892b8352fea5e988dc0"}, + {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2cf71233a0ed05ccdabe209c606fe0bac7379fdcf687f39b944420d2a09fdb57"}, + {file = "wrapt-1.14.1-cp38-cp38-win32.whl", hash = "sha256:aa31fdcc33fef9eb2552cbcbfee7773d5a6792c137b359e82879c101e98584c5"}, + {file = "wrapt-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:d1967f46ea8f2db647c786e78d8cc7e4313dbd1b0aca360592d8027b8508e24d"}, + {file = "wrapt-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3232822c7d98d23895ccc443bbdf57c7412c5a65996c30442ebe6ed3df335383"}, + {file = "wrapt-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:988635d122aaf2bdcef9e795435662bcd65b02f4f4c1ae37fbee7401c440b3a7"}, + {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cca3c2cdadb362116235fdbd411735de4328c61425b0aa9f872fd76d02c4e86"}, + {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d52a25136894c63de15a35bc0bdc5adb4b0e173b9c0d07a2be9d3ca64a332735"}, + {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40e7bc81c9e2b2734ea4bc1aceb8a8f0ceaac7c5299bc5d69e37c44d9081d43b"}, + {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b9b7a708dd92306328117d8c4b62e2194d00c365f18eff11a9b53c6f923b01e3"}, + {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6a9a25751acb379b466ff6be78a315e2b439d4c94c1e99cb7266d40a537995d3"}, + {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:34aa51c45f28ba7f12accd624225e2b1e5a3a45206aa191f6f9aac931d9d56fe"}, + {file = "wrapt-1.14.1-cp39-cp39-win32.whl", hash = "sha256:dee0ce50c6a2dd9056c20db781e9c1cfd33e77d2d569f5d1d9321c641bb903d5"}, + {file = "wrapt-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:dee60e1de1898bde3b238f18340eec6148986da0455d8ba7848d50470a7a32fb"}, + {file = "wrapt-1.14.1.tar.gz", hash = "sha256:380a85cf89e0e69b7cfbe2ea9f765f004ff419f34194018a6827ac0e3edfed4d"}, +] diff --git a/public/img/icons/android-chrome-192x192.png b/public/img/icons/android-chrome-192x192.png deleted file mode 100644 index 7f23511..0000000 Binary files a/public/img/icons/android-chrome-192x192.png and /dev/null differ diff --git a/public/img/icons/android-chrome-512x512.png b/public/img/icons/android-chrome-512x512.png deleted file mode 100644 index 6a53405..0000000 Binary files a/public/img/icons/android-chrome-512x512.png and /dev/null differ diff --git a/public/img/icons/apple-touch-icon.png b/public/img/icons/apple-touch-icon.png deleted file mode 100644 index ecd3bb0..0000000 Binary files a/public/img/icons/apple-touch-icon.png and /dev/null differ diff --git a/public/img/icons/favicon-16x16.png b/public/img/icons/favicon-16x16.png deleted file mode 100644 index c73e31a..0000000 Binary files a/public/img/icons/favicon-16x16.png and /dev/null differ diff --git a/public/img/icons/favicon-32x32.png b/public/img/icons/favicon-32x32.png deleted file mode 100644 index 1e1e069..0000000 Binary files a/public/img/icons/favicon-32x32.png and /dev/null differ diff --git a/public/img/icons/favicon.ico b/public/img/icons/favicon.ico deleted file mode 100644 index b7ff454..0000000 Binary files a/public/img/icons/favicon.ico and /dev/null differ diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index eb05362..0000000 --- a/public/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Disallow: diff --git a/pyinstaller.config.ini b/pyinstaller.config.ini new file mode 100644 index 0000000..9981586 --- /dev/null +++ b/pyinstaller.config.ini @@ -0,0 +1,3 @@ +[DEFAULT] +build = False + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9b1c7da --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[tool.poetry] +name = "Swing music player" +version = "0.1.0" +description = "" +authors = ["geoffrey45 "] + +[tool.poetry.dependencies] +python = ">=3.10" +Flask = "^2.0.2" +Flask-Cors = "^3.0.10" +requests = "^2.27.1" +watchdog = "^2.2.0" +gunicorn = "^20.1.0" +Pillow = "^9.0.1" +"colorgram.py" = "^1.2.0" +tqdm = "^4.64.0" +rapidfuzz = "^2.13.7" +tinytag = "^1.8.1" +hypothesis = "^6.56.3" +pytest = "^7.1.3" +pylint = "^2.15.5" +Unidecode = "^1.3.6" +pyinstaller = "^5.7.0" + +[tool.poetry.dev-dependencies] +black = {version = "^22.6.0", allow-prereleases = true} + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[pytest] +console_output_style = "classic" +testpaths = "tests" diff --git a/rd-me-banner.png b/rd-me-banner.png new file mode 100644 index 0000000..3fa931f Binary files /dev/null and b/rd-me-banner.png differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c312b0e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,30 @@ +black @ file:///home/cwilvx/.cache/pypoetry/artifacts/6a/ca/67/2501f462728be2eb38d33f074ba5e8c08d49867e154b321b3f0b41db86/black-22.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl +cachelib @ file:///home/cwilvx/.cache/pypoetry/artifacts/cd/93/0e/3cc9b898ce11816a06c6fdc2c82b4f32443ad17db9ac94b2b74380ebdf/cachelib-0.7.0-py3-none-any.whl +certifi @ file:///home/cwilvx/.cache/pypoetry/artifacts/8c/ef/5f/67cf35ca016dcd84174e483e20fc3cfcd8bf39b6852e96236e852f31ba/certifi-2022.5.18.1-py3-none-any.whl +charset-normalizer @ file:///home/cwilvx/.cache/pypoetry/artifacts/d5/27/31/db4fb74906e3a7f55f720e0079ac1850dd86e30651cdfa5e1f04c53cfa/charset_normalizer-2.0.12-py3-none-any.whl +click @ file:///home/cwilvx/.cache/pypoetry/artifacts/63/f3/4c/2270b95f4d37b9ea73cd401abe68b6e9ede30380533cd4e7118a8e3aa3/click-8.1.3-py3-none-any.whl +colorgram.py @ file:///home/cwilvx/.cache/pypoetry/artifacts/b9/4f/19/0bfe8f89dd3c5df77fd3399df1820ed195abdb2f850e3d64336a672d1b/colorgram.py-1.2.0-py2.py3-none-any.whl +Flask @ file:///home/cwilvx/.cache/pypoetry/artifacts/61/b9/1a/04191a9edc7415cae23e0e84b682bd895d55cc79f68018278adbca71c8/Flask-2.1.2-py3-none-any.whl +Flask-Caching @ file:///home/cwilvx/.cache/pypoetry/artifacts/e9/38/2f/8faf7982cf117a9058f8e8c2140c686f929bf8911986c0ab697cae8448/Flask_Caching-1.11.1-py3-none-any.whl +Flask-Cors @ file:///home/cwilvx/.cache/pypoetry/artifacts/b7/c4/f4/3606582505f2ade21c9f72607db37c2bd347d83951df4749019c3d39f8/Flask_Cors-3.0.10-py2.py3-none-any.whl +gunicorn @ file:///home/cwilvx/.cache/pypoetry/artifacts/9f/68/9f/f1166be9473b4fe2cc59c98fac616db1f94b18662b9055d1ac940374e3/gunicorn-20.1.0-py3-none-any.whl +idna @ file:///home/cwilvx/.cache/pypoetry/artifacts/90/36/8c/81eabf6ac88608721ab27f439c9a6b9a8e6a21cc58c59ebb1a42720199/idna-3.3-py3-none-any.whl +itsdangerous @ file:///home/cwilvx/.cache/pypoetry/artifacts/2e/15/8d/e1a5243416994d875e03f548c0c5af64a08970297056408d4e67e6bc28/itsdangerous-2.1.2-py3-none-any.whl +jarowinkler @ file:///home/cwilvx/.cache/pypoetry/artifacts/37/93/e8/2c0fb4589d71bd0c06ac156569ba434fb917ce65e3fc77353dc1960e7a/jarowinkler-1.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl +Jinja2 @ file:///home/cwilvx/.cache/pypoetry/artifacts/49/36/ae/943f6cd852641f7249acddef711eb97d0c9ed91d7f435c798b6d7041ca/Jinja2-3.1.2-py3-none-any.whl +MarkupSafe @ file:///home/cwilvx/.cache/pypoetry/artifacts/dd/cc/d7/91f68383c04a15a87f0a2b31599de891c89b1d15e309273f759daf132c/MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl +mutagen @ file:///home/cwilvx/.cache/pypoetry/artifacts/b4/fa/ad/d30a69658cc841ca38b77185eed0d97982259ce1cf1da32af87b376d4e/mutagen-1.45.1-py3-none-any.whl +mypy-extensions @ file:///home/cwilvx/.cache/pypoetry/artifacts/2f/c6/09/3e1afdcb75322c65b786e63cd7e879b6be3db36dea78ca376db5483ae4/mypy_extensions-0.4.3-py2.py3-none-any.whl +pathspec @ file:///home/cwilvx/.cache/pypoetry/artifacts/48/a0/9f/f5128d9e11d591bca7a942dd80ec44f9b2de8294775c68e0b99beeef93/pathspec-0.9.0-py2.py3-none-any.whl +Pillow @ file:///home/cwilvx/.cache/pypoetry/artifacts/95/cd/1a/99053885d95d74defc6d40d0bd7518f83fd74b133dfd762dfb523db565/Pillow-9.2.0-cp310-cp310-manylinux_2_28_x86_64.whl +platformdirs @ file:///home/cwilvx/.cache/pypoetry/artifacts/ea/8e/52/e5ac2f14474cef8f0fd44b4aa7d6968bfa89442d1b88ab567c446eae70/platformdirs-2.5.2-py3-none-any.whl +progress @ file:///home/cwilvx/.cache/pypoetry/artifacts/79/c2/d7/2a7bb2708100a9ccc186a9d3b9376c85fb53798080a3fe7480454fb17b/progress-1.6.tar.gz +pymongo @ file:///home/cwilvx/.cache/pypoetry/artifacts/64/8f/c6/6c691a87845035107c96bbd25b59f19d5cc716c2d46cbbdeb4ec149795/pymongo-4.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl +rapidfuzz @ file:///home/cwilvx/.cache/pypoetry/artifacts/9d/5e/96/b127feb34cd55e8eedc2ba19c53199ebceb9252389ec7a95cd4eb6e154/rapidfuzz-2.0.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl +requests @ file:///home/cwilvx/.cache/pypoetry/artifacts/d2/b2/c6/a04ce59140c6739203837d8dd0f518e29051b7ab61d2f34d4fd4241d30/requests-2.27.1-py2.py3-none-any.whl +six @ file:///home/cwilvx/.cache/pypoetry/artifacts/89/b2/f8/fd92b6d5daa0f8889429b2fc67ec21eedc5cae5d531ee2853828ced6c7/six-1.16.0-py2.py3-none-any.whl +tomli @ file:///home/cwilvx/.cache/pypoetry/artifacts/62/12/b6/6db9ebb9c8e1a6c5aa8a92ae73098d8119816b5e8507490916621bc305/tomli-2.0.1-py3-none-any.whl +tqdm @ file:///home/cwilvx/.cache/pypoetry/artifacts/9c/70/8c/d9fd60c1049cc4dba00815d66d598e9d5f265d4d59489e074827e331a9/tqdm-4.64.0-py2.py3-none-any.whl +urllib3 @ file:///home/cwilvx/.cache/pypoetry/artifacts/88/1c/d5/a55ed0245e5d7cd3a9f40dd75733644cbf7b7d94a6c521eb6c027a326c/urllib3-1.26.9-py2.py3-none-any.whl +watchdog @ file:///home/cwilvx/.cache/pypoetry/artifacts/0f/af/c3/b6575b0b5cab70c439d50980bd9673762b57878772f930d2d908ca83fc/watchdog-2.1.8-py3-none-manylinux2014_x86_64.whl +Werkzeug @ file:///home/cwilvx/.cache/pypoetry/artifacts/34/38/89/78911cfcd7dec75796d6056c730d94f730967bfe4fb4c5192b8d0d81ec/Werkzeug-2.1.2-py3-none-any.whl diff --git a/roadmap.md b/roadmap.md new file mode 100644 index 0000000..895a641 --- /dev/null +++ b/roadmap.md @@ -0,0 +1,39 @@ +# Fixes ! + +- [ ] Click on artist image to go to artist page ⚠ +- [ ] Play next song if current song can't be loaded ⚠ + +- [ ] Removing song duplicates from queries +- [ ] Add support for WAV files +- [ ] Compress thumbnails + +# Features + + +## Needed features + +- [ ] Adding songs to queue + +- [ ] Add keyboard shortcuts +- [ ] Adjust volume +- [ ] Add listening statistics for all songs +- [ ] Extract color from artist image [for use with artist card gradient] +- [ ] Adding songs to favorites +- [ ] Playing song radio + +## Future features + +- [ ] Toggle shuffle +- [ ] Toggle repeat +- [ ] Suggest similar artists +- [ ] Getting artist info +- [ ] Create a Python script to build, bundle and serve the app +- [ ] Getting extra song info (probably from genius) +- [ ] Getting lyrics +- [ ] Sorting songs +- [ ] Suggest undiscorvered artists, albums and songs +- [ ] Remember last played song +- [ ] Add next and previous song transition and progress bar reset animations +- [ ] Add playlist to folder +- [ ] Add functionality to 'Listen now' button +- [ ] Paginated requests for songs +- [ ] Package app as installable PWA diff --git a/src/App.vue b/src/App.vue deleted file mode 100644 index 3cc7a14..0000000 --- a/src/App.vue +++ /dev/null @@ -1,120 +0,0 @@ - - - - - diff --git a/src/assets/googlesans.ttf b/src/assets/googlesans.ttf deleted file mode 100644 index e2c69c3..0000000 Binary files a/src/assets/googlesans.ttf and /dev/null differ diff --git a/src/assets/icons/a.svg b/src/assets/icons/a.svg deleted file mode 100644 index e8edc7e..0000000 --- a/src/assets/icons/a.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/assets/icons/add-to-queue.svg b/src/assets/icons/add-to-queue.svg deleted file mode 100644 index 20ca7dd..0000000 --- a/src/assets/icons/add-to-queue.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/assets/icons/add_to_queue.svg b/src/assets/icons/add_to_queue.svg deleted file mode 100644 index 67a7b53..0000000 --- a/src/assets/icons/add_to_queue.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/album.svg b/src/assets/icons/album.svg deleted file mode 100644 index 5f52fc7..0000000 --- a/src/assets/icons/album.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/arrow.svg b/src/assets/icons/arrow.svg deleted file mode 100644 index 65b600c..0000000 --- a/src/assets/icons/arrow.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/artist.svg b/src/assets/icons/artist.svg deleted file mode 100644 index 1ac9f0d..0000000 --- a/src/assets/icons/artist.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/cancel.svg b/src/assets/icons/cancel.svg deleted file mode 100644 index ad2e5d3..0000000 --- a/src/assets/icons/cancel.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/clock.svg b/src/assets/icons/clock.svg deleted file mode 100644 index 8369a22..0000000 --- a/src/assets/icons/clock.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/assets/icons/delete.svg b/src/assets/icons/delete.svg deleted file mode 100644 index 2fc7289..0000000 --- a/src/assets/icons/delete.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/expand.svg b/src/assets/icons/expand.svg deleted file mode 100644 index 1434d41..0000000 --- a/src/assets/icons/expand.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/file.svg b/src/assets/icons/file.svg deleted file mode 100644 index 4a2a516..0000000 --- a/src/assets/icons/file.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/assets/icons/folder-1.svg b/src/assets/icons/folder-1.svg deleted file mode 100644 index dfea337..0000000 --- a/src/assets/icons/folder-1.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/folder.fill.svg b/src/assets/icons/folder.fill.svg deleted file mode 100644 index e37dc4c..0000000 --- a/src/assets/icons/folder.fill.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/folder.svg b/src/assets/icons/folder.svg deleted file mode 100644 index 8827d6c..0000000 --- a/src/assets/icons/folder.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/grid.svg b/src/assets/icons/grid.svg deleted file mode 100644 index af9ee1e..0000000 --- a/src/assets/icons/grid.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/assets/icons/heart.fill.svg b/src/assets/icons/heart.fill.svg deleted file mode 100644 index cd52039..0000000 --- a/src/assets/icons/heart.fill.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/heart.svg b/src/assets/icons/heart.svg deleted file mode 100644 index b189bf7..0000000 --- a/src/assets/icons/heart.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/home.svg b/src/assets/icons/home.svg deleted file mode 100644 index d1186bb..0000000 --- a/src/assets/icons/home.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/info.svg b/src/assets/icons/info.svg deleted file mode 100644 index 8c62247..0000000 --- a/src/assets/icons/info.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/assets/icons/logo-small-white-bg.svg b/src/assets/icons/logo-small-white-bg.svg deleted file mode 100644 index 47fa273..0000000 --- a/src/assets/icons/logo-small-white-bg.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/icons/logo-small.svg b/src/assets/icons/logo-small.svg deleted file mode 100644 index 3a17182..0000000 --- a/src/assets/icons/logo-small.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/more.svg b/src/assets/icons/more.svg deleted file mode 100644 index 32cf28b..0000000 --- a/src/assets/icons/more.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/assets/icons/next.svg b/src/assets/icons/next.svg deleted file mode 100644 index f1c9d48..0000000 --- a/src/assets/icons/next.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/pause.svg b/src/assets/icons/pause.svg deleted file mode 100644 index efd0485..0000000 --- a/src/assets/icons/pause.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/pin.svg b/src/assets/icons/pin.svg deleted file mode 100644 index 818e36e..0000000 --- a/src/assets/icons/pin.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/play-next.svg b/src/assets/icons/play-next.svg deleted file mode 100644 index 99c1387..0000000 --- a/src/assets/icons/play-next.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/assets/icons/play.svg b/src/assets/icons/play.svg deleted file mode 100644 index ba1a89c..0000000 --- a/src/assets/icons/play.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/playing.gif b/src/assets/icons/playing.gif deleted file mode 100644 index e49caf8..0000000 Binary files a/src/assets/icons/playing.gif and /dev/null differ diff --git a/src/assets/icons/playing.webp b/src/assets/icons/playing.webp deleted file mode 100644 index 9d1923e..0000000 Binary files a/src/assets/icons/playing.webp and /dev/null differ diff --git a/src/assets/icons/playlist-1.svg b/src/assets/icons/playlist-1.svg deleted file mode 100644 index 723ba82..0000000 --- a/src/assets/icons/playlist-1.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/icons/playlist.svg b/src/assets/icons/playlist.svg deleted file mode 100644 index e29bd4d..0000000 --- a/src/assets/icons/playlist.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/plus.svg b/src/assets/icons/plus.svg deleted file mode 100644 index 412c608..0000000 --- a/src/assets/icons/plus.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/previous.svg b/src/assets/icons/previous.svg deleted file mode 100644 index a63081c..0000000 --- a/src/assets/icons/previous.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/queue.svg b/src/assets/icons/queue.svg deleted file mode 100644 index db5b63d..0000000 --- a/src/assets/icons/queue.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/icons/repeat-one.svg b/src/assets/icons/repeat-one.svg deleted file mode 100644 index 23708f1..0000000 --- a/src/assets/icons/repeat-one.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/assets/icons/repeat.svg b/src/assets/icons/repeat.svg deleted file mode 100644 index 6f08054..0000000 --- a/src/assets/icons/repeat.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/right-arrow.svg b/src/assets/icons/right-arrow.svg deleted file mode 100644 index b23b235..0000000 --- a/src/assets/icons/right-arrow.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/sdcard.svg b/src/assets/icons/sdcard.svg deleted file mode 100644 index 0a86328..0000000 --- a/src/assets/icons/sdcard.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/search.svg b/src/assets/icons/search.svg deleted file mode 100644 index 124fe83..0000000 --- a/src/assets/icons/search.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/settings.svg b/src/assets/icons/settings.svg deleted file mode 100644 index 5d7f667..0000000 --- a/src/assets/icons/settings.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/shuffle.svg b/src/assets/icons/shuffle.svg deleted file mode 100644 index 47d2851..0000000 --- a/src/assets/icons/shuffle.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/symlink.svg b/src/assets/icons/symlink.svg deleted file mode 100644 index 2bf800c..0000000 --- a/src/assets/icons/symlink.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/src/assets/icons/tag.svg b/src/assets/icons/tag.svg deleted file mode 100644 index 5bdb1cf..0000000 --- a/src/assets/icons/tag.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/toast/check.svg b/src/assets/icons/toast/check.svg deleted file mode 100644 index 65575a3..0000000 --- a/src/assets/icons/toast/check.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/assets/icons/toast/error.svg b/src/assets/icons/toast/error.svg deleted file mode 100644 index a5061e8..0000000 --- a/src/assets/icons/toast/error.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/assets/icons/toast/info.svg b/src/assets/icons/toast/info.svg deleted file mode 100644 index 36fc150..0000000 --- a/src/assets/icons/toast/info.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/assets/icons/toast/ok.svg b/src/assets/icons/toast/ok.svg deleted file mode 100644 index 0304ea1..0000000 --- a/src/assets/icons/toast/ok.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/assets/icons/toast/working.svg b/src/assets/icons/toast/working.svg deleted file mode 100644 index 1c40de8..0000000 --- a/src/assets/icons/toast/working.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/assets/images/folder.webp b/src/assets/images/folder.webp deleted file mode 100644 index 158c1c6..0000000 Binary files a/src/assets/images/folder.webp and /dev/null differ diff --git a/src/assets/images/folderbg.svg b/src/assets/images/folderbg.svg deleted file mode 100644 index dbcb62f..0000000 --- a/src/assets/images/folderbg.svg +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/assets/images/folderbg.webp b/src/assets/images/folderbg.webp deleted file mode 100644 index 75b948d..0000000 Binary files a/src/assets/images/folderbg.webp and /dev/null differ diff --git a/src/assets/images/icons/android-chrome-192x192.png b/src/assets/images/icons/android-chrome-192x192.png deleted file mode 100644 index 7f23511..0000000 Binary files a/src/assets/images/icons/android-chrome-192x192.png and /dev/null differ diff --git a/src/assets/images/icons/android-chrome-512x512.png b/src/assets/images/icons/android-chrome-512x512.png deleted file mode 100644 index 6a53405..0000000 Binary files a/src/assets/images/icons/android-chrome-512x512.png and /dev/null differ diff --git a/src/assets/images/icons/apple-touch-icon.png b/src/assets/images/icons/apple-touch-icon.png deleted file mode 100644 index ecd3bb0..0000000 Binary files a/src/assets/images/icons/apple-touch-icon.png and /dev/null differ diff --git a/src/assets/images/icons/favicon-16x16.png b/src/assets/images/icons/favicon-16x16.png deleted file mode 100644 index c73e31a..0000000 Binary files a/src/assets/images/icons/favicon-16x16.png and /dev/null differ diff --git a/src/assets/images/icons/favicon-32x32.png b/src/assets/images/icons/favicon-32x32.png deleted file mode 100644 index 1e1e069..0000000 Binary files a/src/assets/images/icons/favicon-32x32.png and /dev/null differ diff --git a/src/assets/images/icons/favicon.ico b/src/assets/images/icons/favicon.ico deleted file mode 100644 index b7ff454..0000000 Binary files a/src/assets/images/icons/favicon.ico and /dev/null differ diff --git a/src/assets/images/logo.webp b/src/assets/images/logo.webp deleted file mode 100644 index 8c43924..0000000 Binary files a/src/assets/images/logo.webp and /dev/null differ diff --git a/src/assets/logo.svg b/src/assets/logo.svg deleted file mode 100644 index 68cd099..0000000 --- a/src/assets/logo.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/assets/scss/BottomBar/BottomBar.scss b/src/assets/scss/BottomBar/BottomBar.scss deleted file mode 100644 index 1c406a6..0000000 --- a/src/assets/scss/BottomBar/BottomBar.scss +++ /dev/null @@ -1,64 +0,0 @@ -.b-bar { - height: 100%; - - .grid { - background-color: $primary; - height: 100%; - padding-right: $small; - - @include phone-only { - grid-template-columns: 1fr 9.2rem; - } - - .controlsx { - width: 100%; - overflow: hidden; - display: grid; - grid-template-columns: 12rem 1fr 12rem; - align-items: center; - padding: $small; - - .progress-bottom { - display: flex; - align-items: center; - } - - .progress-bottom { - width: 100%; - - .durationx { - background-color: $black; - padding: $smaller; - border-radius: 0.5rem; - margin: 0 $small 0 $small; - font-size: 0.8rem; - min-width: 2.5rem; - } - } - } - - .r-group { - display: flex; - align-items: center; - gap: $small; - - #heart { - background-image: url(../../icons/heart.svg); - } - - #add-to { - background-image: url(../../icons/plus.svg); - } - - #repeat { - background-image: url(../../icons/repeat.svg); - } - } - - .volume-group { - @include tablet-portrait { - display: none; - } - } - } -} diff --git a/src/assets/scss/Global/album-grid.scss b/src/assets/scss/Global/album-grid.scss deleted file mode 100644 index 2113138..0000000 --- a/src/assets/scss/Global/album-grid.scss +++ /dev/null @@ -1,12 +0,0 @@ -.card-grid-view { - height: 100%; - - .scrollable { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); - padding: 0 1rem; - padding-bottom: 4rem; - overflow: auto; - max-height: 100%; - } -} diff --git a/src/assets/scss/Global/app-grid.scss b/src/assets/scss/Global/app-grid.scss deleted file mode 100644 index 97fbefe..0000000 --- a/src/assets/scss/Global/app-grid.scss +++ /dev/null @@ -1,139 +0,0 @@ -$g-border: solid 1px $gray5; - -#app-grid { - display: grid; - grid-template-columns: min-content 1fr 29rem; - grid-template-rows: max-content 1fr 5rem; - grid-template-areas: - "l-sidebar nav r-sidebar" - "l-sidebar content r-sidebar" - "bottombar bottombar bottombar"; - - // gap: 0 1.5rem; - height: 100%; - border: $g-border; - border-top: none; - border-bottom: none; - margin: 0 auto; - max-width: 1720px; -} - -#acontent { - width: 100%; - grid-area: content; - padding-right: calc($medium); - - overflow: hidden; -} - -.vue-recycle-scroller__item-wrapper { - overflow: visible !important; -} - -.vue-recycle-scroller { - padding-left: 1.25rem; - scrollbar-gutter: stable; -} - -.r-sidebar { - grid-area: r-sidebar; - border-left: $g-border; -} - -.topnav { - grid-area: nav; - margin: 1rem 0; -} - -.l-sidebar { - width: 15rem; - grid-area: l-sidebar; - display: grid; - grid-template-rows: 1fr max-content; - border-right: $g-border; -} - -.b-bar { - grid-area: bottombar; - border-top: $g-border; -} - -.content-page { - margin-left: 1.25rem; - margin-right: -$medium; - padding-right: $medium; - scrollbar-gutter: stable; -} - -// ====== MODIFIERS ======= - -#app-grid.extendWidth { - max-width: 100%; - padding-right: 0; - border-left: none; - border-right: none; -} - -#app-grid.noSidebar { - grid-template-columns: min-content 1fr; - grid-template-areas: - "l-sidebar nav" - "l-sidebar content" - "bottombar bottombar"; - - #acontent { - margin-right: 0 !important; - padding-right: $medium !important; - } - - .topnav { - //reduce width to match #acontent - width: calc(100% - 1rem); - padding-right: 0; - } -} - -#app-grid.NoSideBorders { - border-right: none; - border-left: none; -} - -.v-scroll-page { - width: calc(100% + $medium) !important; - - .scroller { - height: 100%; - width: 100%; - padding-right: 1.25rem; - padding-bottom: $content-padding-bottom; - } -} - -.isSmall { - .songlist-item { - grid-template-columns: 1.75rem 2fr 2.5rem 2.5rem; - } - - .song-artists, - .song-album { - display: none !important; - } - - .isSmallArtists { - display: unset !important; - font-size: small; - color: $white; - opacity: 0.67; - } -} - -.isMedium { - // hide album column - .songlist-item { - grid-template-columns: 1.75rem 1.5fr 1fr 2.5rem 2.5rem; - } - - .song-album { - display: none !important; - } -} diff --git a/src/assets/scss/Global/basic.scss b/src/assets/scss/Global/basic.scss deleted file mode 100644 index ed91240..0000000 --- a/src/assets/scss/Global/basic.scss +++ /dev/null @@ -1,150 +0,0 @@ -// TEXT - -.t-center { - text-align: center; -} - -.ellip { - display: -webkit-box; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; - overflow: hidden; - text-overflow: ellipsis; - word-break: break-all; - width: fit-content; - max-width: 100%; -} - -.ellip2 { - word-wrap: anywhere; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - text-overflow: ellipsis; -} - -.heading { - font-size: 2rem; - font-weight: bold; -} - -a { - text-decoration: none; - color: #fff; -} - -.image { - background-position: center; - background-repeat: no-repeat; - background-size: cover; - transition: transform 0.3s ease-in-out; -} - -// BORDERS -.rounded { - border-radius: 1rem; -} - -.rounded-sm { - border-radius: $small; -} - -.rounded-md { - border-radius: $medium; -} - -.circular { - border-radius: 10rem; -} - -.border { - border: 1px solid $gray3; -} - -.bg-primary { - background-color: $gray4; - box-shadow: 0 0 1rem rgba(0, 0, 0, 0.425); -} - -// BUTTONS -button { - border: none; - font-size: 0.9rem !important; - color: inherit; - border-radius: $small; - display: flex; - align-items: center; - justify-content: center; - height: 2.25rem; - background: linear-gradient(70deg, $gray3, $gray2); - padding: 0 $small; - - svg { - transition: all 0.2s; - } - - &:active { - svg { - transform: scale(0.75); - } - } - - &:hover { - background-image: linear-gradient($darkestblue, $darkblue); - } -} - -.btn-active { - background-image: linear-gradient($darkestblue, $darkblue); -} - -.btn-disabled { - pointer-events: none; - opacity: 0.5; -} - -.btn-more { - width: 2.5rem; -} - -// POSITION - -.abs { - position: absolute; -} - -// OTHERS -.grid { - display: grid; -} - -.flex { - display: flex; -} - -.separator { - border-top: 1px $separator solid; - color: transparent; - margin: $small 0 $small 0; - opacity: 0.5; -} - -// NO THIS, NO THAT (OVERRIDES) -.no-border { - border: none; -} - -.no-scroll { - overflow: hidden; -} - -.no-select { - user-select: none; -} - -.load_disabled { - pointer-events: all; - background: $gray5 !important; - opacity: 1; -} diff --git a/src/assets/scss/Global/index.scss b/src/assets/scss/Global/index.scss deleted file mode 100644 index a5c28e1..0000000 --- a/src/assets/scss/Global/index.scss +++ /dev/null @@ -1,47 +0,0 @@ -@import "./app-grid.scss", "./controls.scss", "./inputs.scss", - "./scrollbars.scss", "./state.scss", "./variants.scss", "./basic.scss", - "./search-tabheaders.scss", "./album-grid.scss"; - -* { - box-sizing: border-box; -} - -#vue-recycle-scroller__item-wrapper { - overflow: visible !important; -} - -html { - cursor: default !important; - - & > * { - overflow: visible !important; - } -} - -html.loading, -html.loading * { - cursor: progress !important; -} - -body { - background-color: $body; - color: $white; - font-family: "SFCompactDisplay", -apple-system, BlinkMacSystemFont, "Segoe UI", - Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", - "Segoe UI Symbol"; - font-size: 1rem; - image-rendering: -webkit-optimize-contrast; - height: 100vh; - width: 100vw; - overflow: hidden; - margin: 0; - - #app { - width: 100%; - height: 100%; - } - - a { - cursor: default !important; - } -} diff --git a/src/assets/scss/Global/inputs.scss b/src/assets/scss/Global/inputs.scss deleted file mode 100644 index e1cca9d..0000000 --- a/src/assets/scss/Global/inputs.scss +++ /dev/null @@ -1,10 +0,0 @@ -input[type="number"] { - width: 40px; - padding: 4px 5px; - border: 1px solid #bbb; - border-radius: 3px; -} - -input[type="search"] { - height: 2.25rem !important; -} diff --git a/src/assets/scss/Global/scrollbars.scss b/src/assets/scss/Global/scrollbars.scss deleted file mode 100644 index 6174149..0000000 --- a/src/assets/scss/Global/scrollbars.scss +++ /dev/null @@ -1,23 +0,0 @@ -::-webkit-scrollbar { - width: 3px; -} - -/* Track */ - -::-webkit-scrollbar-track { - background: transparent; - border-radius: 1rem; -} - -/* Handle */ - -::-webkit-scrollbar-thumb { - background: $gray1; - border-radius: 1rem; -} - -/* Handle on hover */ - -::-webkit-scrollbar-thumb:hover { - background: $blue; -} diff --git a/src/assets/scss/Global/search-tabheaders.scss b/src/assets/scss/Global/search-tabheaders.scss deleted file mode 100644 index e9c2fee..0000000 --- a/src/assets/scss/Global/search-tabheaders.scss +++ /dev/null @@ -1,33 +0,0 @@ -.tabheaders { - display: grid; - grid-template-columns: repeat(5, max-content); - justify-content: space-around; - margin: 1rem; - width: max-content; - background-color: $gray4; - height: 2.25rem; - - & > * { - border-left: solid 1px $gray3; - } - - .tab { - display: flex; - align-items: center; - justify-content: center; - user-select: none; - - transition: all 0.3s ease; - padding: 0 $small; - - &:first-child { - border-left: solid 1px transparent; - } - } - - .activetab { - background-color: $darkblue; - transition: all 0.3s ease; - border-left: solid 1px transparent; - } -} diff --git a/src/assets/scss/Global/state.scss b/src/assets/scss/Global/state.scss deleted file mode 100644 index 25c2b54..0000000 --- a/src/assets/scss/Global/state.scss +++ /dev/null @@ -1,18 +0,0 @@ - -.now-playing-track-indicator { - background-image: url(../../assets/icons/playing.gif); - height: 2rem; - width: 2rem; - border-radius: 50%; - background-color: $white; - background-size: 1.5rem !important; -} - -.last_played { - background-image: url(../../assets/icons/playing.webp); - transition: all 0.3s ease-in-out; -} - -.hidden { - display: none; -} diff --git a/src/assets/scss/Global/variants.scss b/src/assets/scss/Global/variants.scss deleted file mode 100644 index 6980bb9..0000000 --- a/src/assets/scss/Global/variants.scss +++ /dev/null @@ -1,29 +0,0 @@ -// paddings -.pad-smaller { - padding: $smaller; -} - -.pad-sm { - padding: $small; -} - -.pad-medium { - padding: $medium; -} - -.pad-lg { - padding: 1rem; -} - -// shadows -.shadow-sm { - box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.452); -} - -.shadow-md { - box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.075); -} - -.shadow-lg { - box-shadow: 0 0 $medium rgba(0, 0, 0, 0.589); -} diff --git a/src/assets/scss/ProgressBar.scss b/src/assets/scss/ProgressBar.scss deleted file mode 100644 index 77940f3..0000000 --- a/src/assets/scss/ProgressBar.scss +++ /dev/null @@ -1,72 +0,0 @@ -input[type="range"] { - -webkit-appearance: none; - margin-right: 15px; - width: calc(100% - 2px); - height: 0.3rem; - border-radius: 5px; - background: $gray4 linear-gradient(90deg, $darkblue, $darkestblue) no-repeat; - background-size: 100% 100%; - - &::-webkit-slider-thumb { - -webkit-appearance: none; - - height: 0; - width: 0.8rem; - border-radius: 50%; - background: $darkestblue; - } - - &::-moz-range-thumb { - -webkit-appearance: none; - - height: 0; - border-radius: 50%; - background: $darkestblue; - border: none; - } - - &::-ms-thumb { - -webkit-appearance: none; - - height: 0; - width: 0.8rem; - border-radius: 50%; - background: $darkestblue; - border: none; - } -} - -/* Input Thumb */ -input[type="range"]::-webkit-slider-thumb:hover { - background: $accent; -} - -input[type="range"]::-moz-range-thumb:hover { - background: $accent; -} - -input[type="range"]::-ms-thumb:hover { - background: $accent; -} - -/* Input Track */ -input[type="range"]::-webkit-slider-runnable-track { - -webkit-appearance: none; - box-shadow: none; - border: none; - background: transparent; -} - -input[type="range"]::-moz-range-track { - -webkit-appearance: none; - box-shadow: none; - border: none; - background: transparent; -} - -input[type="range"]::-ms-track { - -webkit-appearance: none; - box-shadow: none; - border: none; - background: transparent; -} diff --git a/src/assets/scss/_mixins.scss b/src/assets/scss/_mixins.scss deleted file mode 100644 index ac7c5fb..0000000 --- a/src/assets/scss/_mixins.scss +++ /dev/null @@ -1,36 +0,0 @@ -@mixin ximage { - background-position: center; - background-size: cover; - background-repeat: no-repeat; -} - -// media query mixins -@mixin phone-only { - @media (max-width: 599px) { - @content; - } -} - -@mixin tablet-landscape { - @media (max-width: 1080px) { - @content; - } -} - -@mixin tablet-portrait { - @media (max-width: 810) { - @content; - } -} - -@mixin for-desktop-down { - @media (max-width: 1600px) { - @content; - } -} - -@mixin for-desktop-up { - @media (min-width: 1800px) { - @content; - } -} diff --git a/src/assets/scss/_variables.scss b/src/assets/scss/_variables.scss deleted file mode 100644 index 49df347..0000000 --- a/src/assets/scss/_variables.scss +++ /dev/null @@ -1,56 +0,0 @@ -// colors - -$separator: #ffffff2f; -$highlight-blue: #006eff; -$darkestblue: #234ece; -$bbb: #161616; //bottom controls background -$theme: #464545fd; - -// sizes -$small: 0.5rem; -$smaller: 0.25rem; -$medium: 0.75rem; -$large: 1.5rem; -$larger: 2rem; - -$banner-height: 18rem; -$song-item-height: 4rem; -$content-padding-bottom: 2rem; - -// apple human design guideline colors -$black: #181a1c; -$white: #ffffffde; - -$gray: #1c1c1e; -$gray1: rgb(142, 142, 147); -$gray2: rgb(99, 99, 102); -$gray3: rgb(72, 72, 74); -$gray4: rgb(58, 58, 60); -$gray5: rgb(44, 44, 46); -$body: rgba(0, 0, 0, 0.95); - -$red: #ff453a; -$blue: #0a84ff; -$darkblue: #055ee2; -$green: rgb(20, 160, 55); -$yellow: rgb(255, 214, 10); -$orange: rgb(255, 159, 10); -$pink: rgb(255, 55, 95); -$purple: #bf5af2; -$brown: rgb(172, 142, 104); -$indigo: #5e5ce6; -$teal: rgb(64, 200, 224); - -$primary: $gray4; -$accent: $gray1; -$secondary: $gray5; -$danger: $red; -$track-hover: $gray4; -$context: black; -$playlist-card-bg: $gray4; - -// SVG COLORS -$default: $accent; -$side-nav-svg: $red; - -$overshoot: cubic-bezier(0.68, -0.55, 0.265, 1.55); diff --git a/src/assets/scss/index.scss b/src/assets/scss/index.scss deleted file mode 100644 index 87e56c0..0000000 --- a/src/assets/scss/index.scss +++ /dev/null @@ -1,15 +0,0 @@ -@import -"./mixins.scss", -"./variables", -"./ProgressBar.scss", -"./BottomBar/BottomBar.scss", -"./Global", -"./moz.scss" -; - - -@font-face { - font-family: "SFCompactDisplay"; - src: url("../sf-compact.woff") format("woff"); -} - diff --git a/src/assets/scss/moz.scss b/src/assets/scss/moz.scss deleted file mode 100644 index f403fff..0000000 --- a/src/assets/scss/moz.scss +++ /dev/null @@ -1,40 +0,0 @@ -// Styles that only apply to our dear Firefox - -@-moz-document url-prefix() { - #acontent { - margin-right: calc(-1rem + 1px); - padding-right: 1rem; - } - - // applies to playlist list page - .content-page { - margin-right: calc(0rem - ($medium + 4px)); - } - - // virtual scroller pages: folder, playlist, album - .header-list-layout { - margin-right: calc(0rem - ($medium + 4px)) !important; - - .scrollable { - padding-right: calc(1rem - 3px) !important; - } - } - - .v-scroll-page { - width: calc(100% + 1rem) !important; - - .scroller { - height: 100%; - width: 100%; - // padding-right: 1.25rem !important; - } - } - - #app-grid.noSidebar > #acontent { - padding-right: 1rem !important; - } - - .search-view { - margin-right: -1rem !important; - } -} diff --git a/src/assets/sf-compact.woff b/src/assets/sf-compact.woff deleted file mode 100644 index a1cfb31..0000000 Binary files a/src/assets/sf-compact.woff and /dev/null differ diff --git a/src/components/AlbumView/AlbumBio.vue b/src/components/AlbumView/AlbumBio.vue deleted file mode 100644 index 68637d3..0000000 --- a/src/components/AlbumView/AlbumBio.vue +++ /dev/null @@ -1,113 +0,0 @@ - - - - - diff --git a/src/components/AlbumView/AlbumDiscBar.vue b/src/components/AlbumView/AlbumDiscBar.vue deleted file mode 100644 index ad67712..0000000 --- a/src/components/AlbumView/AlbumDiscBar.vue +++ /dev/null @@ -1,32 +0,0 @@ - - - - - diff --git a/src/components/AlbumView/ArtistAlbums.vue b/src/components/AlbumView/ArtistAlbums.vue deleted file mode 100644 index db5b904..0000000 --- a/src/components/AlbumView/ArtistAlbums.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - - - diff --git a/src/components/AlbumView/GenreBanner.vue b/src/components/AlbumView/GenreBanner.vue deleted file mode 100644 index 7918206..0000000 --- a/src/components/AlbumView/GenreBanner.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - - - diff --git a/src/components/AlbumView/Header.vue b/src/components/AlbumView/Header.vue deleted file mode 100644 index 429189a..0000000 --- a/src/components/AlbumView/Header.vue +++ /dev/null @@ -1,263 +0,0 @@ - - - - - diff --git a/src/components/AlbumsExplorer/AlbumList.vue b/src/components/AlbumsExplorer/AlbumList.vue deleted file mode 100644 index a0030c7..0000000 --- a/src/components/AlbumsExplorer/AlbumList.vue +++ /dev/null @@ -1,218 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/components/AlbumsExplorer/TopAlbums.vue b/src/components/AlbumsExplorer/TopAlbums.vue deleted file mode 100644 index e5e2e3f..0000000 --- a/src/components/AlbumsExplorer/TopAlbums.vue +++ /dev/null @@ -1,164 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/components/ArtistView/ArtistAlbumsFetcher.vue b/src/components/ArtistView/ArtistAlbumsFetcher.vue deleted file mode 100644 index 1ef949a..0000000 --- a/src/components/ArtistView/ArtistAlbumsFetcher.vue +++ /dev/null @@ -1,18 +0,0 @@ - - - diff --git a/src/components/ArtistView/Header.vue b/src/components/ArtistView/Header.vue deleted file mode 100644 index f67c085..0000000 --- a/src/components/ArtistView/Header.vue +++ /dev/null @@ -1,158 +0,0 @@ - - - - - diff --git a/src/components/ArtistView/TopTracks.vue b/src/components/ArtistView/TopTracks.vue deleted file mode 100644 index 8535330..0000000 --- a/src/components/ArtistView/TopTracks.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - - - diff --git a/src/components/ArtistsExplorer/ArtistsList.vue b/src/components/ArtistsExplorer/ArtistsList.vue deleted file mode 100644 index 0cb736f..0000000 --- a/src/components/ArtistsExplorer/ArtistsList.vue +++ /dev/null @@ -1,126 +0,0 @@ - - - - diff --git a/src/components/ArtistsExplorer/TopArtists.vue b/src/components/ArtistsExplorer/TopArtists.vue deleted file mode 100644 index 08b4c61..0000000 --- a/src/components/ArtistsExplorer/TopArtists.vue +++ /dev/null @@ -1,160 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/components/BottomBar.vue b/src/components/BottomBar.vue deleted file mode 100644 index c8b2be3..0000000 --- a/src/components/BottomBar.vue +++ /dev/null @@ -1,253 +0,0 @@ - - - - - diff --git a/src/components/ContextMenu.vue b/src/components/ContextMenu.vue deleted file mode 100644 index ea7361a..0000000 --- a/src/components/ContextMenu.vue +++ /dev/null @@ -1,91 +0,0 @@ - - - - - diff --git a/src/components/Contextmenu/ContextItem.vue b/src/components/Contextmenu/ContextItem.vue deleted file mode 100644 index b356d20..0000000 --- a/src/components/Contextmenu/ContextItem.vue +++ /dev/null @@ -1,205 +0,0 @@ - - - - - diff --git a/src/components/FolderView/FolderItem.vue b/src/components/FolderView/FolderItem.vue deleted file mode 100644 index a4006d9..0000000 --- a/src/components/FolderView/FolderItem.vue +++ /dev/null @@ -1,62 +0,0 @@ - - - - - diff --git a/src/components/FolderView/FolderList.vue b/src/components/FolderView/FolderList.vue deleted file mode 100644 index c4398d3..0000000 --- a/src/components/FolderView/FolderList.vue +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/src/components/HomePage.vue b/src/components/HomePage.vue deleted file mode 100644 index 378e5b0..0000000 --- a/src/components/HomePage.vue +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/src/components/LeftSidebar/NP/HotKeys.vue b/src/components/LeftSidebar/NP/HotKeys.vue deleted file mode 100644 index 1f495c1..0000000 --- a/src/components/LeftSidebar/NP/HotKeys.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - - - diff --git a/src/components/LeftSidebar/NP/Progress.vue b/src/components/LeftSidebar/NP/Progress.vue deleted file mode 100644 index 2f998a2..0000000 --- a/src/components/LeftSidebar/NP/Progress.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - diff --git a/src/components/LeftSidebar/NP/SongCard.vue b/src/components/LeftSidebar/NP/SongCard.vue deleted file mode 100644 index babcfde..0000000 --- a/src/components/LeftSidebar/NP/SongCard.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - - - diff --git a/src/components/LeftSidebar/NP/otherKeys.vue b/src/components/LeftSidebar/NP/otherKeys.vue deleted file mode 100644 index d4cd915..0000000 --- a/src/components/LeftSidebar/NP/otherKeys.vue +++ /dev/null @@ -1,31 +0,0 @@ - - - diff --git a/src/components/LeftSidebar/Navigation.vue b/src/components/LeftSidebar/Navigation.vue deleted file mode 100644 index 60d7a2f..0000000 --- a/src/components/LeftSidebar/Navigation.vue +++ /dev/null @@ -1,129 +0,0 @@ - - - - - diff --git a/src/components/LeftSidebar/NowPlayingImg.vue b/src/components/LeftSidebar/NowPlayingImg.vue deleted file mode 100644 index 05fe673..0000000 --- a/src/components/LeftSidebar/NowPlayingImg.vue +++ /dev/null @@ -1,17 +0,0 @@ - - - - diff --git a/src/components/LeftSidebar/index.vue b/src/components/LeftSidebar/index.vue deleted file mode 100644 index 002ddae..0000000 --- a/src/components/LeftSidebar/index.vue +++ /dev/null @@ -1,20 +0,0 @@ - - - \ No newline at end of file diff --git a/src/components/Logo.vue b/src/components/Logo.vue deleted file mode 100644 index 44fa47e..0000000 --- a/src/components/Logo.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/src/components/Notification.vue b/src/components/Notification.vue deleted file mode 100644 index 86a5494..0000000 --- a/src/components/Notification.vue +++ /dev/null @@ -1,93 +0,0 @@ - - - - diff --git a/src/components/PlaylistView/ArtistsList.vue b/src/components/PlaylistView/ArtistsList.vue deleted file mode 100644 index b05da25..0000000 --- a/src/components/PlaylistView/ArtistsList.vue +++ /dev/null @@ -1,47 +0,0 @@ - - - - - diff --git a/src/components/PlaylistView/Header.vue b/src/components/PlaylistView/Header.vue deleted file mode 100644 index 71e571d..0000000 --- a/src/components/PlaylistView/Header.vue +++ /dev/null @@ -1,163 +0,0 @@ - - - - - diff --git a/src/components/PlaylistsList/PlaylistCard.vue b/src/components/PlaylistsList/PlaylistCard.vue deleted file mode 100644 index 77e93d5..0000000 --- a/src/components/PlaylistsList/PlaylistCard.vue +++ /dev/null @@ -1,82 +0,0 @@ - - - - - diff --git a/src/components/RightSideBar/Home/Main.vue b/src/components/RightSideBar/Home/Main.vue deleted file mode 100644 index bfbf0ac..0000000 --- a/src/components/RightSideBar/Home/Main.vue +++ /dev/null @@ -1,18 +0,0 @@ - - - - - diff --git a/src/components/RightSideBar/Home/Recommendation.vue b/src/components/RightSideBar/Home/Recommendation.vue deleted file mode 100644 index fedad7a..0000000 --- a/src/components/RightSideBar/Home/Recommendation.vue +++ /dev/null @@ -1,66 +0,0 @@ - - - - - diff --git a/src/components/RightSideBar/Main.vue b/src/components/RightSideBar/Main.vue deleted file mode 100644 index f740150..0000000 --- a/src/components/RightSideBar/Main.vue +++ /dev/null @@ -1,66 +0,0 @@ - - - - - diff --git a/src/components/RightSideBar/Queue.vue b/src/components/RightSideBar/Queue.vue deleted file mode 100644 index d00df8c..0000000 --- a/src/components/RightSideBar/Queue.vue +++ /dev/null @@ -1,81 +0,0 @@ - - - - - diff --git a/src/components/RightSideBar/Queue/QueueActions.vue b/src/components/RightSideBar/Queue/QueueActions.vue deleted file mode 100644 index 47af51d..0000000 --- a/src/components/RightSideBar/Queue/QueueActions.vue +++ /dev/null @@ -1,46 +0,0 @@ - - - - - diff --git a/src/components/RightSideBar/Queue/upNext.vue b/src/components/RightSideBar/Queue/upNext.vue deleted file mode 100644 index 276cf40..0000000 --- a/src/components/RightSideBar/Queue/upNext.vue +++ /dev/null @@ -1,65 +0,0 @@ - - - - - diff --git a/src/components/RightSideBar/Search/ArtistGrid.vue b/src/components/RightSideBar/Search/ArtistGrid.vue deleted file mode 100644 index 916b63e..0000000 --- a/src/components/RightSideBar/Search/ArtistGrid.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - - - diff --git a/src/components/RightSideBar/Search/LoadMore.vue b/src/components/RightSideBar/Search/LoadMore.vue deleted file mode 100644 index 4643f51..0000000 --- a/src/components/RightSideBar/Search/LoadMore.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - - - diff --git a/src/components/RightSideBar/Search/Main.vue b/src/components/RightSideBar/Search/Main.vue deleted file mode 100644 index 4a9a933..0000000 --- a/src/components/RightSideBar/Search/Main.vue +++ /dev/null @@ -1,59 +0,0 @@ - - - - - diff --git a/src/components/RightSideBar/Search/Tab.vue b/src/components/RightSideBar/Search/Tab.vue deleted file mode 100644 index d52e674..0000000 --- a/src/components/RightSideBar/Search/Tab.vue +++ /dev/null @@ -1,39 +0,0 @@ - - - diff --git a/src/components/RightSideBar/Search/TabsWrapper.vue b/src/components/RightSideBar/Search/TabsWrapper.vue deleted file mode 100644 index bb4d71c..0000000 --- a/src/components/RightSideBar/Search/TabsWrapper.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - - - diff --git a/src/components/RightSideBar/Search/TracksGrid.vue b/src/components/RightSideBar/Search/TracksGrid.vue deleted file mode 100644 index 26cda85..0000000 --- a/src/components/RightSideBar/Search/TracksGrid.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - - - diff --git a/src/components/RightSideBar/SearchInput.vue b/src/components/RightSideBar/SearchInput.vue deleted file mode 100644 index 1568ac4..0000000 --- a/src/components/RightSideBar/SearchInput.vue +++ /dev/null @@ -1,113 +0,0 @@ - - - - - diff --git a/src/components/RightSideBar/Tabs.vue b/src/components/RightSideBar/Tabs.vue deleted file mode 100644 index 2c2392c..0000000 --- a/src/components/RightSideBar/Tabs.vue +++ /dev/null @@ -1,75 +0,0 @@ - - - - - diff --git a/src/components/SearchPage/FetchMore.vue b/src/components/SearchPage/FetchMore.vue deleted file mode 100644 index 4266aea..0000000 --- a/src/components/SearchPage/FetchMore.vue +++ /dev/null @@ -1,12 +0,0 @@ - - diff --git a/src/components/SettingsView/Components/Select.vue b/src/components/SettingsView/Components/Select.vue deleted file mode 100644 index af8d74d..0000000 --- a/src/components/SettingsView/Components/Select.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - - - diff --git a/src/components/SettingsView/Components/Switch.vue b/src/components/SettingsView/Components/Switch.vue deleted file mode 100644 index 72b5324..0000000 --- a/src/components/SettingsView/Components/Switch.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - - - diff --git a/src/components/SettingsView/Content.vue b/src/components/SettingsView/Content.vue deleted file mode 100644 index 612ae48..0000000 --- a/src/components/SettingsView/Content.vue +++ /dev/null @@ -1,26 +0,0 @@ - - - - - diff --git a/src/components/SettingsView/Group.vue b/src/components/SettingsView/Group.vue deleted file mode 100644 index 7d3e35c..0000000 --- a/src/components/SettingsView/Group.vue +++ /dev/null @@ -1,96 +0,0 @@ - - - - - diff --git a/src/components/SettingsView/Nav.vue b/src/components/SettingsView/Nav.vue deleted file mode 100644 index c4888eb..0000000 --- a/src/components/SettingsView/Nav.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - - - diff --git a/src/components/WelcomeModal.vue b/src/components/WelcomeModal.vue deleted file mode 100644 index 683bc42..0000000 --- a/src/components/WelcomeModal.vue +++ /dev/null @@ -1,63 +0,0 @@ - - - - - diff --git a/src/components/modal.vue b/src/components/modal.vue deleted file mode 100644 index e03110b..0000000 --- a/src/components/modal.vue +++ /dev/null @@ -1,107 +0,0 @@ - - - - - diff --git a/src/components/modals/ConfirmModal.vue b/src/components/modals/ConfirmModal.vue deleted file mode 100644 index fd28b06..0000000 --- a/src/components/modals/ConfirmModal.vue +++ /dev/null @@ -1,32 +0,0 @@ - - - - - diff --git a/src/components/modals/NewPlaylist.vue b/src/components/modals/NewPlaylist.vue deleted file mode 100644 index 8179847..0000000 --- a/src/components/modals/NewPlaylist.vue +++ /dev/null @@ -1,105 +0,0 @@ - - - - - diff --git a/src/components/modals/updatePlaylist.vue b/src/components/modals/updatePlaylist.vue deleted file mode 100644 index 5b0241c..0000000 --- a/src/components/modals/updatePlaylist.vue +++ /dev/null @@ -1,206 +0,0 @@ - - - - - diff --git a/src/components/nav/NavBar.vue b/src/components/nav/NavBar.vue deleted file mode 100644 index 66f5564..0000000 --- a/src/components/nav/NavBar.vue +++ /dev/null @@ -1,123 +0,0 @@ - - - - - diff --git a/src/components/nav/NavButtons.vue b/src/components/nav/NavButtons.vue deleted file mode 100644 index 8c28944..0000000 --- a/src/components/nav/NavButtons.vue +++ /dev/null @@ -1,48 +0,0 @@ - - - - - diff --git a/src/components/nav/Titles/ArtistDiscographyTitle.vue b/src/components/nav/Titles/ArtistDiscographyTitle.vue deleted file mode 100644 index 967a612..0000000 --- a/src/components/nav/Titles/ArtistDiscographyTitle.vue +++ /dev/null @@ -1,118 +0,0 @@ - - - - - diff --git a/src/components/nav/Titles/Folder.vue b/src/components/nav/Titles/Folder.vue deleted file mode 100644 index 774fc8f..0000000 --- a/src/components/nav/Titles/Folder.vue +++ /dev/null @@ -1,135 +0,0 @@ - - - - - diff --git a/src/components/nav/Titles/PlaylistsTitle.vue b/src/components/nav/Titles/PlaylistsTitle.vue deleted file mode 100644 index 703130a..0000000 --- a/src/components/nav/Titles/PlaylistsTitle.vue +++ /dev/null @@ -1,27 +0,0 @@ - - - - - diff --git a/src/components/nav/Titles/QueueTitle.vue b/src/components/nav/Titles/QueueTitle.vue deleted file mode 100644 index c650424..0000000 --- a/src/components/nav/Titles/QueueTitle.vue +++ /dev/null @@ -1,154 +0,0 @@ - - - - - diff --git a/src/components/nav/Titles/SearchTitle.vue b/src/components/nav/Titles/SearchTitle.vue deleted file mode 100644 index 40d5ebc..0000000 --- a/src/components/nav/Titles/SearchTitle.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - - - diff --git a/src/components/nav/Titles/SettingsTitle.vue b/src/components/nav/Titles/SettingsTitle.vue deleted file mode 100644 index 8a78479..0000000 --- a/src/components/nav/Titles/SettingsTitle.vue +++ /dev/null @@ -1,18 +0,0 @@ - - - - - diff --git a/src/components/nav/Titles/SimpleNav.vue b/src/components/nav/Titles/SimpleNav.vue deleted file mode 100644 index 717a568..0000000 --- a/src/components/nav/Titles/SimpleNav.vue +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/src/components/shared/AlbumCard.vue b/src/components/shared/AlbumCard.vue deleted file mode 100644 index 437438f..0000000 --- a/src/components/shared/AlbumCard.vue +++ /dev/null @@ -1,144 +0,0 @@ - - - - - diff --git a/src/components/shared/ArtistCard.vue b/src/components/shared/ArtistCard.vue deleted file mode 100644 index 4189dfb..0000000 --- a/src/components/shared/ArtistCard.vue +++ /dev/null @@ -1,56 +0,0 @@ - - - - - diff --git a/src/components/shared/ArtistName.vue b/src/components/shared/ArtistName.vue deleted file mode 100644 index 86043b3..0000000 --- a/src/components/shared/ArtistName.vue +++ /dev/null @@ -1,54 +0,0 @@ - - - - - diff --git a/src/components/shared/HeartSvg.vue b/src/components/shared/HeartSvg.vue deleted file mode 100644 index 6913cdf..0000000 --- a/src/components/shared/HeartSvg.vue +++ /dev/null @@ -1,43 +0,0 @@ - - - - - diff --git a/src/components/shared/Loader.vue b/src/components/shared/Loader.vue deleted file mode 100644 index ecf81d0..0000000 --- a/src/components/shared/Loader.vue +++ /dev/null @@ -1,42 +0,0 @@ - - - - - diff --git a/src/components/shared/MasterFlag.vue b/src/components/shared/MasterFlag.vue deleted file mode 100644 index 08a8c88..0000000 --- a/src/components/shared/MasterFlag.vue +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/src/components/shared/NavSearchInput.vue b/src/components/shared/NavSearchInput.vue deleted file mode 100644 index 82d4839..0000000 --- a/src/components/shared/NavSearchInput.vue +++ /dev/null @@ -1,130 +0,0 @@ - - - - - diff --git a/src/components/shared/Option.vue b/src/components/shared/Option.vue deleted file mode 100644 index d6559fe..0000000 --- a/src/components/shared/Option.vue +++ /dev/null @@ -1,32 +0,0 @@ - - diff --git a/src/components/shared/PlayBtn.vue b/src/components/shared/PlayBtn.vue deleted file mode 100644 index 41dd431..0000000 --- a/src/components/shared/PlayBtn.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - - - diff --git a/src/components/shared/PlayBtnRect.vue b/src/components/shared/PlayBtnRect.vue deleted file mode 100644 index 83ae39a..0000000 --- a/src/components/shared/PlayBtnRect.vue +++ /dev/null @@ -1,40 +0,0 @@ - - - - - diff --git a/src/components/shared/SeeAll.vue b/src/components/shared/SeeAll.vue deleted file mode 100644 index 4996c66..0000000 --- a/src/components/shared/SeeAll.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - - - diff --git a/src/components/shared/SongItem.vue b/src/components/shared/SongItem.vue deleted file mode 100644 index e73676f..0000000 --- a/src/components/shared/SongItem.vue +++ /dev/null @@ -1,298 +0,0 @@ - - - - - - - diff --git a/src/components/shared/SongList.vue b/src/components/shared/SongList.vue deleted file mode 100644 index 21b6b3c..0000000 --- a/src/components/shared/SongList.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - \ No newline at end of file diff --git a/src/components/shared/TrackItem.vue b/src/components/shared/TrackItem.vue deleted file mode 100644 index 5892a9e..0000000 --- a/src/components/shared/TrackItem.vue +++ /dev/null @@ -1,197 +0,0 @@ - - - - - - diff --git a/src/composables/colors/album.ts b/src/composables/colors/album.ts deleted file mode 100644 index 069493c..0000000 --- a/src/composables/colors/album.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Returns `true` if the rgb color passed is light. - * - * @param {string} rgb The color to check whether it's light or dark. - * @returns {boolean} true if color is light, false if color is dark. - */ -export function isLight(rgb: string): boolean { - if (rgb == null || undefined) return false; - - const [r, g, b] = rgb.match(/\d+/g)!.map(Number); - const brightness = (r * 299 + g * 587 + b * 114) / 1000; - - return brightness > 165; -} - -interface BtnColor { - color: string; - isDark: boolean; -} - -/** - * Returns the luminance of a color. - * @param r The red value of the color. - * @param g The green value of the color. - * @param b The blue value of the color. - */ -export function luminance(r: any, g: any, b: any) { - let a = [r, g, b].map(function (v) { - v /= 255; - return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); - }); - return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; -} - -/** - * Returns a contrast ratio of `color1`:`color2` - * @param {string} color1 The first color - * @param {string} color2 The second color - */ -export function contrast(color1: number[], color2: number[]): number { - let lum1 = luminance(color1[0], color1[1], color1[2]); - let lum2 = luminance(color2[0], color2[1], color2[2]); - let brightest = Math.max(lum1, lum2); - let darkest = Math.min(lum1, lum2); - return (brightest + 0.05) / (darkest + 0.05); -} - -/** - * Converts a rgb color string to an array of the form: `[r, g, b]` - * @param rgb The color to convert - * @returns {number[]} The array representation of the color - */ -export function rgbToArray(rgb: string): number[] { - return rgb.match(/\d+/g)!.map(Number); -} - -/** - * Returns true if the `color2` contrast with `color1`. - * @param color1 The first color - * @param color2 The second color - */ -export function theyContrast(color1: string, color2: string) { - return contrast(rgbToArray(color1), rgbToArray(color2)) > 3; -} diff --git a/src/composables/context.ts b/src/composables/context.ts deleted file mode 100644 index 30ad3dd..0000000 --- a/src/composables/context.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Ref } from "vue"; - -import useModalStore from "@/stores/modal"; -import useQueueStore from "@/stores/queue"; -import useContextStore from "@/stores/context"; - -import { ContextSrc } from "./enums"; -import { Track } from "@/interfaces"; -import trackContext from "@/contexts/track_context"; - -/** - * Handles showing the context menu for a track component. - * @param e The MouseEvent for positioning the context menu - * @param track The track to link to the context menu - * @param flag The boolean that manages the context visibility in the source component - */ -export const showTrackContextMenu = ( - e: MouseEvent, - track: Track, - flag: Ref -) => { - const menu = useContextStore(); - - const options = () => trackContext(track, useModalStore, useQueueStore); - - menu.showContextMenu(e, options, ContextSrc.Track); - flag.value = true; - - // watch for context menu visibility and reset flag - menu.$subscribe((mutation, state) => { - if (!state.visible) { - flag.value = false; - } - }); -}; diff --git a/src/composables/enums.ts b/src/composables/enums.ts deleted file mode 100644 index b6cc5e6..0000000 --- a/src/composables/enums.ts +++ /dev/null @@ -1,61 +0,0 @@ -export enum playSources { - playlist, - album, - search, - folder, - artist, - favorite, -} - -export enum NotifType { - Success = "success", - Info = "info", - Error = "error", - Working = "working", - Favorite = "favorite", -} - -export enum FromOptions { - playlist = "playlist", - folder = "folder", - album = "album", - search = "search", - artist = "artist", - albumCard = "albumCard", - favorite = "favorite", -} - -export enum ContextSrc { - PHeader = "PHeader", - Track = "Track", - AHeader = "AHeader", - FHeader = "FHeader", -} - -export const FuseTrackOptions = { - keys: [ - { name: "title", weight: 1 }, - { name: "album", weight: 0.7 }, - { name: "artist.name", weight: 0.5 }, - { name: "albumartist", weight: 0.25 }, - ], -}; - -export enum contextChildrenShowMode { - click = "click", - hover = "hover", -} - -export enum discographyAlbumTypes { - all = "All", - albums = "Albums", - singles = "Singles", - eps = "EPs", - appearances = "Appearances", -} - -export enum favType { - artist = "artist", - album = "album", - track = "track", -} diff --git a/src/composables/favoriteHandler.ts b/src/composables/favoriteHandler.ts deleted file mode 100644 index 5804817..0000000 --- a/src/composables/favoriteHandler.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { favType } from "./enums"; -import { addFavorite, removeFavorite } from "./fetch/favorite"; -import useQueueStore from "@/stores/queue"; -/** - * Handles the favorite state of an item. - * @param setter The ref to track the is_favorite state - * @param type The type of item - * @param itemhash The hash of the item - */ -export default async function favoriteHandler( - flag: boolean | undefined, - type: favType, - itemhash: string, - setter: (x?: unknown) => void, - remover: (x?: unknown) => void -) { - const queue = useQueueStore(); - const is_current = - type === favType.track && itemhash === queue.currenttrackhash; - if (flag) { - const removed = await removeFavorite(type, itemhash); - if (removed) remover(); - } else { - const added = await addFavorite(type, itemhash); - if (added) setter(); - } - - if (is_current) { - queue.toggleFav(queue.currentindex); - } -} diff --git a/src/composables/fetch/album.ts b/src/composables/fetch/album.ts deleted file mode 100644 index a0bf62e..0000000 --- a/src/composables/fetch/album.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { paths } from "@/config"; -import { NotifType, useNotifStore } from "@/stores/notification"; -import { Album, Track } from "../../interfaces"; -import useAxios from "./useAxios"; - -const { - album: albumUrl, - albumartists: albumArtistsUrl, - albumbio: albumBioUrl, - albumsByArtistUrl, -} = paths.api; - -const getAlbumData = async (hash: string, ToastStore: typeof useNotifStore) => { - interface AlbumData { - info: Album; - tracks: Track[]; - } - - const { data, status } = await useAxios({ - url: albumUrl, - props: { - hash: hash, - }, - }); - - if (status == 204) { - ToastStore().showNotification("Album not created yet!", NotifType.Error); - } - return data as AlbumData; -}; - -const getAlbumArtists = async (hash: string) => { - const { data, error } = await useAxios({ - url: albumArtistsUrl, - props: { - hash: hash, - }, - }); - - if (error) { - console.error(error); - } - - return data.artists; -}; - -const getAlbumBio = async (hash: string) => { - const { data, status } = await useAxios({ - url: albumBioUrl, - props: { - hash: hash, - }, - }); - - if (data) { - return data.bio; - } - - if (status == 404) { - return null; - } -}; - -const getAlbumsFromArtist = async ( - albumartists: string, - limit: number = 2, - exclude: string -) => { - const { data } = await useAxios({ - url: albumsByArtistUrl, - props: { - albumartists: albumartists, - limit: limit, - exclude: exclude, - }, - }); - - if (data) { - return data.data; - } - - return []; -}; - -async function getAlbumTracks(albumhash: string): Promise { - const { data } = await useAxios({ - url: albumUrl + `/${albumhash}/` + "tracks", - get: true, - }); - - return data.tracks; -} - -export { - getAlbumData as getAlbum, - getAlbumTracks, - getAlbumArtists, - getAlbumBio, - getAlbumsFromArtist, -}; diff --git a/src/composables/fetch/artists.ts b/src/composables/fetch/artists.ts deleted file mode 100644 index ef1da75..0000000 --- a/src/composables/fetch/artists.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { NotifType, useNotifStore } from "@/stores/notification"; -import { paths } from "@/config"; -import useAxios from "./useAxios"; -import { Artist, Track, Album } from "@/interfaces"; - -const getArtistData = async (hash: string, limit: number = 5) => { - interface ArtistData { - artist: Artist; - tracks: Track[]; - } - - const { data, error, status } = await useAxios({ - get: true, - url: paths.api.artist + `/${hash}?limit=${limit}`, - }); - - if (status == 404) { - useNotifStore().showNotification("Artist not found", NotifType.Error); - } - - if (error) { - console.error(error); - } - - return data as ArtistData; -}; - -const getArtistAlbums = async (hash: string, limit = 6, all = false) => { - interface ArtistAlbums { - albums: Album[]; - eps: Album[]; - singles: Album[]; - appearances: Album[]; - artistname: string; - } - - const { data, error } = await useAxios({ - get: true, - url: paths.api.artist + `/${hash}/albums?limit=${limit}&all=${all}`, - }); - - if (error) { - console.error(error); - } - - return data as ArtistAlbums; -}; - -const getArtistTracks = async (hash: string, offset: number = 0, limit = 6) => { - const { data, error } = await useAxios({ - get: true, - url: paths.api.artist + `/${hash}/tracks?offset=${offset}&limit=${limit}`, - }); - - if (error) { - console.error(error); - } - - return data.tracks as Track[]; -}; - -export { getArtistData, getArtistAlbums, getArtistTracks }; diff --git a/src/composables/fetch/favorite.ts b/src/composables/fetch/favorite.ts deleted file mode 100644 index 8752b6b..0000000 --- a/src/composables/fetch/favorite.ts +++ /dev/null @@ -1,99 +0,0 @@ -import useAxios from "./useAxios"; -import { paths } from "@/config"; -import { favType, NotifType } from "@/composables/enums"; - -import { useNotifStore as notif } from "@/stores/notification"; -import { Album, Artist, Track } from "@/interfaces"; - -export async function addFavorite(favtype: favType, itemhash: string) { - const { data, error } = await useAxios({ - url: paths.api.addFavorite, - props: { - type: favtype, - hash: itemhash, - }, - }); - - if (error) { - notif().showNotification("Something funny happened!", NotifType.Error); - return false; - } - - if (data) { - notif().showNotification("Added to favorites!", NotifType.Success); - } - - return true; -} - -export async function removeFavorite(favtype: favType, itemhash: string) { - const { data, error } = await useAxios({ - url: paths.api.removeFavorite, - props: { - type: favtype, - hash: itemhash, - }, - }); - - if (error) { - notif().showNotification("Something funny happened!", NotifType.Error); - return false; - } - - if (data) { - notif().showNotification("Removed from favorites!", NotifType.Error); - } - - return true; -} - -export async function getAllFavs( - track_limit = 6, - album_limit = 6, - artist_limit = 6 -) { - const { data } = await useAxios({ - url: - paths.api.favorites + - `?track_limit=${track_limit}&album_limit=${album_limit}&artist_limit=${artist_limit}`, - get: true, - }); - - return data; -} - -export async function getFavAlbums(limit = 6) { - const { data } = await useAxios({ - url: paths.api.favAlbums + `?limit=${limit}`, - get: true, - }); - - return data.albums as Album[]; -} - -export async function getFavTracks(limit = 5) { - const { data } = await useAxios({ - url: paths.api.favTracks + `?limit=${limit}`, - get: true, - }); - - return data.tracks as Track[]; -} - -export async function getFavArtists(limit = 6) { - const { data } = await useAxios({ - url: paths.api.favArtists + `?limit=${limit}`, - get: true, - }); - - return data.artists as Artist[]; -} - -export async function isFavorite(itemhash: string, type: favType) { - const { data } = await useAxios({ - url: paths.api.isFavorite + `?hash=${itemhash}&type=${type}`, - get: true, - }); - - return data.is_favorite as boolean; -} diff --git a/src/composables/fetch/folders.ts b/src/composables/fetch/folders.ts deleted file mode 100644 index c4efc63..0000000 --- a/src/composables/fetch/folders.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { paths } from "@/config"; -import { Folder, Track } from "@/interfaces"; -import useAxios from "./useAxios"; - -export default async function (path: string) { - interface FolderData { - tracks: Track[]; - folders: Folder[]; - } - - const { data, error } = await useAxios({ - url: paths.api.folder, - props: { - folder: path, - }, - }); - - if (error) { - console.error(error); - } - - if (data) { - return data as FolderData; - } - - return { - tracks: [], - folders: [], - }; -} diff --git a/src/composables/fetch/playlists.ts b/src/composables/fetch/playlists.ts deleted file mode 100644 index f5dc67c..0000000 --- a/src/composables/fetch/playlists.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { paths } from "@/config"; -import { Artist, Playlist, Track } from "../../interfaces"; -import { Notification, NotifType } from "../../stores/notification"; -import useAxios from "./useAxios"; - -const { - new: newPlaylistUrl, - all: allPlaylistsUrl, - base: basePlaylistUrl, - artists: playlistArtistsUrl, -} = paths.api.playlist; - -/** - * Creates a new playlist on the server. - * @param playlist_name The name of the playlist to create. - */ -export async function createNewPlaylist(playlist_name: string, track?: Track) { - const { data, status } = await useAxios({ - url: newPlaylistUrl, - props: { - name: playlist_name, - }, - }); - - if (status == 201) { - new Notification("Playlist created successfullly!"); - - if (track) { - setTimeout(() => { - addTrackToPlaylist(data.playlist, track); - }, 1000); - } - - return { - success: true, - playlist: data.playlist as Playlist, - }; - } - - let message = "Something went wrong"; - - if (status == 409) { - message = "That playlist already exists"; - } - - new Notification(message, NotifType.Error); - - return { - success: false, - playlist: {}, - }; -} - -/** - * Fetches all playlists from the server. - * @returns {Promise} A promise that resolves to an array of playlists. - */ -export async function getAllPlaylists(): Promise { - const { data, error } = await useAxios({ - url: allPlaylistsUrl, - get: true, - }); - - if (error) console.error(error); - - if (data) { - return data.data; - } - - return []; -} - -export async function addTrackToPlaylist(playlist: Playlist, track: Track) { - const uri = `${basePlaylistUrl}/${playlist.id}/add`; - - const { status } = await useAxios({ - url: uri, - props: { - track: track.trackhash, - }, - }); - - if (status == 409) { - new Notification("Track already exists in playlist"); - return; - } - - new Notification( - track.title + " added to " + playlist.name, - NotifType.Success - ); -} - -export async function getPlaylist(pid: string) { - const uri = `${basePlaylistUrl}/${pid}`; - - interface PlaylistData { - info: Playlist; - tracks: Track[]; - } - - const { data, status } = await useAxios({ - url: uri, - get: true, - }); - - if (status == 404) { - new Notification("Playlist not found", NotifType.Error); - } - - if (data) { - return data as PlaylistData; - } - - return null; -} - -export async function updatePlaylist( - pid: string, - playlist: FormData, - pStore: any -) { - const uri = `${basePlaylistUrl}/${pid}/update`; - - const { data, status } = await useAxios({ - url: uri, - put: true, - props: playlist, - headers: { - "Content-Type": "multipart/form-data", - }, - }); - - if (status == 400) { - new Notification("Failed: Unsupported image", NotifType.Error); - return; - } - - if (data) { - pStore.updatePInfo(data.data); - new Notification("Playlist updated!"); - } -} - -/** - * Gets the artists in a playlist. - * @param pid The playlist id to fetch tracks for. - * @returns {Promise} A promise that resolves to an array of artists. - */ -export async function getPlaylistArtists(pid: string): Promise { - const { data, error } = await useAxios({ - url: playlistArtistsUrl, - props: { - pid: pid, - }, - }); - - if (error) { - new Notification("Something funny happened!", NotifType.Error); - } - - if (data) { - return data.data as Artist[]; - } - - return []; -} - -export async function deletePlaylist(pid: string) { - const { status } = await useAxios({ - url: paths.api.playlist.base + "/delete", - props: { - pid, - }, - }); - - if (status == 200) { - new Notification("Playlist deleted", NotifType.Success); - } -} - -export async function updateBannerPos(pid: number, pos: number) { - const { status } = await useAxios({ - url: paths.api.playlist.base + `/${pid}/set-image-pos`, - props: { - pos, - }, - }); - - if (status === 200) { - new Notification("Image position saved", NotifType.Info); - return; - } - - new Notification("Unable to save image position", NotifType.Error); -} diff --git a/src/composables/fetch/searchMusic.ts b/src/composables/fetch/searchMusic.ts deleted file mode 100644 index 6d2cf26..0000000 --- a/src/composables/fetch/searchMusic.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { paths } from "@/config"; -import axios from "axios"; -import useAxios from "./useAxios"; - -const { - tracks: searchTracksUrl, - albums: searchAlbumsUrl, - artists: searchArtistsUrl, - load: loadMoreUrl, -} = paths.api.search; - -/** - * Fetch data from url - * @param url url to fetch json from - * @returns promise that resolves to the JSON - */ -async function fetchData(url: string) { - const { data } = await useAxios({ - url: url, - get: true, - }); - - return data; -} - -async function searchTracks(query: string) { - const url = searchTracksUrl + encodeURIComponent(query.trim()); - return await fetchData(url); -} - -async function searchAlbums(query: string) { - const url = searchAlbumsUrl + encodeURIComponent(query.trim()); - return await fetchData(url); -} - -async function searchArtists(query: string) { - const url = searchArtistsUrl + encodeURIComponent(query.trim()); - return await fetchData(url); -} - -async function loadMoreTracks(index: number) { - const response = await axios.get(loadMoreUrl, { - params: { - type: "tracks", - index: index, - }, - }); - - return response.data; -} - -async function loadMoreAlbums(index: number) { - const response = await axios.get(loadMoreUrl, { - params: { - type: "albums", - index: index, - }, - }); - - return response.data; -} - -async function loadMoreArtists(index: number) { - const response = await axios.get(loadMoreUrl, { - params: { - type: "artists", - index: index, - }, - }); - - return response.data; -} - -export { - searchTracks, - searchAlbums, - searchArtists, - loadMoreTracks, - loadMoreAlbums, - loadMoreArtists, -}; - -// TODO: Rewrite this module using `useAxios` hook diff --git a/src/composables/fetch/useAxios.ts b/src/composables/fetch/useAxios.ts deleted file mode 100644 index 3da8457..0000000 --- a/src/composables/fetch/useAxios.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { FetchProps } from "../../interfaces"; -import axios, { AxiosError, AxiosResponse } from "axios"; - -import useLoaderStore from "@/stores/loader"; -import { paths } from "@/config"; - -const url = paths.api - -export default async (args: FetchProps) => { - let data: any = null; - let error: string | null = null; - let status: number | null = null; - - function getAxios() { - if (args.get) { - return axios.get(args.url, args.props); - } - - if (args.put) { - return axios.put(args.url, args.props, args.headers); - } - - return axios.post(args.url, args.props); - } - - const { startLoading, stopLoading } = useLoaderStore(); - startLoading(); - await getAxios() - .then((res: AxiosResponse) => { - data = res.data; - status = res.status; - }) - .catch((err: AxiosError) => { - error = err.message as string; - status = err.response?.status as number; - }) - .then(() => stopLoading()); - - return { data, error, status }; -}; diff --git a/src/composables/mediaNotification.ts b/src/composables/mediaNotification.ts deleted file mode 100644 index 70285af..0000000 --- a/src/composables/mediaNotification.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { paths } from "../config"; - -import useQueueStore from "../stores/queue"; - -export default () => { - if ("mediaSession" in navigator) { - const queue = useQueueStore(); - const { currenttrack: track } = queue; - - if (track === undefined) { - return; - } - - const url = paths.images.thumb.large; - - navigator.mediaSession.metadata = new window.MediaMetadata({ - title: track.title, - artist: track.artist.map((a) => a.name).join(", "), - artwork: [ - { - src: url + track.image, - sizes: "96x96", - type: "image/jpeg", - }, - { - src: url + track.image, - sizes: "128x128", - type: "image/webp", - }, - { - src: url + track.image, - sizes: "192x192", - type: "image/webp", - }, - { - src: url + track.image, - sizes: "256x256", - type: "image/webp", - }, - { - src: url + track.image, - sizes: "384x384", - type: "image/webp", - }, - { - src: url + track.image, - sizes: "512x512", - type: "image/webp", - }, - ], - }); - - navigator.mediaSession.setActionHandler("play", () => { - queue.playPause(); - }); - navigator.mediaSession.setActionHandler("pause", () => { - queue.playPause(); - }); - navigator.mediaSession.setActionHandler("previoustrack", () => { - queue.playPrev(); - }); - navigator.mediaSession.setActionHandler("nexttrack", () => { - queue.playNext(); - }); - } -}; diff --git a/src/composables/normalizeContextMenu.ts b/src/composables/normalizeContextMenu.ts deleted file mode 100644 index ddc17f9..0000000 --- a/src/composables/normalizeContextMenu.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { getElem } from "./perks"; - -export default (mouseX: number, mouseY: number) => { - const scope = getElem("app", "id"); - const contextMenu = getElem("context-menu", "class"); - // ? compute what is the mouse position relative to the container element - // (scope) - let { left: scopeOffsetX, top: scopeOffsetY } = scope.getBoundingClientRect(); - - scopeOffsetX = scopeOffsetX < 0 ? 0 : scopeOffsetX; - scopeOffsetY = scopeOffsetY < 0 ? 0 : scopeOffsetY; - - const scopeX = mouseX - scopeOffsetX; - const scopeY = mouseY - scopeOffsetY; - - // ? check if the element will go out of bounds - const outOfBoundsOnX = scopeX + contextMenu.clientWidth > scope.clientWidth; - - const outOfBoundsOnY = scopeY + contextMenu.clientHeight > scope.clientHeight; - - let normalX = mouseX; - let normalY = mouseY; - let normalizedX = false; - let normalizedY = false; - - if (window.innerWidth - normalX < 375) { - normalizedX = true; - } - // ? normalize on X - if (outOfBoundsOnX) { - normalX = scopeOffsetX + scope.clientWidth - contextMenu.clientWidth; - normalX -= 10; - } - - // ? normalize on Y - if (outOfBoundsOnY) { - normalY = scopeOffsetY + scope.clientHeight - contextMenu.clientHeight; - normalY -= 10; - - normalizedY = true; - } - - return { normalX, normalY, normalizedX, normalizedY }; -}; diff --git a/src/composables/perks.ts b/src/composables/perks.ts deleted file mode 100644 index eca9a2b..0000000 --- a/src/composables/perks.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { RouteLocationNormalized } from "vue-router"; - -function getElem(id: string, type: string) { - switch (type) { - case "class": { - return document.getElementsByClassName(id)[0]; - } - case "id": { - return document.getElementById(id); - } - } -} - -type r = RouteLocationNormalized; - -function isSameRoute(to: r, from: r) { - if (to.params.path == from.params.path) { - return true; - } - - return false; -} - -export { getElem, isSameRoute }; diff --git a/src/composables/state.ts b/src/composables/state.ts deleted file mode 100644 index e876ab7..0000000 --- a/src/composables/state.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ref } from "@vue/reactivity"; - -const loading = ref(false); -const settings = { - uri: "http://10.5.8.81:1970", -}; - -export default { - loading, - settings, -}; diff --git a/src/composables/useBreakpoints.ts b/src/composables/useBreakpoints.ts deleted file mode 100644 index 6d1756e..0000000 --- a/src/composables/useBreakpoints.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useBreakpoints } from "@vueuse/core"; - -const breakpoints = useBreakpoints({ - xl: 1280, - xxl: 1720, -}); - -const xl = breakpoints.greater("xl"); -const xxl = breakpoints.greater("xxl"); - -export { xl, xxl }; diff --git a/src/composables/useKeyboard.ts b/src/composables/useKeyboard.ts deleted file mode 100644 index cb1e5bf..0000000 --- a/src/composables/useKeyboard.ts +++ /dev/null @@ -1,86 +0,0 @@ -import useQStore from "@/stores/queue"; - -let key_down_fired = false; - -function focusPageSearchBox() { - const elem = document.getElementById( - "page-search-trigger" - ) as HTMLButtonElement; - if (elem) { - elem.dispatchEvent(new MouseEvent("click", { bubbles: false })); - } -} - -export default function (queue: typeof useQStore) { - const q = queue(); - window.addEventListener("keydown", (e: KeyboardEvent) => { - const target = e.target as HTMLElement; - if (e.altKey) return; - if (e.shiftKey) return; - - let ctrlKey = e.ctrlKey; - - function FocusedOnInput(target: HTMLElement) { - return target.tagName === "INPUT" || target.tagName === "TEXTAREA"; - } - - if (FocusedOnInput(target)) return; - - switch (e.key) { - case "ArrowRight": - { - if (!key_down_fired) { - key_down_fired = true; - - setTimeout(() => { - key_down_fired = false; - }, 1000); - - q.playNext(); - } - } - break; - - case "ArrowLeft": - { - if (!key_down_fired) { - key_down_fired = true; - - q.playPrev(); - - setTimeout(() => { - key_down_fired = false; - }, 1000); - } - } - - break; - - case " ": - { - if (!key_down_fired) { - e.preventDefault(); - key_down_fired = true; - - q.playPause(); - } - } - - break; - - case "f": { - if (!key_down_fired) { - if (!ctrlKey) return; - e.preventDefault(); - focusPageSearchBox(); - - key_down_fired = true; - } - } - } - }); -} - -window.addEventListener("keyup", () => { - key_down_fired = false; -}); diff --git a/src/composables/usePlayFrom.ts b/src/composables/usePlayFrom.ts deleted file mode 100644 index 4f188a1..0000000 --- a/src/composables/usePlayFrom.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { NotifType, playSources } from "@/composables/enums"; - -import { useNotifStore } from "@/stores/notification"; -import useAStore from "@/stores/pages/album"; -import useArtistPageStore from "@/stores/pages/artist"; -import useFStore from "@/stores/pages/folder"; -import usePStore from "@/stores/pages/playlist"; -import useQStore from "@/stores/queue"; -import useSettingsStore from "@/stores/settings"; - -import { getAlbumTracks } from "./fetch/album"; -import { getArtistTracks } from "./fetch/artists"; - -const queue = useQStore; -const folder = useFStore; -const album = useAStore; -const playlist = usePStore; -const artist = useArtistPageStore; - -type store = - | typeof queue - | typeof folder - | typeof album - | typeof playlist - | typeof artist; - -export default async function play( - source: playSources, - aqueue: typeof queue, - store: store -) { - const useQueue = aqueue(); - - switch (source) { - // check which route the play request come from - // case playSources.folder: - // store = store as typeof folder; - // const f = store(); - - // useQueue.playFromFolder(f.path, f.tracks); - // useQueue.play(); - // break; - case playSources.album: - store = store as typeof album; - const a_store = store(); - - useQueue.playFromAlbum( - a_store.info.title, - a_store.info.albumhash, - a_store.srcTracks - ); - useQueue.play(); - break; - case playSources.playlist: - store = store as typeof playlist; - const p = store(); - - if (p.tracks.length === 0) return; - - useQueue.playFromPlaylist(p.info.name, p.info.id, p.tracks); - useQueue.play(); - break; - - case playSources.artist: - store = store as typeof artist; - utilPlayFromArtist(useQStore, useArtistPageStore, 0); - } -} - -async function utilPlayFromArtist( - queue: typeof useQStore, - artist: typeof useArtistPageStore, - index: number = 0 -) { - const qu = queue(); - const ar = artist(); - const settings = useSettingsStore(); - - if (ar.tracks.length === 0) return; - - if (ar.info.trackcount <= settings.artist_top_tracks_count) { - qu.playFromArtist(ar.info.artisthash, ar.info.name, ar.tracks); - qu.play(); - return; - } - - const tracks = await getArtistTracks(ar.info.artisthash); - - qu.playFromArtist(ar.info.artisthash, ar.info.name, tracks); - qu.play(index); -} - -async function playFromAlbumCard( - queue: typeof useQStore, - albumhash: string, - albumname: string -) { - const qu = queue(); - - const tracks = await getAlbumTracks(albumhash); - - if (tracks.length === 0) { - useNotifStore().showNotification("Album tracks not found", NotifType.Error); - return; - } - - qu.playFromAlbum(albumname, albumhash, tracks); - qu.play(); -} - -export { utilPlayFromArtist, playFromAlbumCard }; diff --git a/src/composables/useWaitForScroll.ts b/src/composables/useWaitForScroll.ts deleted file mode 100644 index 41127da..0000000 --- a/src/composables/useWaitForScroll.ts +++ /dev/null @@ -1,41 +0,0 @@ -// CREDITS: https://stackoverflow.com/a/66664192 - -/** - * Scrolls and waits for the scroll to finish. Returns a promise that resolves when the scroll is finished. - * @param elem The element to scroll and wait for - * @param pos The position to scroll to - * @param delay The delay in seconds to wait for - * @returns A promise that resolves when the element has been scrolled to the position - */ -export default function waitForScrollEnd( - elem: HTMLElement, - pos = 0, - delay = 100 -): Promise { - elem.scroll({ - top: pos, - behavior: "smooth", - }); - const frame_limit = 20; - let last_changed_frame = 0; - let last_y = elem.scrollTop; - - return new Promise((resolve) => { - function tick(frames: number) { - // We requestAnimationFrame either for 500 frames or until 20 frames with - // no change have been observed. - if (frames >= 500 || frames - last_changed_frame > frame_limit) { - setTimeout(() => { - resolve(); - }, delay); - } else { - if (window.scrollY != last_y) { - last_changed_frame = frames; - last_y = window.scrollY; - } - requestAnimationFrame(tick.bind(null, frames + 1)); - } - } - tick(0); - }); -} diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index 1665c42..0000000 --- a/src/config.ts +++ /dev/null @@ -1,89 +0,0 @@ -const development = import.meta.env.DEV; -const dev_url = "http://localhost:1970"; - -const baseApiUrl = development ? dev_url : ""; -const baseImgUrl = baseApiUrl + "/img"; - -const imageRoutes = { - thumb: { - large: "/t/", - small: "/t/s/", - }, - artist: { - large: "/a/", - small: "/a/s/", - }, - playlist: "/p/", - raw: "/raw/", -}; - -const paths = { - api: { - album: baseApiUrl + "/album", - favorite: baseApiUrl + "/favorite", - favorites: baseApiUrl + "/favorites", - favAlbums: baseApiUrl + "/albums/favorite", - favTracks: baseApiUrl + "/tracks/favorite", - favArtists: baseApiUrl + "/artists/favorite", - isFavorite: baseApiUrl + "/favorites/check", - artist: baseApiUrl + "/artist", - get addFavorite() { - return this.favorite + "/add"; - }, - get removeFavorite() { - return this.favorite + "/remove"; - }, - get albumartists() { - return this.album + "/artists"; - }, - get albumbio() { - return this.album + "/bio"; - }, - get albumsByArtistUrl() { - return this.album + "/from-artist"; - }, - folder: baseApiUrl + "/folder", - playlist: { - base: baseApiUrl + "/playlist", - get new() { - return this.base + "/new"; - }, - get all() { - return this.base + "s"; - }, - get artists() { - return this.base + "/artists"; - }, - }, - search: { - base: baseApiUrl + "/search", - get tracks() { - return this.base + "/tracks?q="; - }, - get albums() { - return this.base + "/albums?q="; - }, - get artists() { - return this.base + "/artists?q="; - }, - get load() { - return this.base + "/loadmore"; - }, - }, - files: baseApiUrl + "/file", - }, - images: { - thumb: { - small: baseImgUrl + imageRoutes.thumb.small, - large: baseImgUrl + imageRoutes.thumb.large, - }, - artist: { - small: baseImgUrl + imageRoutes.artist.small, - large: baseImgUrl + imageRoutes.artist.large, - }, - playlist: baseImgUrl + imageRoutes.playlist, - raw: baseImgUrl + imageRoutes.raw, - }, -}; - -export { paths }; diff --git a/src/contexts/playlist.ts b/src/contexts/playlist.ts deleted file mode 100644 index f06eecd..0000000 --- a/src/contexts/playlist.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Option } from "../interfaces"; - -export default async () => { - const deletePlaylist: Option = { - label: "Delete playlist", - critical: true, - action: () => { - console.log("delete playlist"); - }, - }; - - const playNext: Option = { - label: "Play next", - action: () => { - console.log("play next"); - }, - }; - - const addToQueue: Option = { - label: "Add to queue", - action: () => { - console.log("add to queue"); - }, - }; - - - return [playNext, addToQueue, deletePlaylist]; -}; diff --git a/src/contexts/track_context.ts b/src/contexts/track_context.ts deleted file mode 100644 index a2287da..0000000 --- a/src/contexts/track_context.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { Artist, Playlist, Track } from "../interfaces"; - -// @ts-ignore -import { Option } from "../interfaces"; -import Router from "../router"; - -import { - addTrackToPlaylist, - getAllPlaylists, -} from "../composables/fetch/playlists"; - -import useModalStore from "../stores/modal"; -import useQueueStore from "../stores/queue"; -import { Routes } from "@/router/routes"; -/** - * Returns a list of context menu items for a track. - * @param {any} track a track object. - * @param {any} modalStore a pinia store. - * @return {Array