@ -1,3 +0,0 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
11
.eslintrc.js
@ -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",
|
||||
},
|
||||
};
|
21
.gitignore
vendored
@ -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
|
||||
@ -24,3 +13,13 @@ pnpm-debug.log*
|
||||
*.sw?
|
||||
|
||||
__pycache__
|
||||
.hypothesis
|
||||
sqllib.py
|
||||
encoderx.py
|
||||
tests
|
||||
.pytest_cache
|
||||
|
||||
# pyinstaller files
|
||||
dist
|
||||
build
|
||||
client
|
93
README.md
@ -1,5 +1,94 @@
|
||||
### Swing music client
|
||||
This repo contains the client code for the swing music player. Documentation coming soon ...
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 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 <http://localhost:1970> 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**
|
||||
|
44
alice.spec
Normal file
@ -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,
|
||||
)
|
30
app/api/__init__.py
Normal file
@ -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
|
151
app/api/album.py
Normal file
@ -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/<albumhash>/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}
|
323
app/api/artist.py
Normal file
@ -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/<artisthash>", 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/<artisthash>/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/<artisthash>/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/<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()
|
||||
# }
|
210
app/api/favorites.py
Normal file
@ -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}
|
32
app/api/folder.py
Normal file
@ -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),
|
||||
}
|
226
app/api/playlist.py
Normal file
@ -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/<playlist_id>/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/<playlistid>")
|
||||
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/<playlistid>/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/<pid>/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
|
218
app/api/search.py
Normal file
@ -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,
|
||||
}
|
33
app/api/track.py
Normal file
@ -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/<trackhash>")
|
||||
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
|
214
app/db/__init__.py
Normal file
@ -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
|
47
app/db/sqlite/__init__.py
Normal file
@ -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())
|
125
app/db/sqlite/albums.py
Normal file
@ -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 []
|
36
app/db/sqlite/artists.py
Normal file
@ -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
|
77
app/db/sqlite/favorite.py
Normal file
@ -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
|
179
app/db/sqlite/playlists.py
Normal file
@ -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))
|
65
app/db/sqlite/queries.py
Normal file
@ -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
|
||||
);
|
||||
"""
|
142
app/db/sqlite/tracks.py
Normal file
@ -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
|
93
app/db/sqlite/utils.py
Normal file
@ -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()
|
459
app/db/store.py
Normal file
@ -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]
|
40
app/functions.py
Normal file
@ -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)
|
114
app/imgserver/__init__.py
Normal file
@ -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 "<h1>Image Server</h1>"
|
||||
|
||||
|
||||
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/<imgpath>")
|
||||
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/<imgpath>")
|
||||
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/<imgpath>")
|
||||
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/<imgpath>")
|
||||
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/<imgpath>")
|
||||
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/<path:imgpath>")
|
||||
# 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)
|
3
app/lib/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
This module contains all the data processing and non-API libraries
|
||||
"""
|
3
app/lib/albumslib.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
Contains methods relating to albums.
|
||||
"""
|
130
app/lib/artistlib.py
Normal file
@ -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('<a href="https://www.last.fm/')[0]
|
||||
# except KeyError:
|
||||
# bio = None
|
||||
|
||||
# return bio
|
||||
|
||||
|
||||
# class FetchAlbumBio:
|
||||
# """
|
||||
# Returns the album bio for a given album.
|
||||
# """
|
||||
|
||||
# def __init__(self, title: str, albumartist: str):
|
||||
# self.title = title
|
||||
# self.albumartist = albumartist
|
||||
|
||||
# def __call__(self):
|
||||
# return fetch_album_bio(self.title, self.albumartist)
|
94
app/lib/colorlib.py
Normal file
@ -0,0 +1,94 @@
|
||||
"""
|
||||
Contains everything that deals with image color extraction.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import colorgram
|
||||
from tqdm import tqdm
|
||||
|
||||
from app import settings
|
||||
from app.db.sqlite.albums import SQLiteAlbumMethods as db
|
||||
from app.db.sqlite.artists import SQLiteArtistMethods as adb
|
||||
from app.db.sqlite.utils import SQLiteManager
|
||||
from app.db.store import Store
|
||||
from app.models import Album, Artist
|
||||
|
||||
|
||||
def get_image_colors(image: str) -> 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.
|
47
app/lib/folderslib.py
Normal file
@ -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
|
115
app/lib/playlistlib.py
Normal file
@ -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
|
100
app/lib/populate.py
Normal file
@ -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]
|
125
app/lib/searchlib.py
Normal file
@ -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]
|
159
app/lib/taglib.py
Normal file
@ -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
|
19
app/lib/trackslib.py
Normal file
@ -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)
|
172
app/lib/watchdogg.py
Normal file
@ -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()
|
49
app/logger.py
Normal file
@ -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:
|
206
app/models.py
Normal file
@ -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"
|
55
app/serializer.py
Normal file
@ -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"
|
||||
|
103
app/settings.py
Normal file
@ -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
|
128
app/setup/__init__.py
Normal file
@ -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()
|
226
app/utils.py
Normal file
@ -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()
|
8
assets/album.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="defaultAlbumImage">
|
||||
<g id="defaultAlbumImage_2">
|
||||
<path id="Vector" d="M21 31.5C26.799 31.5 31.5 26.799 31.5 21C31.5 15.201 26.799 10.5 21 10.5C15.201 10.5 10.5 15.201 10.5 21C10.5 26.799 15.201 31.5 21 31.5Z" stroke="#78777F" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M21 23.5C22.3807 23.5 23.5 22.3807 23.5 21C23.5 19.6193 22.3807 18.5 21 18.5C19.6193 18.5 18.5 19.6193 18.5 21C18.5 22.3807 19.6193 23.5 21 23.5Z" stroke="#78777F" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 654 B |
BIN
assets/artist.webp
Normal file
After Width: | Height: | Size: 3.9 KiB |
BIN
assets/default.webp
Normal file
After Width: | Height: | Size: 1.9 KiB |
13
assets/playlist.svg
Normal file
@ -0,0 +1,13 @@
|
||||
<svg viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="defaultPlaylistImage">
|
||||
<g id="defaultPlaylistImage_2">
|
||||
<g id="Group">
|
||||
<path id="Vector" d="M14.1 29.3C15.6464 29.3 16.9 28.0464 16.9 26.5C16.9 24.9536 15.6464 23.7 14.1 23.7C12.5536 23.7 11.3 24.9536 11.3 26.5C11.3 28.0464 12.5536 29.3 14.1 29.3Z" stroke="#78777F" stroke-miterlimit="10" stroke-linecap="round"/>
|
||||
<path id="Vector_2" d="M16.9 26.5V12.8" stroke="#78777F" stroke-miterlimit="10"/>
|
||||
<path id="Vector_3" d="M21 24.2H29.3" stroke="#78777F" stroke-miterlimit="10"/>
|
||||
<path id="Vector_4" d="M21 16.9H31.1" stroke="#78777F" stroke-miterlimit="10"/>
|
||||
<path id="Vector_5" d="M21 20.5H30.2" stroke="#78777F" stroke-miterlimit="10"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 737 B |
21
index.html
@ -1,21 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<link rel="icon" href="./src/assets/icons/logo-small.svg" />
|
||||
<title>Alice</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong
|
||||
>We're sorry but this app doesn't work properly without JavaScript
|
||||
enabled. Please enable it to continue.</strong
|
||||
>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
183
manage.py
Normal file
@ -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("/<path:path>")
|
||||
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.
|
44
manage.spec
Normal file
@ -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,
|
||||
)
|
39
package.json
@ -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"
|
||||
}
|
1135
poetry.lock
generated
Normal file
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 9.5 KiB |
Before Width: | Height: | Size: 591 B |
Before Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 15 KiB |
@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow:
|
3
pyinstaller.config.ini
Normal file
@ -0,0 +1,3 @@
|
||||
[DEFAULT]
|
||||
build = False
|
||||
|
34
pyproject.toml
Normal file
@ -0,0 +1,34 @@
|
||||
[tool.poetry]
|
||||
name = "Swing music player"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = ["geoffrey45 <geoffreymungai45@gmail.com>"]
|
||||
|
||||
[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"
|
BIN
rd-me-banner.png
Normal file
After Width: | Height: | Size: 33 KiB |
30
requirements.txt
Normal file
@ -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
|
39
roadmap.md
Normal file
@ -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
|
120
src/App.vue
@ -1,120 +0,0 @@
|
||||
<template>
|
||||
<ContextMenu />
|
||||
<Modal />
|
||||
<Notification />
|
||||
<section
|
||||
id="app-grid"
|
||||
:class="{
|
||||
noSidebar: !settings.use_sidebar || !xl,
|
||||
NoSideBorders: !xxl,
|
||||
extendWidth: settings.extend_width && settings.can_extend_width,
|
||||
}"
|
||||
>
|
||||
<LeftSidebar />
|
||||
<NavBar />
|
||||
<div id="acontent" v-element-size="updateContentElemSize">
|
||||
<router-view />
|
||||
</div>
|
||||
<RightSideBar v-if="settings.use_sidebar && xl" />
|
||||
<BottomBar />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// @libraries
|
||||
import { vElementSize } from "@vueuse/components";
|
||||
import { onStartTyping } from "@vueuse/core";
|
||||
import { onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
// @stores
|
||||
import { content_width } from "@/stores/content-width";
|
||||
import useContextStore from "@/stores/context";
|
||||
import useModalStore from "@/stores/modal";
|
||||
import useQStore from "@/stores/queue";
|
||||
import useSettingsStore from "@/stores/settings";
|
||||
|
||||
// @utils
|
||||
import handleShortcuts from "@/composables/useKeyboard";
|
||||
import { readLocalStorage, writeLocalStorage } from "@/utils";
|
||||
import { xl, xxl } from "./composables/useBreakpoints";
|
||||
|
||||
// @small-components
|
||||
import ContextMenu from "@/components/ContextMenu.vue";
|
||||
import Modal from "@/components/modal.vue";
|
||||
import Notification from "@/components/Notification.vue";
|
||||
|
||||
// @app-grid-components
|
||||
import BottomBar from "@/components/BottomBar.vue";
|
||||
import NavBar from "@/components/nav/NavBar.vue";
|
||||
import RightSideBar from "@/components/RightSideBar/Main.vue";
|
||||
import LeftSidebar from "./components/LeftSidebar/index.vue";
|
||||
|
||||
const queue = useQStore();
|
||||
const router = useRouter();
|
||||
const modal = useModalStore();
|
||||
const settings = useSettingsStore();
|
||||
|
||||
queue.readQueue();
|
||||
handleShortcuts(useQStore);
|
||||
|
||||
router.afterEach(() => {
|
||||
(document.getElementById("acontent") as HTMLElement).scrollTo(0, 0);
|
||||
});
|
||||
|
||||
onStartTyping((e) => {
|
||||
if (e.ctrlKey) {
|
||||
console.log("ctrl pressed");
|
||||
}
|
||||
|
||||
const elem = document.getElementById("globalsearch") as HTMLInputElement;
|
||||
elem.focus();
|
||||
elem.value = "";
|
||||
});
|
||||
|
||||
function updateContentElemSize({ width }: { width: number }) {
|
||||
content_width.value = width;
|
||||
}
|
||||
|
||||
function handleWelcomeModal() {
|
||||
let welcomeShowCount = readLocalStorage("shown-welcome-message");
|
||||
|
||||
if (!welcomeShowCount) {
|
||||
welcomeShowCount = 0;
|
||||
}
|
||||
|
||||
if (welcomeShowCount < 2) {
|
||||
modal.showWelcomeModal();
|
||||
writeLocalStorage("shown-welcome-message", welcomeShowCount + 1);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleWelcomeModal();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "./assets/scss/mixins.scss";
|
||||
|
||||
.l-sidebar {
|
||||
position: relative;
|
||||
|
||||
.withlogo {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.l-album-art {
|
||||
width: calc(100% - 2rem);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.r-sidebar {
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1 +0,0 @@
|
||||
<svg fill="#ffffff" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path d="M 39.486328 6.9785156 A 1.50015 1.50015 0 0 0 38.439453 7.4394531 L 24 21.878906 L 9.5605469 7.4394531 A 1.50015 1.50015 0 0 0 8.484375 6.984375 A 1.50015 1.50015 0 0 0 7.4394531 9.5605469 L 21.878906 24 L 7.4394531 38.439453 A 1.50015 1.50015 0 1 0 9.5605469 40.560547 L 24 26.121094 L 38.439453 40.560547 A 1.50015 1.50015 0 1 0 40.560547 38.439453 L 26.121094 24 L 40.560547 9.5605469 A 1.50015 1.50015 0 0 0 39.486328 6.9785156 z"></path></svg>
|
Before Width: | Height: | Size: 533 B |
@ -1,4 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.02445 6.1448H25.1201C25.6925 6.1448 26.1638 5.68309 26.1638 5.11075C26.1638 4.53841 25.6925 4.0863 25.1201 4.0863H3.02445C2.45211 4.0863 2 4.53841 2 5.11075C2 5.68309 2.45211 6.1448 3.02445 6.1448ZM3.02445 12.2758H25.1201C25.6925 12.2758 26.1638 11.8216 26.1638 11.2493C26.1638 10.6769 25.6925 10.2152 25.1201 10.2152H3.02445C2.45211 10.2152 2 10.6769 2 11.2493C2 11.8216 2.45211 12.2758 3.02445 12.2758ZM16.8493 18.4144H25.1201C25.6925 18.4144 26.1638 17.9623 26.1638 17.3899C26.1638 16.8059 25.6925 16.3538 25.1201 16.3538H16.8493C16.2748 16.3538 15.8227 16.8059 15.8227 17.3899C15.8227 17.9623 16.2748 18.4144 16.8493 18.4144ZM16.8493 24.5529H25.1201C25.6925 24.5529 26.1638 24.1008 26.1638 23.5285C26.1638 22.9465 25.6925 22.4944 25.1201 22.4944H16.8493C16.2748 22.4944 15.8227 22.9465 15.8227 23.5285C15.8227 24.1008 16.2748 24.5529 16.8493 24.5529Z" fill="white"/>
|
||||
<path d="M2.01172 16.5307V18.9767C2.01172 20.8306 3.22578 21.9134 5.07218 21.9134H8.22969V23.1195C8.22969 24.132 9.19531 24.51 9.97156 23.8807L13.314 21.188C13.8155 20.7919 13.8198 20.0881 13.314 19.6824L9.97156 17.0014C9.21664 16.403 8.22969 16.7715 8.22969 17.7563V18.9514H5.42069C5.12585 18.9514 4.97374 18.7779 4.97374 18.4927V16.5054C4.97374 15.5204 4.4014 14.8658 3.48687 14.8658C2.57445 14.8658 2.01172 15.495 2.01172 16.5307Z" fill="white"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.4 KiB |
@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.83789 11.4512C5.83789 14.958 8.20215 17.3662 12.1836 17.3662H17.7383L19.7686 17.2783L18.2393 18.5703L16.0068 20.75C15.8311 20.9258 15.7168 21.1367 15.7168 21.4268C15.7168 21.9805 16.0947 22.3848 16.6748 22.3848C16.9209 22.3848 17.1934 22.2705 17.3779 22.0771L22.4229 17.1113C22.625 16.918 22.7305 16.6543 22.7305 16.3906C22.7305 16.1182 22.625 15.8545 22.4229 15.6611L17.3779 10.7041C17.1934 10.5107 16.9209 10.3965 16.6748 10.3965C16.0947 10.3965 15.7168 10.8008 15.7168 11.3457C15.7168 11.6357 15.8311 11.8555 16.0068 12.0312L18.2393 14.2021L19.7686 15.5029L17.7383 15.4062H12.1396C9.32715 15.4062 7.77148 13.8066 7.77148 11.5215C7.77148 9.24512 9.32715 7.5752 12.1396 7.5752H14.1963C14.7852 7.5752 15.1895 7.13574 15.1895 6.59082C15.1895 6.05469 14.7764 5.61523 14.1963 5.61523H12.0693C8.14941 5.61523 5.83789 7.93555 5.83789 11.4512Z" fill="#F2F2F2"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 971 B |
@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.9912 22.7422C18.9746 22.7422 23.0879 18.6289 23.0879 13.6543C23.0879 8.67969 18.9658 4.56641 13.9824 4.56641C9.00781 4.56641 4.90332 8.67969 4.90332 13.6543C4.90332 18.6289 9.0166 22.7422 13.9912 22.7422ZM13.9912 20.9316C9.95703 20.9316 6.73145 17.6885 6.73145 13.6543C6.73145 9.62012 9.95703 6.38574 13.9824 6.38574C18.0166 6.38574 21.2598 9.62012 21.2686 13.6543C21.2773 17.6885 18.0254 20.9316 13.9912 20.9316ZM14 17.0996C15.9072 17.0996 17.4453 15.5615 17.4453 13.6455C17.4453 11.7471 15.9072 10.2002 14 10.2002C12.084 10.2002 10.5459 11.7471 10.5459 13.6455C10.5459 15.5615 12.084 17.0996 14 17.0996Z" fill="#fff"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 737 B |
@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.69434 13.6455C5.69434 13.9092 5.80859 14.1641 6.01074 14.3574L11.8027 20.1494C12.0137 20.3516 12.251 20.4482 12.4883 20.4482C13.042 20.4482 13.4375 20.0527 13.4375 19.5254C13.4375 19.2529 13.332 19.0156 13.1562 18.8486L11.1875 16.8535L8.58594 14.4805L10.6426 14.6035H21.3301C21.9014 14.6035 22.3057 14.208 22.3057 13.6455C22.3057 13.0742 21.9014 12.6875 21.3301 12.6875H10.6426L8.59473 12.8105L11.1875 10.4375L13.1562 8.44238C13.332 8.2666 13.4375 8.0293 13.4375 7.75684C13.4375 7.22949 13.042 6.84277 12.4883 6.84277C12.251 6.84277 12.0137 6.93066 11.7852 7.15039L6.01074 12.9336C5.80859 13.1182 5.69434 13.3818 5.69434 13.6455Z" fill="#F2F2F2"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 763 B |
@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 13.8477C16.127 13.8477 17.8496 11.9668 17.8496 9.66406C17.8496 7.39648 16.127 5.59473 14 5.59473C11.8818 5.59473 10.1416 7.42285 10.1504 9.68164C10.1592 11.9756 11.873 13.8477 14 13.8477ZM14 12.3096C12.7871 12.3096 11.7588 11.1582 11.7588 9.68164C11.75 8.24023 12.7783 7.13281 14 7.13281C15.2305 7.13281 16.2412 8.22266 16.2412 9.66406C16.2412 11.1406 15.2217 12.3096 14 12.3096ZM8.51562 22.0215H19.4756C20.9961 22.0215 21.7256 21.5381 21.7256 20.501C21.7256 18.084 18.7109 14.8672 14 14.8672C9.28906 14.8672 6.26562 18.084 6.26562 20.501C6.26562 21.5381 6.99512 22.0215 8.51562 22.0215ZM8.24316 20.4834C8.03223 20.4834 7.95312 20.4131 7.95312 20.2549C7.95312 18.9102 10.124 16.4053 14 16.4053C17.8672 16.4053 20.0381 18.9102 20.0381 20.2549C20.0381 20.4131 19.959 20.4834 19.748 20.4834H8.24316Z" fill="#F2F2F2"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 931 B |
@ -1,3 +0,0 @@
|
||||
<svg viewBox="1.533 3.788 500.308 492.43" width="500.308" height="492.43">
|
||||
<path d="M 479.264 3.794 C 473.442 3.93 467.912 6.326 463.879 10.46 L 251.687 219.318 L 39.492 10.46 C 35.341 6.254 29.636 3.879 23.678 3.879 C 6.707 3.883 -3.894 21.968 4.593 36.431 C 5.611 38.163 6.864 39.748 8.323 41.141 L 220.515 249.998 L 8.323 458.855 C -3.92 470.424 1.153 490.698 17.453 495.348 C 25.327 497.594 33.816 495.355 39.492 489.535 L 251.687 280.678 L 463.879 489.535 C 475.633 501.584 496.231 496.592 500.955 480.547 C 503.238 472.799 500.962 464.443 495.05 458.855 L 282.856 249.998 L 495.05 41.141 C 507.293 29.574 502.225 9.3 485.925 4.647 C 483.762 4.03 481.514 3.741 479.264 3.794 Z" style="fill: rgb(255, 255, 255);"></path>
|
||||
</svg>
|
Before Width: | Height: | Size: 734 B |
@ -1,4 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.0001 1.66666C5.40508 1.66666 1.66675 5.405 1.66675 10C1.66675 14.595 5.40508 18.3333 10.0001 18.3333C14.5951 18.3333 18.3334 14.595 18.3334 10C18.3334 5.405 14.5951 1.66666 10.0001 1.66666ZM10.0001 16.6667C6.32425 16.6667 3.33341 13.6758 3.33341 10C3.33341 6.32416 6.32425 3.33333 10.0001 3.33333C13.6759 3.33333 16.6667 6.32416 16.6667 10C16.6667 13.6758 13.6759 16.6667 10.0001 16.6667Z" fill="white"/>
|
||||
<path d="M10.8334 5.83334H9.16675V10.345L11.9109 13.0892L13.0892 11.9108L10.8334 9.655V5.83334Z" fill="white"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 633 B |
@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.96582 22.7686H18.043C19.3965 22.7686 20.2666 21.9512 20.3369 20.5977L20.9258 7.94141H21.8926C22.3408 7.94141 22.6836 7.58984 22.6836 7.15039C22.6836 6.71094 22.332 6.37695 21.8926 6.37695H17.9902V5.05859C17.9902 3.70508 17.1289 2.91406 15.6611 2.91406H12.3213C10.8535 2.91406 9.99219 3.70508 9.99219 5.05859V6.37695H6.10742C5.66797 6.37695 5.31641 6.71973 5.31641 7.15039C5.31641 7.59863 5.66797 7.94141 6.10742 7.94141H7.07422L7.66309 20.5977C7.7334 21.96 8.59473 22.7686 9.96582 22.7686ZM11.6357 5.1377C11.6357 4.68945 11.9521 4.39941 12.4355 4.39941H15.5469C16.0303 4.39941 16.3467 4.68945 16.3467 5.1377V6.37695H11.6357V5.1377ZM10.1416 21.1953C9.6582 21.1953 9.30664 20.835 9.28027 20.3164L8.69141 7.94141H19.2822L18.7109 20.3164C18.6934 20.8438 18.3506 21.1953 17.8496 21.1953H10.1416ZM11.4072 19.7803C11.7852 19.7803 12.0225 19.543 12.0137 19.1914L11.75 9.99805C11.7412 9.64648 11.4951 9.41797 11.1348 9.41797C10.7656 9.41797 10.5283 9.65527 10.5371 10.0068L10.8008 19.2002C10.8096 19.5518 11.0557 19.7803 11.4072 19.7803ZM14 19.7803C14.3691 19.7803 14.624 19.5518 14.624 19.2002V10.0068C14.624 9.65527 14.3691 9.41797 14 9.41797C13.6309 9.41797 13.3848 9.65527 13.3848 10.0068V19.2002C13.3848 19.5518 13.6309 19.7803 14 19.7803ZM16.5928 19.7891C16.9443 19.7891 17.1904 19.5518 17.1992 19.2002L17.4629 10.0068C17.4717 9.65527 17.2344 9.42676 16.8652 9.42676C16.5049 9.42676 16.2588 9.65527 16.25 10.0068L15.9863 19.2002C15.9775 19.543 16.2148 19.7891 16.5928 19.7891Z"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.6 KiB |
@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.6514 13.6543C19.6426 13.3467 19.5283 13.083 19.291 12.8457L12.4531 6.15723C12.251 5.96387 12.0137 5.8584 11.7236 5.8584C11.1348 5.8584 10.6777 6.31543 10.6777 6.9043C10.6777 7.18555 10.792 7.44922 10.9941 7.65137L17.1465 13.6543L10.9941 19.6572C10.792 19.8594 10.6777 20.1143 10.6777 20.4043C10.6777 20.9932 11.1348 21.4502 11.7236 21.4502C12.0049 21.4502 12.251 21.3447 12.4531 21.1514L19.291 14.4541C19.5371 14.2256 19.6514 13.9619 19.6514 13.6543Z" fill="#F2F2F2"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 585 B |
@ -1,6 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 4V20C4 20.5304 4.21071 21.0391 4.58579 21.4142C4.96086 21.7893 5.46957 22 6 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V8.342C20 8.07556 19.9467 7.81181 19.8433 7.56624C19.7399 7.32068 19.5885 7.09824 19.398 6.912L14.958 2.57C14.5844 2.20466 14.0826 2.00007 13.56 2H6C5.46957 2 4.96086 2.21071 4.58579 2.58579C4.21071 2.96086 4 3.46957 4 4V4Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 13H15" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 17H12" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 2V6C14 6.53043 14.2107 7.03914 14.5858 7.41421C14.9609 7.78929 15.4696 8 16 8H20" stroke="white" stroke-width="2" stroke-linejoin="round"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 931 B |
@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 5.6875C5.55231 5.6875 4.375 6.86481 4.375 8.3125V19.6875C4.375 21.1352 5.55231 22.3125 7 22.3125H21C22.4477 22.3125 23.625 21.1352 23.625 19.6875V10.0625C23.625 8.61481 22.4477 7.4375 21 7.4375H12.7328C12.3369 7.4375 11.9492 7.30146 11.6407 7.05383L10.8914 6.45483C10.2732 5.96046 9.49659 5.6875 8.70471 5.6875H7ZM7 7.4375H8.70471C9.10065 7.4375 9.48873 7.57354 9.79761 7.82117L10.5461 8.42017C11.1643 8.91454 11.9409 9.1875 12.7328 9.1875H21C21.4826 9.1875 21.875 9.57994 21.875 10.0625V10.5H6.125V8.3125C6.125 7.82994 6.51744 7.4375 7 7.4375ZM6.125 12.25H21.875V19.6875C21.875 20.1701 21.4826 20.5625 21 20.5625H7C6.51744 20.5625 6.125 20.1701 6.125 19.6875V12.25ZM15.8705 13.3634L13.8214 13.7787C13.6705 13.8093 13.5625 13.9347 13.5625 14.0795V17.1086C13.5625 17.2556 13.4371 17.3717 13.025 17.4513C12.3867 17.5751 11.8125 17.8221 11.8125 18.5903C11.8125 18.9701 12.1575 19.4551 13.025 19.4551C13.7806 19.4551 14.4375 18.8381 14.4375 17.9631V15.6073C14.4375 15.5106 14.509 15.4271 14.6101 15.4065L15.9286 15.1382C16.0795 15.1076 16.1875 14.9822 16.1875 14.8374V13.6035C16.1875 13.4469 16.0337 13.3302 15.8705 13.3634Z" fill="currentColor"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.2 KiB |
@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.1807 6.99219H13.0332C12.4004 6.99219 12.0225 6.85156 11.5303 6.44727L11.0381 6.04297C10.4141 5.5332 9.95703 5.36621 9.03418 5.36621H6.54688C4.89453 5.36621 3.91895 6.33301 3.91895 8.1875V10.6484H24.0723V9.85742C24.0723 7.97656 23.0791 6.99219 21.1807 6.99219ZM6.81055 21.7666H21.3916C23.0879 21.7666 24.0723 20.7822 24.0723 18.9014V11.9141H3.91895V18.9014C3.91895 20.791 4.91211 21.7666 6.81055 21.7666Z" fill="#F2F2F2"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 538 B |
@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.81055 21.7666H21.3916C23.0879 21.7666 24.0723 20.7822 24.0723 18.9014V9.85742C24.0723 7.97656 23.0791 6.99219 21.1807 6.99219H13.0332C12.4004 6.99219 12.0225 6.85156 11.5303 6.44727L11.0381 6.04297C10.4141 5.5332 9.95703 5.36621 9.03418 5.36621H6.54688C4.89453 5.36621 3.91895 6.33301 3.91895 8.1875V18.9014C3.91895 20.791 4.91211 21.7666 6.81055 21.7666ZM5.66797 8.33691C5.66797 7.53711 6.11621 7.11523 6.89844 7.11523H8.56836C9.19238 7.11523 9.56152 7.26465 10.0625 7.66895L10.5547 8.08203C11.1699 8.57422 11.6445 8.75 12.5674 8.75H21.0664C21.875 8.75 22.3232 9.17188 22.3232 10.0156V10.5342H5.66797V8.33691ZM6.9248 20.0176C6.11621 20.0176 5.66797 19.5957 5.66797 18.7432V12.0723H22.3232V18.752C22.3232 19.5957 21.875 20.0176 21.0664 20.0176H6.9248Z" fill="#F2F2F2"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 885 B |
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24" width="24px" height="24px">
|
||||
<g id="surface88165820">
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 5 3 C 3.90625 3 3 3.90625 3 5 L 3 9 C 3 10.09375 3.90625 11 5 11 L 9 11 C 10.09375 11 11 10.09375 11 9 L 11 5 C 11 3.90625 10.09375 3 9 3 Z M 15 3 C 13.90625 3 13 3.90625 13 5 L 13 9 C 13 10.09375 13.90625 11 15 11 L 19 11 C 20.09375 11 21 10.09375 21 9 L 21 5 C 21 3.90625 20.09375 3 19 3 Z M 5 5 L 9 5 L 9 9 L 5 9 Z M 15 5 L 19 5 L 19 9 L 15 9 Z M 5 13 C 3.90625 13 3 13.90625 3 15 L 3 19 C 3 20.09375 3.90625 21 5 21 L 9 21 C 10.09375 21 11 20.09375 11 19 L 11 15 C 11 13.90625 10.09375 13 9 13 Z M 15 13 C 13.90625 13 13 13.90625 13 15 L 13 19 C 13 20.09375 13.90625 21 15 21 L 19 21 C 20.09375 21 21 20.09375 21 19 L 21 15 C 21 13.90625 20.09375 13 19 13 Z M 5 15 L 9 15 L 9 19 L 5 19 Z M 15 15 L 19 15 L 19 19 L 15 19 Z M 15 15 "/>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.0 KiB |
@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="rgb(250, 33, 33)" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.9912 22.1445C14.2197 22.1445 14.5449 21.9775 14.8086 21.8105C19.7217 18.6465 22.8682 14.9375 22.8682 11.1758C22.8682 7.9502 20.6445 5.7002 17.8408 5.7002C16.0918 5.7002 14.7822 6.66699 13.9912 8.11719C13.2178 6.67578 11.8994 5.7002 10.1504 5.7002C7.34668 5.7002 5.11426 7.9502 5.11426 11.1758C5.11426 14.9375 8.26074 18.6465 13.1738 21.8105C13.4463 21.9775 13.7715 22.1445 13.9912 22.1445Z"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 521 B |
@ -1,3 +0,0 @@
|
||||
<svg width="28" fill="rgb(250, 33, 33)" height="28" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.09668 11.1846C5.09668 14.9375 8.25195 18.6465 13.1562 21.8105C13.4287 21.9863 13.7627 22.1621 13.9912 22.1621C14.2197 22.1621 14.5537 21.9863 14.8262 21.8105C19.7393 18.6465 22.8857 14.9375 22.8857 11.1846C22.8857 7.94141 20.6445 5.69141 17.7705 5.69141C16.0918 5.69141 14.7822 6.45605 13.9912 7.61621C13.2178 6.46484 11.8994 5.69141 10.2207 5.69141C7.33789 5.69141 5.09668 7.94141 5.09668 11.1846ZM6.90723 11.1758C6.90723 8.96094 8.36621 7.45801 10.3262 7.45801C11.9082 7.45801 12.7959 8.41602 13.3496 9.25098C13.5957 9.61133 13.7627 9.72559 13.9912 9.72559C14.2285 9.72559 14.3779 9.60254 14.6328 9.25098C15.2305 8.43359 16.083 7.45801 17.6562 7.45801C19.625 7.45801 21.084 8.96094 21.084 11.1758C21.084 14.2695 17.8672 17.6973 14.1582 20.1582C14.0791 20.2109 14.0264 20.2461 13.9912 20.2461C13.9561 20.2461 13.9033 20.2109 13.833 20.1582C10.124 17.6973 6.90723 14.2695 6.90723 11.1758Z" />
|
||||
</svg>
|
Before Width: | Height: | Size: 1020 B |
@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.25098 13.2146C3.25098 13.6716 3.60254 14.0671 4.16504 14.0671C4.4375 14.0671 4.68359 13.9177 4.90332 13.7419L5.90527 12.8982V21.072C5.90527 22.3728 6.6875 23.1462 8.03223 23.1462H19.9238C21.2598 23.1462 22.0508 22.3728 22.0508 21.072V12.8542L23.1055 13.7419C23.3164 13.9177 23.5625 14.0671 23.835 14.0671C24.3535 14.0671 24.749 13.7419 24.749 13.2322C24.749 12.9333 24.6348 12.696 24.4062 12.5027L22.0508 10.5164V6.77222C22.0508 6.37671 21.7959 6.13062 21.4004 6.13062H20.1875C19.8008 6.13062 19.5371 6.37671 19.5371 6.77222V8.40698L15.2568 4.81226C14.4922 4.17065 13.5254 4.17065 12.7607 4.81226L3.60254 12.5027C3.36523 12.696 3.25098 12.9597 3.25098 13.2146ZM16.5312 15.6404C16.5312 15.2273 16.2676 14.9636 15.8545 14.9636H12.1631C11.75 14.9636 11.4775 15.2273 11.4775 15.6404V21.3972H8.49805C7.95312 21.3972 7.6543 21.0896 7.6543 20.5359V11.4304L13.6221 6.42065C13.8682 6.20972 14.1494 6.20972 14.3955 6.42065L20.293 11.3777V20.5359C20.293 21.0896 19.9941 21.3972 19.4492 21.3972H16.5312V15.6404Z" fill="#F2F2F2"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.1 KiB |
@ -1,4 +0,0 @@
|
||||
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 1.875C7.75195 1.875 1.875 7.75195 1.875 15C1.875 22.248 7.75195 28.125 15 28.125C22.248 28.125 28.125 22.248 28.125 15C28.125 7.75195 22.248 1.875 15 1.875ZM15 25.8984C8.98242 25.8984 4.10156 21.0176 4.10156 15C4.10156 8.98242 8.98242 4.10156 15 4.10156C21.0176 4.10156 25.8984 8.98242 25.8984 15C25.8984 21.0176 21.0176 25.8984 15 25.8984Z" fill="white"/>
|
||||
<path d="M13.5938 9.84375C13.5938 10.2167 13.7419 10.5744 14.0056 10.8381C14.2694 11.1018 14.627 11.25 15 11.25C15.373 11.25 15.7306 11.1018 15.9944 10.8381C16.2581 10.5744 16.4062 10.2167 16.4062 9.84375C16.4062 9.47079 16.2581 9.1131 15.9944 8.84938C15.7306 8.58566 15.373 8.4375 15 8.4375C14.627 8.4375 14.2694 8.58566 14.0056 8.84938C13.7419 9.1131 13.5938 9.47079 13.5938 9.84375V9.84375ZM15.7031 13.125H14.2969C14.168 13.125 14.0625 13.2305 14.0625 13.3594V21.3281C14.0625 21.457 14.168 21.5625 14.2969 21.5625H15.7031C15.832 21.5625 15.9375 21.457 15.9375 21.3281V13.3594C15.9375 13.2305 15.832 13.125 15.7031 13.125Z" fill="white"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.1 KiB |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="375" viewBox="0 0 375 375" height="375" version="1.0"><defs><clipPath id="a"><path d="M 73.445312 66 L 301 66 L 301 308.839844 L 73.445312 308.839844 Z M 73.445312 66"/></clipPath></defs><path fill="#FFF" d="M 187.5 0 C 83.945312 0 0 83.945312 0 187.5 C 0 291.054688 83.945312 375 187.5 375 C 291.054688 375 375 291.054688 375 187.5 C 375 83.945312 291.054688 0 187.5 0 Z M 187.5 0"/><g clip-path="url(#a)"><path fill="#4AD168" d="M 88.226562 152.753906 C 78.492188 138.632812 73.621094 118.523438 73.621094 92.417969 L 73.621094 66.613281 L 143.804688 66.613281 L 276.328125 211.664062 C 283.808594 219.96875 288.796875 226.96875 291.289062 232.664062 C 293.785156 238.359375 295.625 243.578125 296.8125 248.324219 C 299.425781 257.816406 300.730469 269.503906 300.730469 283.386719 L 300.730469 308.835938 L 232.507812 308.835938 L 88.761719 152.753906 Z M 223.601562 142.609375 C 223.601562 109.980469 228.648438 88.742188 238.742188 78.894531 C 244.085938 73.671875 251.035156 70.351562 259.585938 68.925781 C 268.253906 67.382812 278.761719 66.613281 291.113281 66.613281 L 300.730469 66.613281 L 300.730469 94.734375 C 300.730469 120.003906 294.082031 136.914062 280.78125 145.457031 C 271.28125 151.625 255.308594 154.710938 232.867188 154.710938 L 223.601562 154.710938 Z M 73.621094 281.074219 C 73.621094 255.800781 80.273438 238.832031 93.570312 230.171875 C 103.070312 224.121094 119.042969 221.09375 141.488281 221.09375 L 150.75 221.09375 L 150.75 233.199219 C 150.75 266.183594 145.703125 287.363281 135.609375 296.734375 C 128.722656 303.140625 118.6875 306.9375 105.507812 308.125 C 98.855469 308.601562 91.433594 308.835938 83.242188 308.835938 L 73.621094 308.835938 Z M 73.621094 281.074219"/></g></svg>
|
Before Width: | Height: | Size: 1.7 KiB |
@ -1,3 +0,0 @@
|
||||
<svg width="60" height="50" viewBox="0 0 60 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M57.6854 3.09975C56.5653 1.69314 54.4815 0 50.7566 0C46.146 0 40.2851 3.0737 35.1276 8.20522C34.3722 8.93457 33.6949 9.66392 33.0437 10.4193V5.20966C33.0437 4.38065 32.7144 3.58559 32.1282 2.99939C31.542 2.41319 30.7469 2.08386 29.9179 2.08386C29.0889 2.08386 28.2938 2.41319 27.7076 2.99939C27.1214 3.58559 26.7921 4.38065 26.7921 5.20966V10.4193C26.1409 9.66392 25.4636 8.93457 24.7082 8.20522C19.5507 3.0737 13.6898 0 9.07926 0C5.35435 0 3.27048 1.69314 2.15041 3.09975C-1.28797 7.50191 -0.0116033 15.082 1.88992 22.636C3.32258 28.4187 6.31814 31.1538 9.13135 32.4041C8.40766 34.0087 8.03468 35.7493 8.03733 37.5096C8.03992 40.0481 8.81518 42.5258 10.26 44.6131C11.7049 46.7004 13.7509 48.2984 16.1259 49.1947C18.501 50.091 21.0927 50.2432 23.5563 49.6308C26.0199 49.0185 28.2388 47.6708 29.9179 45.7669C31.8971 48.0002 34.6097 49.4521 37.5658 49.8601C40.5219 50.2681 43.5265 49.6054 46.0366 47.9918C48.5468 46.3781 50.3971 43.9198 51.2531 41.0611C52.1092 38.2024 51.9146 35.1318 50.7045 32.4041C53.5177 31.1538 56.5132 28.4187 57.9459 22.636C59.8474 15.082 61.1238 7.50191 57.6854 3.09975V3.09975ZM20.5405 43.7612C18.9172 43.8013 17.3419 43.2082 16.1482 42.1074C14.9544 41.0066 14.2358 39.4846 14.1444 37.8633C14.053 36.2421 14.596 34.6489 15.6585 33.4209C16.721 32.1929 18.2197 31.4266 19.8372 31.284C20.2452 31.2419 20.6408 31.1192 21.0011 30.9231C21.3613 30.727 21.6791 30.4613 21.9359 30.1415C22.1927 29.8217 22.3835 29.4541 22.4972 29.06C22.6109 28.6659 22.6453 28.2531 22.5983 27.8456C22.5066 27.0244 22.0934 26.2728 21.449 25.7554C20.8047 25.238 19.9817 24.9968 19.16 25.0845C17.12 25.2999 15.1671 26.0255 13.4814 27.1944C11.4497 27.0121 9.10531 25.7097 7.93313 21.1252C6.0316 13.4409 5.74507 8.67409 7.09959 6.9549C7.25588 6.74651 7.62055 6.25159 9.07926 6.25159C11.9446 6.25159 16.477 8.83038 20.3061 12.6334C24.1352 16.4365 26.7921 20.9949 26.7921 23.9644V37.5096C26.7853 39.1655 26.1244 40.7516 24.9535 41.9225C23.7826 43.0935 22.1964 43.7543 20.5405 43.7612ZM51.9027 21.1252C50.7305 25.7097 48.3862 27.0121 46.3544 27.1944C44.6687 26.0255 42.7158 25.2999 40.6759 25.0845C40.2538 25.0083 39.8206 25.0198 39.4032 25.1182C38.9858 25.2166 38.5932 25.3999 38.2496 25.6566C37.9061 25.9133 37.619 26.2379 37.4063 26.6103C37.1936 26.9827 37.0598 27.3948 37.0133 27.8212C36.9667 28.2475 37.0084 28.6788 37.1356 29.0883C37.2629 29.4978 37.4731 29.8768 37.7531 30.2016C38.0331 30.5264 38.377 30.7901 38.7633 30.9763C39.1496 31.1626 39.5701 31.2673 39.9986 31.284C41.1968 31.4197 42.3302 31.899 43.2622 32.6642C44.1942 33.4294 44.885 34.4478 45.2514 35.5966C45.6177 36.7455 45.644 37.9758 45.327 39.1393C45.01 40.3027 44.3633 41.3497 43.4648 42.154C42.5663 42.9582 41.4544 43.4855 40.2631 43.6722C39.0718 43.8588 37.8519 43.697 36.7504 43.2061C35.649 42.7152 34.713 41.9163 34.0553 40.9055C33.3977 39.8948 33.0463 38.7154 33.0437 37.5096V23.9644C33.0437 20.9949 35.5964 16.5407 39.5297 12.6334C43.463 8.72618 47.8912 6.25159 50.7566 6.25159C52.2153 6.25159 52.5799 6.74651 52.7362 6.9549C54.0907 8.67409 53.8042 13.4409 51.9027 21.1252Z" fill="white"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 3.1 KiB |
@ -1,5 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="7.04999" cy="12.0498" r="1.25" fill="white"/>
|
||||
<circle cx="12.05" cy="12.0498" r="1.25" fill="white"/>
|
||||
<circle cx="17.05" cy="12.0498" r="1.25" fill="white"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 273 B |
@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.19043 20.7383C8.57715 20.7383 8.91113 20.624 9.28906 20.4043L18.2979 15.1133C18.8164 14.8057 19.1416 14.4453 19.2471 14.0146V20.1143C19.2471 20.6592 19.6777 21.0723 20.2314 21.0723C20.7939 21.0723 21.2246 20.6592 21.2246 20.1143V7.21191C21.2246 6.66699 20.7939 6.25391 20.2314 6.25391C19.6777 6.25391 19.2471 6.66699 19.2471 7.21191V13.3027C19.1416 12.8721 18.8252 12.5293 18.2979 12.2129L9.28906 6.92188C8.90234 6.70215 8.57715 6.58789 8.18164 6.58789C7.4082 6.58789 6.7666 7.16797 6.7666 8.24023V19.0859C6.7666 20.1582 7.41699 20.7383 8.19043 20.7383ZM8.70898 18.708C8.59473 18.708 8.50684 18.6377 8.50684 18.4795V8.84668C8.50684 8.68848 8.59473 8.61816 8.70898 8.61816C8.76172 8.61816 8.82324 8.63574 8.90234 8.67969L16.9707 13.4521C17.0762 13.5049 17.1289 13.5576 17.1289 13.6631C17.1289 13.7598 17.0762 13.8213 16.9707 13.874L8.90234 18.6465C8.82324 18.6904 8.76172 18.708 8.70898 18.708Z" fill="#F2F2F2"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.0 KiB |
@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.9238 21.0723C11.4775 21.0723 11.9082 20.6592 11.9082 20.1143V7.21191C11.9082 6.66699 11.4775 6.25391 10.9238 6.25391C10.3701 6.25391 9.93945 6.66699 9.93945 7.21191V20.1143C9.93945 20.6592 10.3701 21.0723 10.9238 21.0723ZM17.0674 21.0723C17.6211 21.0723 18.0518 20.6592 18.0518 20.1143V7.21191C18.0518 6.66699 17.6211 6.25391 17.0674 6.25391C16.5137 6.25391 16.083 6.66699 16.083 7.21191V20.1143C16.083 20.6592 16.5137 21.0723 17.0674 21.0723Z" fill="#F2F2F2"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 578 B |
@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.803 4.385C14.766 2.354 17.45 1.889 19.039 3.478L24.522 8.961C26.111 10.551 25.646 13.234 23.615 14.196L18.225 16.75C17.8189 16.9424 17.5024 17.2836 17.341 17.703L15.752 21.833C15.6474 22.105 15.4764 22.3466 15.2547 22.5359C15.033 22.7251 14.7676 22.8559 14.4825 22.9165C14.1974 22.9771 13.9016 22.9656 13.6221 22.8829C13.3426 22.8002 13.0882 22.649 12.882 22.443L9.75 19.31L4.06 25H3V23.94L8.69 18.25L5.558 15.118C5.352 14.9118 5.20076 14.6575 5.118 14.378C5.03523 14.0986 5.02357 13.8029 5.08406 13.5178C5.14456 13.2327 5.2753 12.9673 5.46441 12.7455C5.65353 12.5238 5.89503 12.3527 6.167 12.248L10.297 10.659C10.717 10.498 11.058 10.181 11.25 9.775L13.803 4.385V4.385ZM17.978 4.539C17.7797 4.34069 17.5367 4.19293 17.2694 4.10817C17.002 4.02341 16.7183 4.00414 16.442 4.05198C16.1656 4.09981 15.9048 4.21335 15.6816 4.38304C15.4583 4.55272 15.2791 4.77357 15.159 5.027L12.606 10.417C12.2487 11.1713 11.615 11.7592 10.836 12.059L6.706 13.648C6.66707 13.6629 6.63247 13.6872 6.60535 13.7188C6.57822 13.7505 6.55944 13.7884 6.55069 13.8291C6.54195 13.8698 6.54353 13.9121 6.55528 13.9521C6.56704 13.9921 6.5886 14.0285 6.618 14.058L13.942 21.382C13.9715 21.4114 14.0078 21.433 14.0477 21.4449C14.0876 21.4567 14.1299 21.4583 14.1706 21.4497C14.2113 21.4411 14.2492 21.4224 14.2809 21.3954C14.3126 21.3684 14.337 21.3339 14.352 21.295L15.941 17.165C16.2405 16.3857 16.8285 15.7515 17.583 15.394L22.973 12.841C23.2266 12.721 23.4476 12.5417 23.6174 12.3184C23.7873 12.095 23.9009 11.8341 23.9487 11.5576C23.9966 11.2812 23.9772 10.9972 23.8923 10.7298C23.8075 10.4624 23.6595 10.2193 23.461 10.021L17.978 4.54V4.539Z" fill="white"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.7 KiB |
@ -1,4 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.8493 6.0585H25.1201C25.6925 6.0585 26.1638 5.6064 26.1638 5.02445C26.1638 4.45211 25.6925 4 25.1201 4H16.8493C16.2748 4 15.8227 4.45211 15.8227 5.02445C15.8227 5.6064 16.2748 6.0585 16.8493 6.0585ZM16.8493 12.1991H25.1201C25.6925 12.1991 26.1638 11.747 26.1638 11.163C26.1638 10.5906 25.6925 10.1385 25.1201 10.1385H16.8493C16.2748 10.1385 15.8227 10.5906 15.8227 11.163C15.8227 11.747 16.2748 12.1991 16.8493 12.1991ZM3.02445 18.3376H25.1201C25.6925 18.3376 26.1638 17.8759 26.1638 17.3036C26.1638 16.7312 25.6925 16.277 25.1201 16.277H3.02445C2.45211 16.277 2 16.7312 2 17.3036C2 17.8759 2.45211 18.3376 3.02445 18.3376ZM3.02445 24.4666H25.1201C25.6925 24.4666 26.1638 24.0144 26.1638 23.4421C26.1638 22.8698 25.6925 22.408 25.1201 22.408H3.02445C2.45211 22.408 2 22.8698 2 23.4421C2 24.0144 2.45211 24.4666 3.02445 24.4666Z" fill="white"/>
|
||||
<path d="M2.01172 12.0219C2.01172 13.0576 2.57445 13.6869 3.48687 13.6869C4.4014 13.6869 4.97374 13.0323 4.97374 12.0473V10.06C4.97374 9.77475 5.12585 9.60131 5.42069 9.60131H8.22969V10.7964C8.22969 11.7812 9.21664 12.1497 9.97156 11.5513L13.314 8.8703C13.8198 8.46459 13.8155 7.76077 13.314 7.36467L9.97156 4.67195C9.19531 4.04266 8.22969 4.4207 8.22969 5.43319V6.63929H5.07218C3.22578 6.63929 2.01172 7.7221 2.01172 9.576V12.0219Z" fill="white"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.4 KiB |
@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.3418 21.3711C9.71094 21.3711 10.0361 21.2393 10.4404 21.002L20.8203 14.999C21.5762 14.5596 21.8926 14.2168 21.8926 13.6631C21.8926 13.1094 21.5762 12.7754 20.8203 12.3271L10.4404 6.32422C10.0361 6.08691 9.71094 5.95508 9.3418 5.95508C8.62109 5.95508 8.11133 6.50879 8.11133 7.37891V19.9473C8.11133 20.8262 8.62109 21.3711 9.3418 21.3711Z" fill="#F2F2F2"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 471 B |
Before Width: | Height: | Size: 5.5 KiB |
Before Width: | Height: | Size: 2.0 KiB |
@ -1 +0,0 @@
|
||||
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="28px" height="28px" baseProfile="basic"><path d="M13,46h16.205c0.338,1.549,1.093,2.903,2.174,4H13c-1.104,0-2-0.895-2-2C11,46.895,11.896,46,13,46z"/><path d="M12.999,38l18.714,0c-1.142,0.918-2.077,2.195-2.486,4L13,42c-1.104,0-2-0.895-2-2C11,38.895,11.895,38,12.999,38z"/><path d="M13,30h28v4H13c-1.104,0-2-0.895-2-2C11,30.895,11.896,30,13,30z"/><path d="M13,22h28v4H13c-1.104,0-2-0.895-2-2C11,22.895,11.896,22,13,22z"/><path d="M13,14h28v4H13c-1.104,0-2-0.895-2-2C11,14.895,11.896,14,13,14z"/><path d="M54.026,9.158C54.997,8.834,56,9.557,56,10.581v7.484c0,0.829-0.511,1.572-1.286,1.868l-5.75,2.199 C48.384,22.353,48,22.911,48,23.532V39c0,8-4.083,11-8.561,11C35.026,50,32,47.754,32,44.079c0-3.39,2.07-4.633,6.224-5.553 c4.067-0.9,5.776-1.327,5.776-4.142V13.942c0-0.861,0.551-1.625,1.368-1.897L54.026,9.158z" /></svg>
|
Before Width: | Height: | Size: 902 B |
@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.16016 9.50586C6.85449 9.50586 7.4082 8.95215 7.4082 8.25781C7.4082 7.57227 6.85449 7.00977 6.16016 7.00977C5.47461 7.00977 4.91211 7.57227 4.91211 8.25781C4.91211 8.95215 5.47461 9.50586 6.16016 9.50586ZM10.291 9.10156H22.2266C22.7012 9.10156 23.0791 8.73242 23.0791 8.25781C23.0791 7.7832 22.71 7.41406 22.2266 7.41406H10.291C9.8252 7.41406 9.44727 7.7832 9.44727 8.25781C9.44727 8.73242 9.81641 9.10156 10.291 9.10156ZM6.16016 14.9111C6.85449 14.9111 7.4082 14.3574 7.4082 13.6631C7.4082 12.9775 6.85449 12.415 6.16016 12.415C5.47461 12.415 4.91211 12.9775 4.91211 13.6631C4.91211 14.3574 5.47461 14.9111 6.16016 14.9111ZM10.291 14.5068H22.2266C22.7012 14.5068 23.0791 14.1377 23.0791 13.6631C23.0791 13.1885 22.71 12.8193 22.2266 12.8193H10.291C9.8252 12.8193 9.44727 13.1885 9.44727 13.6631C9.44727 14.1377 9.81641 14.5068 10.291 14.5068ZM6.16016 20.3164C6.85449 20.3164 7.4082 19.7627 7.4082 19.0684C7.4082 18.3828 6.85449 17.8203 6.16016 17.8203C5.47461 17.8203 4.91211 18.3828 4.91211 19.0684C4.91211 19.7627 5.47461 20.3164 6.16016 20.3164ZM10.291 19.9121H22.2266C22.7012 19.9121 23.0791 19.543 23.0791 19.0684C23.0791 18.5938 22.71 18.2246 22.2266 18.2246H10.291C9.8252 18.2246 9.44727 18.5938 9.44727 19.0684C9.44727 19.543 9.81641 19.9121 10.291 19.9121Z" fill="#F2F2F2"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.4 KiB |
@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.63672 14.6562H12.998V20.0176C12.998 20.5625 13.4463 21.0195 14 21.0195C14.5537 21.0195 15.002 20.5625 15.002 20.0176V14.6562H20.3633C20.9082 14.6562 21.3652 14.208 21.3652 13.6543C21.3652 13.1006 20.9082 12.6523 20.3633 12.6523H15.002V7.29102C15.002 6.74609 14.5537 6.28906 14 6.28906C13.4463 6.28906 12.998 6.74609 12.998 7.29102V12.6523H7.63672C7.0918 12.6523 6.63477 13.1006 6.63477 13.6543C6.63477 14.208 7.0918 14.6562 7.63672 14.6562Z" fill="#F2F2F2"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 574 B |
@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.75098 21.0723C8.30469 21.0723 8.73535 20.6592 8.73535 20.1143V14.0146C8.70898 13.9004 8.69141 13.7861 8.69141 13.6543C8.69141 13.5312 8.70898 13.417 8.73535 13.3027V7.21191C8.73535 6.66699 8.30469 6.25391 7.75098 6.25391C7.19727 6.25391 6.7666 6.66699 6.7666 7.21191V20.1143C6.7666 20.6592 7.19727 21.0723 7.75098 21.0723ZM19.8008 20.7383C20.5742 20.7383 21.2246 20.1582 21.2246 19.0859V8.24023C21.2246 7.16797 20.5742 6.58789 19.8008 6.58789C19.4141 6.58789 19.0801 6.70215 18.7021 6.92188L9.69336 12.2129C9.16602 12.5293 8.84961 12.8721 8.73535 13.3027V14.0146C8.84961 14.4453 9.16602 14.8057 9.69336 15.1133L18.7021 20.4043C19.0801 20.624 19.4141 20.7383 19.8008 20.7383ZM19.2822 18.708C19.2207 18.708 19.1592 18.6904 19.0889 18.6465L11.0117 13.874C10.915 13.8213 10.8535 13.7598 10.8535 13.6631C10.8535 13.5576 10.9062 13.5049 11.0117 13.4521L19.0889 8.67969C19.168 8.63574 19.2207 8.61816 19.2734 8.61816C19.3877 8.61816 19.4844 8.68848 19.4844 8.84668V18.4795C19.4844 18.6377 19.3877 18.708 19.2822 18.708Z" fill="#F2F2F2"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.1 KiB |
@ -1 +0,0 @@
|
||||
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="28px" height="28px" baseProfile="basic"><path d="M51,48H25c-1.104,0-2-0.896-2-2s0.896-2,2-2h26c1.104,0,2,0.896,2,2S52.104,48,51,48z"/><path d="M16.356,45.919c1.121,0.082,1.962,1.032,1.962,2.222c0,1.647-1.524,2.816-3.678,2.816c-1.9,0-3.391-1.032-3.391-2.201 c0-0.52,0.362-0.889,0.861-0.889c0.328,0,0.602,0.157,0.909,0.54c0.417,0.561,0.957,0.848,1.62,0.848c0.902,0,1.49-0.472,1.49-1.203 c0-0.725-0.595-1.217-1.483-1.217h-0.554c-0.472,0-0.82-0.362-0.82-0.861c0-0.479,0.342-0.848,0.82-0.848h0.533 c0.738,0,1.285-0.465,1.285-1.101s-0.533-1.073-1.299-1.073c-0.588,0-1.053,0.26-1.47,0.813c-0.246,0.321-0.533,0.465-0.889,0.465 c-0.526,0-0.902-0.355-0.902-0.861c0-1.128,1.456-2.119,3.302-2.119c1.996,0,3.391,1.032,3.391,2.502c0,1.005-0.745,1.9-1.688,2.017 V45.919z"/><path d="M51,34H25c-1.104,0-2-0.896-2-2s0.896-2,2-2h26c1.104,0,2,0.896,2,2S52.104,34,51,34z"/><path d="M17.345,35.209c0.636,0,1.032,0.342,1.032,0.902c0,0.554-0.39,0.889-1.032,0.889h-4.676c-0.663,0-1.101-0.376-1.101-0.95 c0-0.41,0.226-0.766,0.909-1.436l2.365-2.434c0.731-0.745,1.039-1.237,1.039-1.757c0-0.67-0.485-1.121-1.203-1.121 c-0.581,0-1.005,0.294-1.306,0.902c-0.301,0.465-0.561,0.643-0.957,0.643c-0.54,0-0.916-0.369-0.916-0.889 c0-1.271,1.436-2.461,3.302-2.461c1.88,0,3.254,1.148,3.254,2.714c0,0.964-0.465,1.812-1.606,2.933l-1.948,1.969v0.096H17.345z"/><path d="M51,20.019H25c-1.104,0-2-0.896-2-2s0.896-2,2-2h26c1.104,0,2,0.896,2,2S52.104,20.019,51,20.019z"/><path d="M14.765,21.797v-6.146h-0.027l-0.718,0.485c-0.355,0.226-0.547,0.294-0.772,0.294c-0.451,0-0.779-0.328-0.779-0.779 c0-0.335,0.205-0.636,0.608-0.889l1.114-0.745c0.533-0.342,1.019-0.499,1.477-0.499c0.8,0,1.333,0.54,1.333,1.374v6.904 C17,22.569,16.597,23,15.886,23C15.168,23,14.765,22.563,14.765,21.797z"/></svg>
|
Before Width: | Height: | Size: 1.8 KiB |