Merge pull request #147 from swing-opensource/v1.3.0

New release: v1.3.0
This commit is contained in:
Mungai Njoroge
2023-10-11 14:34:40 -07:00
committed by GitHub
118 changed files with 4802 additions and 2055 deletions
+13
View File
@@ -0,0 +1,13 @@
# Contributor Code of Conduct
As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities.
We are committed to making participation in this project a harassment-free experience for everyone, regardless of the level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion.
Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers.
This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/)
+9
View File
@@ -0,0 +1,9 @@
# These are supported funding model platforms
# github: [yyx990803, posva]
# patreon: evanyou
# open_collective: vuejs
# ko_fi: # Replace with a single Ko-fi username
# tidelift: npm/vue
# custom: # Replace with a single custom sponsorship URL
custom: https://swingmusic.vercel.app/support-us
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Create new issue
url: https://github.com/swing-opensource/swingmusic/issues/new
about: Please use the following link to create a new issue.
- name: Support Swing Music
url: https://swingmusic.vercel.app/support-us
about: Love Swing Music? Please consider supporting this project.
+32
View File
@@ -0,0 +1,32 @@
<!--
Please make sure to read the Pull Request Guidelines:
https://github.com/swing-opensource/swingmusic/.github/CONTRIBUTING.md#pull-request-guidelines
-->
<!-- PULL REQUEST TEMPLATE -->
<!-- (Update "[ ]" to "[x]" to check a box) -->
**What kind of change does this PR introduce?** (check at least one)
- [ ] Bugfix
- [ ] Feature
- [ ] Code style update
- [ ] Refactor
- [ ] Build-related changes
- [ ] Other, please describe:
**Does this PR introduce a breaking change?** (check one)
- [ ] Yes
- [ ] No
If yes, please describe the impact and migration path for existing applications:
**The PR fulfills these requirements:**
- [ ] When resolving a specific issue, it's referenced in the PR's title (e.g. `fix #xxx[,#xxx]`, where "xxx" is the issue number)
If adding a **new feature**, the PR's description includes:
- [ ] A convincing reason for adding this feature (it's best to open a suggestion issue first and wait for approval before working on it)
**Other information:**
+94
View File
@@ -0,0 +1,94 @@
# Swing Music Contributing Guide
Hi! We're really excited that you are interested in contributing to Swing Music. This project uses Python, [Flask](https://flask.palletsprojects.com/en/2.3.x/), Sqlite, [Poetry](https://python-poetry.org/), and [Vue](https://vuejs.org/).
If you are interested in making a code contribution take a moment to read through the following guidelines:
- [Code of Conduct](./CODE_OF_CONDUCT.md)
- [Pull Request Guidelines](#pull-request-guidelines)
## Pull Request Guidelines
- Checkout a topic branch from the relevant branch, e.g. `master`, and merge back against that branch.
- If adding a new feature:
- Provide a convincing reason to add this feature. Ideally, you should open a suggestion issue first and have it approved before working on it.
- If fixing bug:
- Provide a detailed description of the bug in the PR.
## Development Setup
This project is broken down into 2 parts. The server (this repo) and the client (which lives [here](https://github.com/swing-opensource/swingmusic-client)).
To contribute to the server development, you need to install [Poetry package manager](https://python-poetry.org/docs).
Fork this repo, git clone and install the dependencies:
```sh
git clone https://github.com/swing-opensource/swingmusic.git
# or with ssh
git clone git@github.com:swing-opensource/swingmusic.git
cd swingmusic
poetry install
```
You need a LastFM API key which you can get on the [API accounts page](https://www.last.fm/api/accounts). Then set it as an environment variable under the name: `LASTFM_API_KEY`.
Finally, run the server. You can use a different port if you have another Swing Music instance running on port `1970`.
```sh
poetry run python manage.py --port 1980
```
After that, checkout into a new branch and make your changes.
```sh
git checkout <branch_name>
```
Finally, commit your changes and open a pull request.
## Contributing to the client
You need to have [yarn](https://yarnpkg.com/) installed in your machine. See their [install guide](https://yarnpkg.com/getting-started/install).
Fork the repo, git clone and install the dependencies:
```sh
git clone https://github.com/swing-opensource/swingmusic-client.git
# or with ssh
git clone git@github.com:swing-opensource/swingmusic-client.git
cd swingmusic-client
yarn install
```
You can now run the client.
```sh
yarn dev
```
You can see the client at http://localhost:5173.
> The client is hardcoded to hook into the server on port `1980` (to allow the another server instance to be running on the default port). You can follow the instructions above to set up the server in that port, or you can change the port in `swingmusic-client/config.ts`. Don't forget to change it back when in the PR.
## Where can I go for help?
If you need help, you can email me at: geoffreymungai45@gmail.com
## What does the Code of Conduct mean for me?
Our Code of Conduct means that you are responsible for treating everyone on the project with respect and courtesy regardless of their identity. If you are the victim of any inappropriate behavior or comments as described in our Code of Conduct, we are here for you and will do the best to ensure that the abuser is reprimanded appropriately, per our code.
See you around?
+107
View File
@@ -0,0 +1,107 @@
name: Release Swing Music
on:
push:
branches:
- main
paths:
- 'client/**'
- 'server/**'
workflow_dispatch:
env:
NODE_VERSION: 18
PYTHON_VERSION: 3.10
jobs:
release-linux:
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install client dependencies
run: yarn install
working-directory: ./client
- name: Build client
run: yarn build --outDir ../swingmusic/client
working-directory: ./client
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install server dependencies
run: poetry install
working-directory: ./server
- name: Build server
run: poetry run python manage.py --build
working-directory: ./server
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.event.head_commit.message.replace('release', '').trim() }}
release_name: ${{ github.event.head_commit.message.replace('release', '').trim() }}
draft: true
prerelease: false
- name: Upload Release Asset
id: upload-release-asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./server/dist/swingmusic
asset_name: swingmusic
asset_content_type: application/octet-stream
release-windows:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install client dependencies
run: yarn install
working-directory: ./client
- name: Build client
run: yarn build --outDir ../swingmusic/client
working-directory: ./client
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install server dependencies
run: poetry install
working-directory: ./server
- name: Build server
run: poetry run python manage.py --build
working-directory: ./server
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.event.head_commit.message.replace('release', '').trim() }}
release_name: ${{ github.event.head_commit.message.replace('release', '').trim() }}
draft: true
prerelease: false
- name: Upload Release Asset
id: upload-release-asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./server/dist/swingmusic.exe
asset_name: swingmusic.exe
asset_content_type: application/octet-stream
+7
View File
@@ -22,3 +22,10 @@ encoderx.py
dist
build
client
.gitignore
logs.txt
*.spec
TODO.md
testdata.py
+4 -24
View File
@@ -1,22 +1,8 @@
FROM node:latest AS CLIENT
FROM ubuntu:latest
RUN git clone https://github.com/swing-opensource/swingmusic-client.git client
WORKDIR /
WORKDIR /client
RUN git checkout $(git describe --tags $(git rev-list --tags --max-count=1))
RUN yarn install
RUN yarn build
FROM python:latest
WORKDIR /app/swingmusic
COPY . .
COPY --from=CLIENT /client/dist/ client
COPY ./dist/swingmusic /swingmusic
EXPOSE 1970/tcp
@@ -24,10 +10,4 @@ VOLUME /music
VOLUME /config
RUN pip install poetry
RUN poetry config virtualenvs.create false
RUN poetry install
ENTRYPOINT ["poetry", "run", "python", "manage.py", "--host", "0.0.0.0", "--config", "/config"]
ENTRYPOINT ["/swingmusic", "--host", "0.0.0.0", "--config", "/config"]
+35
View File
@@ -0,0 +1,35 @@
FROM node:latest AS CLIENT
RUN git clone https://github.com/swing-opensource/swingmusic-client.git client
WORKDIR /client
RUN git checkout $(git describe --tags $(git rev-list --tags --max-count=1))
RUN yarn install
RUN yarn build
FROM python:latest
WORKDIR /app/swingmusic
COPY . .
COPY --from=CLIENT /client/dist/ client
EXPOSE 1970/tcp
VOLUME /music
VOLUME /config
RUN pip install poetry
RUN poetry config virtualenvs.create false
RUN poetry install
ENV LASTFM_API_KEY="45c6776a1029a280fabd6a2c8158023d"
ENTRYPOINT ["poetry", "run", "python", "manage.py", "--host", "0.0.0.0", "--config", "/config"]
+40 -88
View File
@@ -1,24 +1,20 @@
# Swing music
![SWING MUSIC PLAYER BANNER IMAGE](screenshots/readme-artist.webp)
Swing Music is a beautiful, self-hosted music player for your local audio files. Like a cooler Spotify ... but bring your own music. Just run the app and enjoy your music library in a web browser.
![SWING MUSIC PLAYER BANNER IMAGE](screenshots/readme-album.webp)
<a href="https://swingmusic.vercel.app/support-us.html" target="_blank"><img src="screenshots/supportus.png" alt="Buy Me A Coffee" style="height: 60px !important;width: auto !important;" ></a>
![SWING MUSIC PLAYER BANNER IMAGE](screenshots/readme-playlist.webp)
| ![SWING MUSIC PLAYER BANNER IMAGE](screenshots/artist.webp) | ![SWING MUSIC PLAYER BANNER IMAGE](screenshots/album.webp) |
| ------------------------------------------------------------ | ----------------------------------------------------------- |
| ![SWING MUSIC PLAYER BANNER IMAGE](screenshots/artist2.webp) | ![SWING MUSIC PLAYER BANNER IMAGE](screenshots/album2.webp) |
---
### Make listening to your local music fun again.
`Swing` is a music player for local audio files built with both visual coolness and functionality in mind. Just run the app and enjoy your music library in a web browser.
### For screenshots, see the [screenshots page on the website](https://swingmusic.vercel.app/screenshots.html).
> Note: This project is in the early stages of development. Many features are missing but will be added with time.
For full size screenshots, visit the [website](https://swingmusic.vercel.app).
### Setup
Download the latest release from the [release page](https://github.com/geoffrey45/swingmusic/releases) and launch it. For Linux, you need to make the file executable first.
Swing Music is available as pre-compiled binaries for Windows and Linux. Just download the latest release from
the [release page](https://github.com/geoffrey45/swingmusic/releases) and launch it.
For Linux, you need to make the file executable first.
```bash
chmod a+x ./swingmusic
@@ -26,23 +22,26 @@ chmod a+x ./swingmusic
./swingmusic
```
The app should start at <http://localhost:1970> by default.
The app should start at <http://localhost:1970> by default. You can change the default port or host by using
the `--host` and `--port` flags.
```
Usage: swingmusic [options]
Options:
--build: Build the application (in development)
--host: Set the host
--port: Set the port
--show-feat, -sf: Do not extract featured artists from the song title
--show-prod, -sp: Do not hide producers in the song title
--help, -h: Show this help message
--version, -v: Show the app version
```
### Options
| Option | Short | Description |
| -------------------- | ------ | ----------------------------------------------------------------------------- |
| `--help` | `-h` | Show help message |
| `--version` | `-v` | Show the app version |
| `--host` | | Set the host |
| `--port` | | Set the port |
| `--config` | | Set the config path |
| `--no-periodic-scan` | `-nps` | Disable periodic scan |
| `--scan-interval` | `-psi` | Set the periodic scan interval in seconds. Default is 300 seconds (5 minutes) |
| `--build` | | Build the application (in development) |
To stream your music across your local network, use the `--host` flag to run the app in all ports. Like this:
```sh
@@ -53,11 +52,13 @@ The link to access the app will be printed on your terminal. Copy it and open it
### Docker
You can run Swing in a Docker container. To do so, clone the repository and build the image:
You can run Swing Music in a Docker container. To do so, clone the repository and build the image:
git clone https://github.com/swing-opensource/swingmusic.git --depth 1
cd swingmusic
docker build . -t swingmusic
```bash
git clone https://github.com/swing-opensource/swingmusic.git --depth 1
cd swingmusic
docker build . -t swingmusic
```
Then create the container. Here are some example snippets to help you get started creating a container.
@@ -74,7 +75,7 @@ services:
- /path/to/music:/music
- /path/to/config:/config
ports:
- 1970:1970
- "1970:1970"
restart: unless-stopped
```
@@ -92,71 +93,22 @@ docker run -d \
#### Parameters
Container images are configured using parameters passed at runtime (such as those above). These parameters are separated by a colon and indicate `<external>:<internal>` respectively. For example, `-p 8080:80` would expose port `80` from inside the container to be accessible from the host's IP on port `8080` outside the container.
Container images are configured using parameters passed at runtime (such as those above). These parameters are separated
by a colon and indicate `<external>:<internal>` respectively. For example, `-p 8080:80` would expose port `80` from
inside the container to be accessible from the host's IP on port `8080` outside the container.
| Parameter | Function |
| :----: | --- |
| `-p 1970` | WebUI |
| `-v /music` | Recommended directory to store your music collection. You can bind other folder if you wish. |
| `-v /config` | Configuration files. |
### 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 ](https://github.com/geoffrey45/swing-client) 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 virtual environment 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 type hinting 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 https://github.com/geoffrey45/swingmusic.git
cd swingmusic
# install dependencies using poetry
poetry install
# start the app
poetry run python manage.py
```
| Parameter | Function |
| :----------: | -------------------------------------------------------------------------------------------- |
| `-p 1970` | WebUI |
| `-v /music` | Recommended directory to store your music collection. You can bind other folder if you wish. |
| `-v /config` | Configuration files. |
### 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.
See [contributing guidelines](.github/contributing.md).
### 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 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 albums & artists biographies and getting insights on song lyrics (kinda Genium.com-ish).
- 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 build in future. Some of the features listed there are outright stupid but some are cool. 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 its very hard to push things fast. If you have programming knowledge in Python or Vue, feel free to contribute to the project. Your contributions are highly appreciated.
---
**[MIT License](https://opensource.org/licenses/MIT) | Copyright (c) 2023 Mungai Njoroge**
+21 -5
View File
@@ -3,23 +3,39 @@ This module combines all API blueprints into a single Flask app instance.
"""
from flask import Flask
from flask_compress import Compress
from flask_cors import CORS
from app.api import (album, artist, favorites, folder, imgserver, playlist,
search, settings, track, colors)
from app.api import (
album,
artist,
colors,
favorites,
folder,
imgserver,
playlist,
search,
send_file,
settings,
)
def create_api():
"""
Creates the Flask instance, registers modules and registers all the API blueprints.
"""
app = Flask(__name__, static_url_path="")
CORS(app)
app = Flask(__name__)
CORS(app, origins="*")
Compress(app)
app.config["COMPRESS_MIMETYPES"] = [
"application/json",
]
with app.app_context():
app.register_blueprint(album.api)
app.register_blueprint(artist.api)
app.register_blueprint(track.api)
app.register_blueprint(send_file.api)
app.register_blueprint(search.api)
app.register_blueprint(folder.api)
app.register_blueprint(playlist.api)
+100 -20
View File
@@ -2,17 +2,20 @@
Contains all the album routes.
"""
from dataclasses import asdict
import random
from flask import Blueprint, request
from app.db.sqlite.albums import SQLiteAlbumMethods as adb
from app.db.sqlite.albumcolors import SQLiteAlbumMethods as adb
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.db.sqlite.lastfm.similar_artists import SQLiteLastFMSimilarArtists as lastfmdb
from app.lib.albumslib import sort_by_track_no
from app.models import FavType, Track
from app.utils.remove_duplicates import remove_duplicates
from app.store.tracks import TrackStore
from app.serializers.album import serialize_for_card
from app.serializers.track import serialize_track
from app.store.albums import AlbumStore
from app.store.tracks import TrackStore
from app.utils.hashing import create_hash
get_albums_by_albumartist = adb.get_albums_by_albumartist
check_is_fav = favdb.check_is_favorite
@@ -33,7 +36,7 @@ def get_album_tracks_and_info():
return error_msg, 400
try:
albumhash = data["hash"]
albumhash: str = data["albumhash"]
except KeyError:
return error_msg, 400
@@ -61,13 +64,12 @@ def get_album_tracks_and_info():
return list(genres)
album.genres = get_album_genres(tracks)
tracks = remove_duplicates(tracks)
album.count = len(tracks)
album.get_date_from_tracks(tracks)
try:
album.duration = sum((t.duration for t in tracks))
album.duration = sum(t.duration for t in tracks)
except AttributeError:
album.duration = 0
@@ -78,7 +80,10 @@ def get_album_tracks_and_info():
album.is_favorite = check_is_fav(albumhash, FavType.album)
return {"tracks": tracks, "info": album}
return {
"tracks": [serialize_track(t, remove_disc=False) for t in tracks],
"info": album,
}
@api.route("/album/<albumhash>/tracks", methods=["GET"])
@@ -87,13 +92,7 @@ def get_album_tracks(albumhash: str):
Returns all the tracks in the given album, sorted by disc and track number.
"""
tracks = TrackStore.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"])
tracks = sort_by_track_no(tracks)
return {"tracks": tracks}
@@ -107,18 +106,99 @@ def get_artist_albums():
albumartists: str = data["albumartists"]
limit: int = data.get("limit")
exclude: str = data.get("exclude")
base_title: str = data.get("base_title")
albumartists: list[str] = albumartists.split(",")
albums = [
{
"artisthash": a,
"albums": AlbumStore.get_albums_by_albumartist(a, limit, exclude=exclude),
"albums": AlbumStore.get_albums_by_albumartist(
a, limit, exclude=base_title
),
}
for a in albumartists
]
albums = [a for a in albums if len(a["albums"]) > 0]
albums = [
{
"artisthash": a["artisthash"],
"albums": [serialize_for_card(a_) for a_ in (a["albums"])],
}
for a in albums
if len(a["albums"]) > 0
]
return {"data": albums}
@api.route("/album/versions", methods=["POST"])
def get_album_versions():
"""
Returns other versions of the given album.
"""
data = request.get_json()
if data is None:
return {"msg": "No albumartist provided"}
og_album_title: str = data["og_album_title"]
base_title: str = data["base_title"]
artisthash: str = data["artisthash"]
albums = AlbumStore.get_albums_by_artisthash(artisthash)
albums = [
a
for a in albums
if create_hash(a.base_title) == create_hash(base_title)
and create_hash(og_album_title) != create_hash(a.og_title)
]
for a in albums:
tracks = TrackStore.get_tracks_by_albumhash(a.albumhash)
a.get_date_from_tracks(tracks)
return {"data": albums}
@api.route("/album/similar", methods=["GET"])
def get_similar_albums():
"""
Returns similar albums to the given album.
"""
data = request.args
if data is None:
return {"msg": "No artisthash provided"}
artisthash: str = data["artisthash"]
limit: int = data.get("limit")
if limit is None:
limit = 6
limit = int(limit)
similar_artists = lastfmdb.get_similar_artists_for(artisthash)
if similar_artists is None:
return {"albums": []}
artisthashes = similar_artists.get_artist_hash_set()
if len(artisthashes) == 0:
return {"albums": []}
albums = [AlbumStore.get_albums_by_artisthash(a) for a in artisthashes]
albums = [a for sublist in albums for a in sublist]
albums = list({a.albumhash: a for a in albums}.values())
try:
albums = random.sample(albums, min(len(albums), limit))
except ValueError:
pass
return {"albums": [serialize_for_card(a) for a in albums[:limit]]}
+129 -207
View File
@@ -2,164 +2,34 @@
Contains all the artist(s) routes.
"""
import random
from collections import deque
import math
from datetime import datetime
from flask import Blueprint, request
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.models import Album, FavType, Track
from app.utils.remove_duplicates import remove_duplicates
from app.requests.artists import fetch_similar_artists
from app.db.sqlite.lastfm.similar_artists import SQLiteLastFMSimilarArtists as fmdb
from app.models import Album, FavType
from app.serializers.album import serialize_for_card_many
from app.serializers.track import serialize_tracks
from app.store.albums import AlbumStore
from app.store.tracks import TrackStore
from app.store.artists import ArtistStore
from app.store.tracks import TrackStore
from app.telemetry import Telemetry
from app.utils.threading import background
api = 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
ARTIST_VISIT_COUNT = 0
class ArtistsCache:
"""
Holds artist page cache.
"""
@background
def send_event():
global ARTIST_VISIT_COUNT
ARTIST_VISIT_COUNT += 1
artists: deque[CacheEntry] = deque(maxlen=1)
@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 = [AlbumStore.get_album_by_hash(h) for h in entry.albumhashes]
entry.albums = [album for album in albums if album is not None]
store_albums = AlbumStore.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 = TrackStore.get_tracks_by_albumhash(album.albumhash)
album_tracks = remove_duplicates(album_tracks)
album.get_date_from_tracks(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 = TrackStore.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 ==========================
# =======================================================
if ARTIST_VISIT_COUNT % 5 == 0:
Telemetry.send_artist_visited()
@api.route("/artist/<artisthash>", methods=["GET"])
@@ -167,6 +37,7 @@ def get_artist(artisthash: str):
"""
Get artist data.
"""
send_event()
limit = request.args.get("limit")
if limit is None:
@@ -179,20 +50,7 @@ def get_artist(artisthash: str):
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 = TrackStore.get_tracks_by_artist(artisthash)
albumhashes = set(t.albumhash for t in tracks)
hashes_from_albums = set(
a.albumhash for a in AlbumStore.get_albums_by_artisthash(artisthash)
)
albumhashes = albumhashes.union(hashes_from_albums)
ArtistsCache.add_entry(artisthash, albumhashes, tracks)
tracks = TrackStore.get_tracks_by_artisthash(artisthash)
tcount = len(tracks)
acount = AlbumStore.count_albums_by_artisthash(artisthash)
@@ -205,7 +63,34 @@ def get_artist(artisthash: str):
artist.is_favorite = favdb.check_is_favorite(artisthash, FavType.artist)
return {"artist": artist, "tracks": tracks[:limit]}
genres = set()
for t in tracks:
if t.genre is not None:
genres = genres.union(t.genre)
genres = list(genres)
try:
min_stamp = min(t.date for t in tracks)
year = datetime.fromtimestamp(min_stamp).year
except ValueError:
year = 0
decade = None
if year:
decade = math.floor(year / 10) * 10
decade = str(decade)[2:] + "s"
if decade:
genres.insert(0, decade)
return {
"artist": artist,
"tracks": serialize_tracks(tracks[:limit]),
"genres": genres,
}
@api.route("/artist/<artisthash>/albums", methods=["GET"])
@@ -219,28 +104,53 @@ def get_artist_albums(artisthash: str):
limit = int(limit)
is_cached = ArtistsCache.albums_cached(artisthash)
all_albums = AlbumStore.get_albums_by_artisthash(artisthash)
if not is_cached:
add_albums_to_cache(artisthash)
# start: check for missing albums. ie. compilations and features
all_tracks = TrackStore.get_tracks_by_artisthash(artisthash)
albums_fetched = ArtistsCache.albums_fetched(artisthash)
track_albums = set(t.albumhash for t in all_tracks)
missing_album_hashes = track_albums.difference(set(a.albumhash for a in all_albums))
if not albums_fetched:
ArtistsCache.get_albums(artisthash)
if len(missing_album_hashes) > 0:
missing_albums = AlbumStore.get_albums_by_hashes(list(missing_album_hashes))
all_albums.extend(missing_albums)
all_albums, index = ArtistsCache.get_albums_by_artisthash(artisthash)
# end check
if not ArtistsCache.artists[index].type_checked:
ArtistsCache.process_album_type(artisthash)
def get_album_tracks(albumhash: str):
tracks = [t for t in all_tracks if t.albumhash == albumhash]
if len(tracks) > 0:
return tracks
return TrackStore.get_tracks_by_albumhash(albumhash)
for a in all_albums:
a.check_type()
album_tracks = get_album_tracks(a.albumhash)
if len(album_tracks) == 0:
continue
a.get_date_from_tracks(album_tracks)
if a.date == 0:
AlbumStore.remove_album_by_hash(a.albumhash)
continue
a.check_is_single(album_tracks)
all_albums = sorted(all_albums, key=lambda a: str(a.date), reverse=True)
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
def remove_EPs_and_singles(albums_: list[Album]):
albums_ = [a for a in albums_ if not a.is_single]
albums_ = [a for a in albums_ if not a.is_EP]
return albums_
albums = filter(lambda a: artisthash in a.albumartists_hashes, all_albums)
albums = list(albums)
@@ -257,16 +167,20 @@ def get_artist_albums(artisthash: str):
artist = ArtistStore.get_artist_by_hash(artisthash)
if return_all is not None:
if artist is None:
return {"error": "Artist not found"}, 404
if return_all is not None and return_all == "true":
limit = len(all_albums)
singles_and_eps = singles + eps
return {
"artistname": artist.name,
"albums": albums[:limit],
"singles": singles[:limit],
"eps": eps[:limit],
"appearances": appearances[:limit],
"compilations": compilations[:limit]
"albums": serialize_for_card_many(albums[:limit]),
"singles_and_eps": serialize_for_card_many(singles_and_eps[:limit]),
"appearances": serialize_for_card_many(appearances[:limit]),
"compilations": serialize_for_card_many(compilations[:limit]),
}
@@ -275,32 +189,40 @@ def get_all_artist_tracks(artisthash: str):
"""
Returns all artists by a given artist.
"""
tracks = TrackStore.get_tracks_by_artist(artisthash)
tracks = TrackStore.get_tracks_by_artisthash(artisthash)
return {"tracks": tracks}
return {"tracks": serialize_tracks(tracks)}
#
# @api.route("/artist/<artisthash>/similar", methods=["GET"])
# def get_similar_artists(artisthash: str):
# """
# Returns similar artists.
# """
# limit = request.args.get("limit")
#
# if limit is None:
# limit = 6
#
# limit = int(limit)
#
# artist = ArtistStore.get_artist_by_hash(artisthash)
#
# if artist is None:
# return {"error": "Artist not found"}, 404
#
# similar_hashes = fetch_similar_artists(artist.name)
# similar = ArtistStore.get_artists_by_hashes(similar_hashes)
#
# if len(similar) > limit:
# similar = random.sample(similar, limit)
#
# return {"similar": similar[:limit]}
@api.route("/artist/<artisthash>/similar", methods=["GET"])
def get_similar_artists(artisthash: str):
"""
Returns similar artists.
"""
limit = request.args.get("limit")
if limit is None:
limit = 6
limit = int(limit)
artist = ArtistStore.get_artist_by_hash(artisthash)
if artist is None:
return {"error": "Artist not found"}, 404
# result = LastFMStore.get_similar_artists_for(artist.artisthash)
result = fmdb.get_similar_artists_for(artist.artisthash)
if result is None:
return {"artists": []}
similar = ArtistStore.get_artists_by_hashes(result.get_artist_hash_set())
if len(similar) > limit:
similar = random.sample(similar, limit)
return {"artists": similar[:limit]}
# TODO: Rewrite this file using generators where possible
+5 -7
View File
@@ -8,11 +8,9 @@ api = Blueprint("colors", __name__, url_prefix="/colors")
def get_album_color(albumhash: str):
album = Store.get_album_by_hash(albumhash)
if len(album.colors) > 0:
return {
"color": album.colors[0]
}
msg = {"color": ""}
return {
"color": ""
}
if album is None or len(album.colors) == 0:
return msg, 404
return {"color": album.colors[0]}
+44 -21
View File
@@ -2,11 +2,17 @@ from flask import Blueprint, request
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.models import FavType
from app.serializers.album import serialize_for_card_many
from app.serializers.track import serialize_tracks
from app.utils.bisection import UseBisection
from app.store.artists import ArtistStore
from app.store.albums import AlbumStore
from app.store.tracks import TrackStore
from app.serializers.favorites_serializer import (
recent_fav_album_serializer,
recent_fav_artist_serializer,
)
api = Blueprint("favorite", __name__, url_prefix="/")
@@ -78,7 +84,7 @@ def get_favorite_albums():
if limit == 0:
limit = len(albums)
return {"albums": fav_albums[:limit]}
return {"albums": serialize_for_card_many(fav_albums[:limit])}
@api.route("/tracks/favorite")
@@ -101,7 +107,7 @@ def get_favorite_tracks():
if limit == 0:
limit = len(tracks)
return {"tracks": tracks[:limit]}
return {"tracks": serialize_tracks(tracks[:limit])}
@api.route("/artists/favorite")
@@ -149,52 +155,69 @@ def get_all_favorites():
track_limit = int(track_limit)
album_limit = int(album_limit)
artist_limit = int(artist_limit)
largest = max(track_limit, album_limit, artist_limit)
favs = favdb.get_all()
favs.reverse()
favs = [fav for fav in favs if fav[1] != ""]
tracks = []
albums = []
artists = []
for fav in favs:
if (
len(tracks) >= track_limit
and len(albums) >= album_limit
and len(artists) >= artist_limit
):
break
if not len(tracks) >= track_limit:
if not len(tracks) >= largest:
if fav[2] == FavType.track:
tracks.append(fav[1])
if not len(albums) >= album_limit:
if fav[2] == FavType.album:
albums.append(fav[1])
if not len(artists) >= artist_limit:
if not len(artists) >= largest:
if fav[2] == FavType.artist:
artists.append(fav[1])
if fav[2] == FavType.album:
albums.append(fav[1])
src_tracks = sorted(TrackStore.tracks, key=lambda x: x.trackhash)
src_albums = sorted(AlbumStore.albums, key=lambda x: x.albumhash)
src_artists = sorted(ArtistStore.artists, key=lambda x: x.artisthash)
tracks = UseBisection(src_tracks, "trackhash", tracks)()
albums = UseBisection(src_albums, "albumhash", albums)()
albums = UseBisection(src_albums, "albumhash", albums, limit=album_limit)()
artists = UseBisection(src_artists, "artisthash", artists)()
tracks = remove_none(tracks)
albums = remove_none(albums)
artists = remove_none(artists)
recents = []
# first_n = favs
for fav in favs:
if len(recents) >= largest:
break
if fav[2] == FavType.album:
try:
album = [a for a in albums if a.albumhash == fav[1]][0]
recents.append(
{"type": "album", "item": recent_fav_album_serializer(album)}
)
except IndexError:
continue
if fav[2] == FavType.artist:
try:
artist = [a for a in artists if a.artisthash == fav[1]][0]
recents.append(
{"type": "artist", "item": recent_fav_artist_serializer(artist)}
)
except IndexError:
continue
return {
"tracks": tracks,
"albums": albums,
"artists": artists,
"recents": recents[:album_limit],
"tracks": tracks[:track_limit],
"albums": albums[:album_limit],
"artists": artists[:artist_limit],
}
+36 -5
View File
@@ -2,17 +2,20 @@
Contains all the folder routes.
"""
import os
import psutil
from pathlib import Path
import psutil
from flask import Blueprint, request
from showinfm import show_in_file_manager
from app import settings
from app.lib.folderslib import GetFilesAndDirs, get_folders
from app.db.sqlite.settings import SettingsSQLMethods as db
from app.utils.wintools import win_replace_slash, is_windows
from app.lib.folderslib import GetFilesAndDirs, get_folders
from app.serializers.track import serialize_track
from app.store.tracks import TrackStore as store
from app.utils.wintools import is_windows, win_replace_slash
api = Blueprint("folder", __name__, url_prefix="/")
api = Blueprint("folder", __name__, url_prefix="")
@api.route("/folder", methods=["POST"])
@@ -116,3 +119,31 @@ def list_folders():
return {
"folders": sorted(dirs, key=lambda i: i["name"]),
}
@api.route("/folder/show-in-files")
def open_in_file_manager():
path = request.args.get("path")
if path is None:
return {"error": "No path provided."}, 400
show_in_file_manager(path)
return {"success": True}
@api.route("/folder/tracks/all")
def get_tracks_in_path():
path = request.args.get("path")
if path is None:
return {"error": "No path provided."}, 400
tracks = store.get_tracks_in_path(path)
tracks = sorted(tracks, key=lambda i: i.last_mod)
tracks = (serialize_track(t) for t in tracks if Path(t.filepath).exists())
return {
"tracks": list(tracks)[:300],
}
+11
View File
@@ -22,6 +22,17 @@ def send_fallback_img(filename: str = "default.webp"):
return send_from_directory(path, filename)
@api.route("/t/o/<imgpath>")
def send_original_thumbnail(imgpath: str):
path = Paths.get_original_thumb_path()
fpath = Path(path) / imgpath
if fpath.exists():
return send_from_directory(path, imgpath)
return send_fallback_img()
@api.route("/t/<imgpath>")
def send_lg_thumbnail(imgpath: str):
path = Paths.get_lg_thumb_path()
+252 -70
View File
@@ -3,35 +3,27 @@ All playlist-related routes.
"""
import json
from datetime import datetime
import pathlib
from flask import Blueprint, request
from PIL import UnidentifiedImageError
from PIL import UnidentifiedImageError, Image
from app import models
from app.db.sqlite.playlists import SQLitePlaylistMethods
from app.lib import playlistlib
from app.utils.dates import date_string_to_time_passed, create_new_date
from app.utils.remove_duplicates import remove_duplicates
from app.store.tracks import TrackStore
from app.lib.albumslib import sort_by_track_no
from app.models.track import Track
from app.store.albums import AlbumStore
from app.store.tracks import TrackStore
from app.utils.dates import create_new_date, date_string_to_time_passed
from app.utils.remove_duplicates import remove_duplicates
from app.settings import Paths
api = Blueprint("playlist", __name__, url_prefix="/")
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
def duplicate_images(images: list):
if len(images) == 1:
images *= 4
@@ -43,24 +35,33 @@ def duplicate_images(images: list):
return images
def get_first_4_images(trackhashes: list[str]) -> list[dict['str', str]]:
tracks = TrackStore.get_tracks_by_trackhashes(trackhashes)
def get_first_4_images(
tracks: list[Track] = [], trackhashes: list[str] = []
) -> list[dict["str", str]]:
if len(trackhashes) > 0:
tracks = TrackStore.get_tracks_by_trackhashes(trackhashes)
albums = []
for track in tracks:
if track.albumhash not in albums:
albums.append(track.albumhash)
if len(albums) == 4:
break
albums = AlbumStore.get_albums_by_hashes(albums)
images = [
{
'image': album.image,
'color': ''.join(album.colors),
"image": album.image,
"color": "".join(album.colors),
}
for album in albums
]
if len(images) == 4:
return images
return duplicate_images(images)
@@ -69,16 +70,15 @@ def send_all_playlists():
"""
Gets all the playlists.
"""
# get the no_images query param
no_images = request.args.get("no_images", False)
playlists = get_all_playlists()
playlists = PL.get_all_playlists()
playlists = list(playlists)
for playlist in playlists:
if not no_images:
playlist.images = get_first_4_images(playlist.trackhashes)
playlist.images = [img['image'] for img in playlist.images]
playlist.images = get_first_4_images(trackhashes=playlist.trackhashes)
playlist.images = [img["image"] for img in playlist.images]
playlist.clear_lists()
@@ -90,6 +90,25 @@ def send_all_playlists():
return {"data": playlists}
def insert_playlist(name: str, image: str = None):
playlist = {
"image": image,
"last_updated": create_new_date(),
"name": name,
"trackhashes": json.dumps([]),
"settings": json.dumps(
{
"has_gif": False,
"banner_pos": 50,
"square_img": True if image else False,
"pinned": False,
}
),
}
return PL.insert_one_playlist(playlist)
@api.route("/playlist/new", methods=["POST"])
def create_playlist():
"""
@@ -100,22 +119,12 @@ def create_playlist():
if data is None:
return {"error": "Playlist name not provided"}, 400
existing_playlist_count = count_playlist_by_name(data["name"])
existing_playlist_count = PL.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)
playlist = insert_playlist(data["name"])
if playlist is None:
return {"error": "Playlist could not be created"}, 500
@@ -123,8 +132,35 @@ def create_playlist():
return {"playlist": playlist}, 201
def get_path_trackhashes(path: str):
"""
Returns a list of trackhashes in a folder.
"""
tracks = TrackStore.get_tracks_in_path(path)
tracks = sorted(tracks, key=lambda t: t.last_mod)
return [t.trackhash for t in tracks]
def get_album_trackhashes(albumhash: str):
"""
Returns a list of trackhashes in an album.
"""
tracks = TrackStore.get_tracks_by_albumhash(albumhash)
tracks = sort_by_track_no(tracks)
return [t["trackhash"] for t in tracks]
def get_artist_trackhashes(artisthash: str):
"""
Returns a list of trackhashes for an artist.
"""
tracks = TrackStore.get_tracks_by_artisthash(artisthash)
return [t.trackhash for t in tracks]
@api.route("/playlist/<playlist_id>/add", methods=["POST"])
def add_track_to_playlist(playlist_id: str):
def add_item_to_playlist(playlist_id: str):
"""
Takes a playlist ID and a track hash, and adds the track to the playlist
"""
@@ -133,15 +169,31 @@ def add_track_to_playlist(playlist_id: str):
if data is None:
return {"error": "Track hash not provided"}, 400
trackhash = data["track"]
try:
itemtype = data["itemtype"]
except KeyError:
itemtype = None
insert_count = tracks_to_playlist(int(playlist_id), [trackhash])
try:
itemhash: str = data["itemhash"]
except KeyError:
itemhash = None
if itemtype == "tracks":
trackhashes = itemhash.split(",")
elif itemtype == "folder":
trackhashes = get_path_trackhashes(itemhash)
elif itemtype == "album":
trackhashes = get_album_trackhashes(itemhash)
elif itemtype == "artist":
trackhashes = get_artist_trackhashes(itemhash)
else:
trackhashes = []
insert_count = PL.add_tracks_to_playlist(int(playlist_id), trackhashes)
if insert_count == 0:
return {"error": "Track already exists in playlist"}, 409
add_artist_to_playlist(int(playlist_id), trackhash)
PL.update_last_updated(int(playlist_id))
return {"error": "Item already exists in playlist"}, 409
return {"msg": "Done"}, 200
@@ -151,7 +203,10 @@ 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))
no_tracks = request.args.get("no_tracks", False)
no_tracks = no_tracks == "true"
playlist = PL.get_playlist_by_id(int(playlistid))
if playlist is None:
return {"msg": "Playlist not found"}, 404
@@ -163,17 +218,14 @@ def get_playlist(playlistid: str):
playlist.last_updated = date_string_to_time_passed(playlist.last_updated)
playlist.set_duration(duration)
playlist.set_count(len(tracks))
if not playlist.has_image:
playlist.images = get_first_4_images(playlist.trackhashes)
if len(playlist.images) > 2:
# swap 3rd image with first (3rd image is the visible image in UI)
playlist.images[2], playlist.images[0] = playlist.images[0], playlist.images[2]
playlist.images = get_first_4_images(tracks)
playlist.clear_lists()
return {"info": playlist, "tracks": tracks}
return {"info": playlist, "tracks": tracks if not no_tracks else []}
@api.route("/playlist/<playlistid>/update", methods=["PUT"])
@@ -181,7 +233,7 @@ 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))
db_playlist = PL.get_playlist_by_id(int(playlistid))
if db_playlist is None:
return {"error": "Playlist not found"}, 404
@@ -193,34 +245,36 @@ def update_playlist_info(playlistid: str):
data = request.form
settings = json.loads(data.get("settings"))
settings["has_gif"] = False
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(),
"settings": settings,
"trackhashes": json.dumps([]),
}
if image:
try:
playlist["image"] = playlistlib.save_p_image(image, playlistid)
pil_image = Image.open(image)
content_type = image.content_type
playlist["image"] = playlistlib.save_p_image(
pil_image, playlistid, content_type
)
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)
playlist["settings"]["has_gif"] = True
except UnidentifiedImageError:
return {"error": "Failed: Invalid image"}, 400
p_tuple = (*playlist.values(),)
update_playlist(int(playlistid), playlist)
PL.update_playlist(int(playlistid), playlist)
playlist = models.Playlist(*p_tuple)
playlist.last_updated = date_string_to_time_passed(playlist.last_updated)
@@ -230,6 +284,52 @@ def update_playlist_info(playlistid: str):
}
@api.route("/playlist/<playlistid>/pin_unpin", methods=["GET"])
def pin_unpin_playlist(playlistid: str):
"""
Pins or unpins a playlist.
"""
playlist = PL.get_playlist_by_id(int(playlistid))
if playlist is None:
return {"error": "Playlist not found"}, 404
settings = playlist.settings
try:
settings["pinned"] = not settings["pinned"]
except KeyError:
settings["pinned"] = True
PL.update_settings(int(playlistid), settings)
return {"msg": "Done"}, 200
@api.route("/playlist/<playlistid>/remove-img", methods=["GET"])
def remove_playlist_image(playlistid: str):
"""
Removes the playlist image.
"""
pid = int(playlistid)
playlist = PL.get_playlist_by_id(pid)
if playlist is None:
return {"error": "Playlist not found"}, 404
PL.remove_banner(pid)
playlist.image = None
playlist.thumb = None
playlist.settings["has_gif"] = False
playlist.has_image = False
playlist.images = get_first_4_images(trackhashes=playlist.trackhashes)
playlist.last_updated = date_string_to_time_passed(playlist.last_updated)
return {"playlist": playlist}, 200
@api.route("/playlist/delete", methods=["POST"])
def remove_playlist():
"""
@@ -246,24 +346,106 @@ def remove_playlist():
except KeyError:
return message, 400
delete_playlist(pid)
PL.delete_playlist(pid)
return {"msg": "Done"}, 200
@api.route("/playlist/<pid>/set-image-pos", methods=["POST"])
def update_image_position(pid: int):
@api.route("/playlist/<pid>/remove-tracks", methods=["POST"])
def remove_tracks_from_playlist(pid: int):
data = request.get_json()
message = {"msg": "No data provided"}
if data is None:
return message, 400
return {"error": "Track index not provided"}, 400
# {
# trackhash: str;
# index: int;
# }
tracks = data["tracks"]
PL.remove_tracks_from_playlist(pid, tracks)
return {"msg": "Done"}, 200
def playlist_exists(name: str) -> bool:
return PL.count_playlist_by_name(name) > 0
@api.route("/playlist/save-item", methods=["POST"])
def save_item_as_playlist():
data = request.get_json()
msg = {"error": "'itemtype', 'playlist_name' and 'itemhash' not provided"}, 400
if data is None:
return msg
try:
pos = data["pos"]
playlist_name = data["playlist_name"]
except KeyError:
return message, 400
playlist_name = None
PL.update_banner_pos(pid, pos)
if playlist_exists(playlist_name):
return {"error": "Playlist already exists"}, 409
return {"msg": "Image position saved"}, 200
try:
itemtype = data["itemtype"]
except KeyError:
itemtype = None
try:
itemhash: str = data["itemhash"]
except KeyError:
itemhash = None
if itemtype is None or playlist_name is None or itemhash is None:
return msg
if itemtype == "tracks":
trackhashes = itemhash.split(",")
elif itemtype == "folder":
trackhashes = get_path_trackhashes(itemhash)
elif itemtype == "album":
trackhashes = get_album_trackhashes(itemhash)
elif itemtype == "artist":
trackhashes = get_artist_trackhashes(itemhash)
else:
trackhashes = []
if len(trackhashes) == 0:
return {"error": "No tracks founds"}, 404
image = (
itemhash + ".webp" if itemtype != "folder" and itemtype != "tracks" else None
)
playlist = insert_playlist(playlist_name, image)
if playlist is None:
return {"error": "Playlist could not be created"}, 500
# save image
if itemtype != "folder" and itemtype != "tracks":
filename = itemhash + ".webp"
base_path = (
Paths.get_artist_img_lg_path()
if itemtype == "artist"
else Paths.get_lg_thumb_path()
)
img_path = pathlib.Path(base_path + "/" + filename)
if img_path.exists():
img = Image.open(img_path)
playlistlib.save_p_image(
img, str(playlist.id), "image/webp", filename=filename
)
PL.add_tracks_to_playlist(playlist.id, trackhashes)
playlist.set_count(len(trackhashes))
images = get_first_4_images(trackhashes=trackhashes)
playlist.images = [img["image"] for img in images]
return {"playlist": playlist}, 201
+50 -87
View File
@@ -2,13 +2,11 @@
Contains all the search routes.
"""
from unidecode import unidecode
from flask import Blueprint, request
from unidecode import unidecode
from app import models
from app.lib import searchlib
from app.store.tracks import TrackStore
api = Blueprint("search", __name__, url_prefix="/")
@@ -17,74 +15,52 @@ SEARCH_COUNT = 12
"""The max amount of items to return per request"""
class SearchResults:
def query_in_quotes(query: str) -> bool:
"""
Holds all the search results.
Returns True if the query is in quotes
"""
query: str = ""
tracks: list[models.Track] = []
albums: list[models.Album] = []
playlists: list[models.Playlist] = []
artists: list[models.Artist] = []
try:
return query.startswith('"') and query.endswith('"')
except AttributeError:
return False
class Search:
def __init__(self, query: str) -> None:
self.tracks: list[models.Track] = []
self.query = unidecode(query)
SearchResults.query = self.query
def search_tracks(self):
def search_tracks(self, in_quotes=False):
"""
Calls :class:`SearchTracks` which returns the tracks that fuzzily match
the search terms. Then adds them to the `SearchResults` store.
"""
self.tracks = TrackStore.tracks
tracks = searchlib.SearchTracks(self.query)()
SearchResults.tracks = tracks
return tracks
return searchlib.TopResults().search(
self.query, tracks_only=True, in_quotes=in_quotes
)
def search_artists(self):
"""Calls :class:`SearchArtists` which returns the artists that fuzzily match
the search term. Then adds them to the `SearchResults` store.
"""
artists = searchlib.SearchArtists(self.query)()
SearchResults.artists = artists
return artists
return searchlib.SearchArtists(self.query)()
def search_albums(self):
def search_albums(self, in_quotes=False):
"""Calls :class:`SearchAlbums` which returns the albums that fuzzily match
the search term. Then adds them to the `SearchResults` store.
"""
albums = searchlib.SearchAlbums(self.query)()
SearchResults.albums = albums
return searchlib.TopResults().search(
self.query, albums_only=True, in_quotes=in_quotes
)
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 get_top_results(self):
finder = searchlib.SearchAll()
return finder.search(self.query)
def search_all(self):
"""Calls all the search methods."""
self.search_tracks()
self.search_albums()
self.search_artists()
# self.search_playlists()
def get_top_results(
self,
limit: int,
in_quotes=False,
):
finder = searchlib.TopResults()
return finder.search(self.query, in_quotes=in_quotes, limit=limit)
@api.route("/search/tracks", methods=["GET"])
@@ -94,10 +70,12 @@ def search_tracks():
"""
query = request.args.get("q")
in_quotes = query_in_quotes(query)
if not query:
return {"error": "No query provided"}, 400
tracks = Search(query).search_tracks()
tracks = Search(query).search_tracks(in_quotes)
return {
"tracks": tracks[:SEARCH_COUNT],
@@ -112,14 +90,16 @@ def search_albums():
"""
query = request.args.get("q")
in_quotes = query_in_quotes(query)
if not query:
return {"error": "No query provided"}, 400
tracks = Search(query).search_albums()
albums = Search(query).search_albums(in_quotes)
return {
"albums": tracks[:SEARCH_COUNT],
"more": len(tracks) > SEARCH_COUNT,
"albums": albums[:SEARCH_COUNT],
"more": len(albums) > SEARCH_COUNT,
}
@@ -130,6 +110,7 @@ def search_artists():
"""
query = request.args.get("q")
if not query:
return {"error": "No query provided"}, 400
@@ -141,24 +122,6 @@ def search_artists():
}
# @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,
# }
@api.route("/search/top", methods=["GET"])
def get_top_results():
"""
@@ -166,21 +129,15 @@ def get_top_results():
"""
query = request.args.get("q")
limit = request.args.get("limit", "6")
limit = int(limit)
in_quotes = query_in_quotes(query)
if not query:
return {"error": "No query provided"}, 400
results = Search(query).get_top_results()
# max_results = 2
# return {
# "tracks": SearchResults.tracks[:max_results],
# "albums": SearchResults.albums[:max_results],
# "artists": SearchResults.artists[:max_results],
# "playlists": SearchResults.playlists[:max_results],
# }
return {
"results": results
}
return Search(query).get_top_results(in_quotes=in_quotes, limit=limit)
@api.route("/search/loadmore")
@@ -188,26 +145,32 @@ def search_load_more():
"""
Returns more songs, albums or artists from a search query.
"""
query = request.args.get("q")
in_quotes = query_in_quotes(query)
s_type = request.args.get("type")
index = int(request.args.get("index") or 0)
if s_type == "tracks":
t = SearchResults.tracks
t = Search(query).search_tracks(in_quotes)
return {
"tracks": t[index: index + SEARCH_COUNT],
"tracks": t[index : index + SEARCH_COUNT],
"more": len(t) > index + SEARCH_COUNT,
}
elif s_type == "albums":
a = SearchResults.albums
a = Search(query).search_albums(in_quotes)
return {
"albums": a[index: index + SEARCH_COUNT],
"albums": a[index : index + SEARCH_COUNT],
"more": len(a) > index + SEARCH_COUNT,
}
elif s_type == "artists":
a = SearchResults.artists
a = Search(query).search_artists()
return {
"artists": a[index: index + SEARCH_COUNT],
"artists": a[index : index + SEARCH_COUNT],
"more": len(a) > index + SEARCH_COUNT,
}
# TODO: Rewrite this file using generators where possible
+12 -4
View File
@@ -24,9 +24,17 @@ def send_track_file(trackhash: str):
filepath = request.args.get("filepath")
if filepath is not None and os.path.exists(filepath):
audio_type = get_mime(filepath)
return send_file(filepath, mimetype=audio_type)
if filepath is not None:
try:
track = TrackStore.get_tracks_by_filepaths([filepath])[0]
except IndexError:
track = None
track_exists = track is not None and os.path.exists(track.filepath)
if track_exists:
audio_type = get_mime(filepath)
return send_file(filepath, mimetype=audio_type)
if trackhash is None:
return msg, 404
@@ -41,7 +49,7 @@ def send_track_file(trackhash: str):
try:
return send_file(track.filepath, mimetype=audio_type)
except FileNotFoundError:
except (FileNotFoundError, OSError) as e:
return msg, 404
return msg, 404
+128 -16
View File
@@ -1,17 +1,16 @@
from flask import Blueprint, request
from app import settings
from app.logger import log
from app.db.sqlite.settings import SettingsSQLMethods as sdb
from app.lib import populate
from app.lib.watchdogg import Watcher as WatchDog
from app.db.sqlite.settings import SettingsSQLMethods as sdb
from app.logger import log
from app.settings import Paths, SessionVarKeys, set_flag
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
from app.store.tracks import TrackStore
from app.utils.generators import get_random_str
from app.utils.threading import background
from app.store.albums import AlbumStore
from app.store.tracks import TrackStore
from app.store.artists import ArtistStore
api = Blueprint("settings", __name__, url_prefix="/")
@@ -21,13 +20,24 @@ def get_child_dirs(parent: str, children: list[str]):
return [_dir for _dir in children if _dir.startswith(parent) and _dir != parent]
def reload_everything():
def reload_everything(instance_key: str):
"""
Reloads all stores using the current database items
"""
TrackStore.load_all_tracks()
AlbumStore.load_albums()
ArtistStore.load_artists()
try:
TrackStore.load_all_tracks(instance_key)
except Exception as e:
log.error(e)
try:
AlbumStore.load_albums(instance_key=instance_key)
except Exception as e:
log.error(e)
try:
ArtistStore.load_artists(instance_key)
except Exception as e:
log.error(e)
@background
@@ -35,15 +45,16 @@ def rebuild_store(db_dirs: list[str]):
"""
Restarts the watchdog and rebuilds the music library.
"""
instance_key = get_random_str()
log.info("Rebuilding library...")
TrackStore.remove_tracks_by_dir_except(db_dirs)
reload_everything()
reload_everything(instance_key)
key = get_random_str()
try:
populate.Populate(key=key)
populate.Populate(instance_key=instance_key)
except populate.PopulateCancelledError:
reload_everything()
reload_everything(instance_key)
return
WatchDog().restart()
@@ -51,6 +62,7 @@ def rebuild_store(db_dirs: list[str]):
log.info("Rebuilding library... ✅")
# I freaking don't know what this function does anymore
def finalize(new_: list[str], removed_: list[str], db_dirs_: list[str]):
"""
Params:
@@ -96,7 +108,7 @@ def add_root_dirs():
sdb.remove_root_dirs(db_dirs)
if incoming_home:
finalize([_h], [], [settings.Paths.USER_HOME_DIR])
finalize([_h], [], [Paths.USER_HOME_DIR])
return {"root_dirs": [_h]}
# ---
@@ -127,3 +139,103 @@ def get_root_dirs():
dirs = sdb.get_root_dirs()
return {"dirs": dirs}
# maps settings to their parser flags
mapp = {
"artist_separators": SessionVarKeys.ARTIST_SEPARATORS,
"extract_feat": SessionVarKeys.EXTRACT_FEAT,
"remove_prod": SessionVarKeys.REMOVE_PROD,
"clean_album_title": SessionVarKeys.CLEAN_ALBUM_TITLE,
"remove_remaster": SessionVarKeys.REMOVE_REMASTER_FROM_TRACK,
"merge_albums": SessionVarKeys.MERGE_ALBUM_VERSIONS,
"show_albums_as_singles": SessionVarKeys.SHOW_ALBUMS_AS_SINGLES,
}
@api.route("/settings/", methods=["GET"])
def get_all_settings():
"""
Get all settings from the database.
"""
settings = sdb.get_all_settings()
key_list = list(mapp.keys())
s = {}
for key in key_list:
val_index = key_list.index(key)
try:
s[key] = settings[val_index]
if type(s[key]) == int:
s[key] = bool(s[key])
if type(s[key]) == str:
s[key] = str(s[key]).split(",")
except IndexError:
s[key] = None
root_dirs = sdb.get_root_dirs()
s["root_dirs"] = root_dirs
return {
"settings": s,
}
@background
def reload_all_for_set_setting():
reload_everything(get_random_str())
@api.route("/settings/set", methods=["POST"])
def set_setting():
key = request.get_json().get("key")
value = request.get_json().get("value")
if key is None or value is None or key == "root_dirs":
return {"msg": "Invalid arguments!"}, 400
root_dir = sdb.get_root_dirs()
if not root_dir:
return {"msg": "No root directories set!"}, 400
if key not in mapp:
return {"msg": "Invalid key!"}, 400
sdb.set_setting(key, value)
flag = mapp[key]
if key == "artist_separators":
value = str(value).split(",")
value = set(value)
set_flag(flag, value)
reload_all_for_set_setting()
# if value is a set, convert it to a string
# (artist_separators)
if type(value) == set:
value = ",".join(value)
return {"result": value}
@background
def run_populate():
populate.Populate(instance_key=get_random_str())
@api.route("/settings/trigger-scan", methods=["GET"])
def trigger_scan():
"""
Triggers a scan.
"""
run_populate()
return {"msg": "Scan triggered!"}
+68 -27
View File
@@ -4,19 +4,13 @@ Handles arguments passed to the program.
import os.path
import sys
from configparser import ConfigParser
import PyInstaller.__main__ as bundler
from app import settings
from app.print_help import HELP_MESSAGE
from app.utils.wintools import is_windows
from app.logger import log
from app.print_help import HELP_MESSAGE
from app.utils.xdg_utils import get_xdg_config_dir
# from app.api.imgserver import set_app_dir
config = ConfigParser()
config.read("pyinstaller.config.ini")
from app.utils.wintools import is_windows
ALLARGS = settings.ALLARGS
ARGS = sys.argv[1:]
@@ -28,8 +22,10 @@ class HandleArgs:
self.handle_host()
self.handle_port()
self.handle_config_path()
self.handle_no_feat()
self.handle_remove_prod()
self.handle_periodic_scan()
self.handle_periodic_scan_interval()
self.handle_help()
self.handle_version()
@@ -38,10 +34,33 @@ class HandleArgs:
"""
Runs Pyinstaller.
"""
if ALLARGS.build in ARGS:
with open("pyinstaller.config.ini", "w", encoding="utf-8") as file:
config["DEFAULT"]["BUILD"] = "True"
config.write(file)
if ALLARGS.build not in ARGS:
return
if settings.IS_BUILD:
print("Do the cha cha slide instead!")
print("https://www.youtube.com/watch?v=wZv62ShoStY")
sys.exit(0)
lastfm_key = settings.Keys.LASTFM_API
posthog_key = settings.Keys.POSTHOG_API_KEY
if not lastfm_key:
log.error("ERROR: LASTFM_API_KEY not set in environment")
sys.exit(0)
if not posthog_key:
log.error("ERROR: POSTHOG_API_KEY not set in environment")
sys.exit(0)
try:
with open("./app/configs.py", "w", encoding="utf-8") as file:
# copy the api keys to the config file
line1 = f'LASTFM_API_KEY = "{lastfm_key}"\n'
line2 = f'POSTHOG_API_KEY = "{posthog_key}"\n'
file.write(line1)
file.write(line2)
_s = ";" if is_windows() else ":"
@@ -54,14 +73,17 @@ class HandleArgs:
"--clean",
f"--add-data=assets{_s}assets",
f"--add-data=client{_s}client",
f"--add-data=pyinstaller.config.ini{_s}.",
f"--icon=assets/logo-fill.ico",
"-y",
]
)
with open("pyinstaller.config.ini", "w", encoding="utf-8") as file:
config["DEFAULT"]["BUILD"] = "False"
config.write(file)
finally:
# revert and remove the api keys for dev mode
with open("./app/configs.py", "w", encoding="utf-8") as file:
line1 = "LASTFM_API_KEY = ''\n"
line2 = "POSTHOG_API_KEY = ''\n"
file.write(line1)
file.write(line2)
sys.exit(0)
@@ -92,7 +114,7 @@ class HandleArgs:
print("ERROR: Host not specified")
sys.exit(0)
settings.FLASKVARS.FLASK_HOST = host # type: ignore
settings.FLASKVARS.set_flask_host(host) # type: ignore
@staticmethod
def handle_config_path():
@@ -117,15 +139,34 @@ class HandleArgs:
settings.Paths.set_config_dir(get_xdg_config_dir())
@staticmethod
def handle_no_feat():
# if ArgsEnum.no_feat in ARGS:
if any((a in ARGS for a in ALLARGS.show_feat)):
settings.FromFlags.EXTRACT_FEAT = False
def handle_periodic_scan():
if any((a in ARGS for a in ALLARGS.no_periodic_scan)):
settings.SessionVars.DO_PERIODIC_SCANS = False
@staticmethod
def handle_remove_prod():
if any((a in ARGS for a in ALLARGS.show_prod)):
settings.FromFlags.REMOVE_PROD = False
def handle_periodic_scan_interval():
if any((a in ARGS for a in ALLARGS.periodic_scan_interval)):
index = [
ARGS.index(a) for a in ALLARGS.periodic_scan_interval if a in ARGS
][0]
try:
interval = ARGS[index + 1]
except IndexError:
print("ERROR: Interval not specified")
sys.exit(0)
try:
psi = int(interval)
except ValueError:
print("ERROR: Interval should be a number")
sys.exit(0)
if psi < 0:
print("WADAFUCK ARE YOU TRYING?")
sys.exit(0)
settings.SessionVars.PERIODIC_SCAN_INTERVAL = psi
@staticmethod
def handle_help():
-61
View File
@@ -1,61 +0,0 @@
"""
Module for managing the JSON config file.
"""
import json
from enum import Enum
from typing import Type
from app.settings import Db
class ConfigKeys(Enum):
ROOT_DIRS = ("root_dirs", list[str])
PLAYLIST_DIRS = ("playlist_dirs", list[str])
USE_ART_COLORS = ("use_art_colors", bool)
DEFAULT_ART_COLOR = ("default_art_color", str)
SHUFFLE_MODE = ("shuffle_mode", str)
REPEAT_MODE = ("repeat_mode", str)
AUTOPLAY_ON_START = ("autoplay_on_start", bool)
VOLUME = ("volume", int)
def __init__(self, key_name: str, data_type: Type):
self.key_name = key_name
self.data_type = data_type
def get_data_type(self) -> Type:
return self.data_type
class ConfigManager:
def __init__(self, config_file_path: str):
self.config_file_path = config_file_path
def read_config(self):
try:
with open(self.config_file_path) as f:
return json.load(f)
except FileNotFoundError:
return {}
# in case of errors, return an empty dict
def write_config(self, config_data):
with open(self.config_file_path, "w") as f:
json.dump(config_data, f, indent=4)
def get_value(self, key: ConfigKeys):
config_data = self.read_config()
value = config_data.get(key.key_name)
if value is not None:
return key.get_data_type()(value)
def set_value(self, key: ConfigKeys, value):
config_data = self.read_config()
config_data[key.key_name] = value
self.write_config(config_data)
settings = ConfigManager(Db.get_json_config_path())
a = settings.get_value(ConfigKeys.ROOT_DIRS)
+2
View File
@@ -0,0 +1,2 @@
LASTFM_API_KEY = ''
POSTHOG_API_KEY = ''
-2
View File
@@ -6,8 +6,6 @@ import sqlite3
from pathlib import Path
from sqlite3 import Connection as SqlConn
from app.settings import Db
def create_connection(db_file: str) -> SqlConn:
"""
@@ -17,13 +17,16 @@ class SQLiteAlbumMethods:
"""
cur.execute(sql, (albumhash, colors))
return cur.lastrowid
lastrowid = cur.lastrowid
return lastrowid
@classmethod
def get_all_albums(cls):
with SQLiteManager() as cur:
cur.execute("SELECT * FROM albums")
albums = cur.fetchall()
cur.close()
if albums is not None:
return albums
@@ -35,8 +38,29 @@ class SQLiteAlbumMethods:
with SQLiteManager() as cur:
cur.execute("SELECT * FROM albums WHERE albumartist=?", (albumartist,))
albums = cur.fetchall()
cur.close()
if albums is not None:
return tuples_to_albums(albums)
return []
@staticmethod
def exists(albumhash: str, cur: Cursor = None):
"""
Checks if an album exists in the database.
"""
sql = "SELECT COUNT(1) FROM albums WHERE albumhash = ?"
def _exists(cur: Cursor):
cur.execute(sql, (albumhash,))
count = cur.fetchone()[0]
return count != 0
if cur:
return _exists(cur)
with SQLiteManager() as cur:
return _exists(cur)
@@ -10,7 +10,7 @@ from .utils import SQLiteManager
class SQLiteArtistMethods:
@staticmethod
def insert_one_artist(cur: Cursor, artisthash: str, colors: str | list[str]):
def insert_one_artist(cur: Cursor, artisthash: str, colors: list[str]):
"""
Inserts a single artist into the database.
"""
@@ -23,14 +23,42 @@ class SQLiteArtistMethods:
cur.execute(sql, (artisthash, colors))
@staticmethod
def get_all_artists():
def get_all_artists(cur_: Cursor = None):
"""
Get all artists from the database and return a generator of Artist objects
"""
sql = """SELECT * FROM artists"""
with SQLiteManager() as cur:
cur.execute(sql)
if not cur_:
with SQLiteManager() as cur:
cur.execute(sql)
for artist in cur.fetchall():
for artist in cur.fetchall():
yield artist
cur.close()
else:
cur_.execute(sql)
for artist in cur_.fetchall():
yield artist
@staticmethod
def exists(artisthash: str, cur: Cursor = None):
"""
Checks if an artist exists in the database.
"""
sql = "SELECT COUNT(1) FROM artists WHERE artisthash = ?"
def _exists(cur: Cursor):
cur.execute(sql, (artisthash,))
count = cur.fetchone()[0]
return count != 0
if cur:
return _exists(cur)
with SQLiteManager() as cur:
return _exists(cur)
+9 -2
View File
@@ -14,6 +14,7 @@ class SQLiteFavoriteMethods:
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (itemhash, fav_type))
items = cur.fetchall()
cur.close()
return len(items) > 0
@classmethod
@@ -28,6 +29,7 @@ class SQLiteFavoriteMethods:
sql = """INSERT INTO favorites(type, hash) VALUES(?,?)"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (fav_type, fav_hash))
cur.close()
@classmethod
def get_all(cls) -> list[tuple]:
@@ -37,7 +39,9 @@ class SQLiteFavoriteMethods:
sql = """SELECT * FROM favorites"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql)
return cur.fetchall()
favs = cur.fetchall()
cur.close()
return [fav for fav in favs if fav[1] != ""]
@classmethod
def get_favorites(cls, fav_type: str) -> list[tuple]:
@@ -47,7 +51,9 @@ class SQLiteFavoriteMethods:
sql = """SELECT * FROM favorites WHERE type = ?"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (fav_type,))
return cur.fetchall()
all_favs = cur.fetchall()
cur.close()
return all_favs
@classmethod
def get_fav_tracks(cls) -> list[tuple]:
@@ -79,3 +85,4 @@ class SQLiteFavoriteMethods:
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (fav_hash, fav_type))
cur.close()
View File
+62
View File
@@ -0,0 +1,62 @@
from app.models.lastfm import SimilarArtist
from ..utils import SQLiteManager
class SQLiteLastFMSimilarArtists:
"""
This class contains methods for interacting with the lastfm_similar_artists table.
"""
@classmethod
def insert_one(cls, artist: SimilarArtist):
"""
Inserts a single artist into the database.
"""
sql = """INSERT OR REPLACE INTO lastfm_similar_artists(artisthash, similar_artists) VALUES(?,?)"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (artist.artisthash, artist.similar_artist_hashes))
cur.close()
@classmethod
def get_similar_artists_for(cls, artisthash: str):
"""
Returns a list of similar artists.
"""
sql = """SELECT * FROM lastfm_similar_artists WHERE artisthash = ?"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (artisthash,))
similar_artists = cur.fetchone()
cur.close()
if similar_artists is None:
return None
return SimilarArtist(artisthash, similar_artists[2])
@classmethod
def get_all(cls):
"""
Returns a list of all similar artists.
"""
sql = """SELECT * FROM lastfm_similar_artists"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql)
similar_artists = cur.fetchall()
cur.close()
for a in similar_artists:
yield SimilarArtist(a[1], a[2])
@classmethod
def exists(cls, artisthash: str):
"""
Checks if an artist exists in the database by counting the number of rows
"""
sql = """SELECT COUNT(*) FROM lastfm_similar_artists WHERE artisthash = ?"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (artisthash,))
count = cur.fetchone()[0]
cur.close()
return count > 0
+13 -49
View File
@@ -6,62 +6,26 @@ from app.db.sqlite.utils import SQLiteManager
class MigrationManager:
all_get_sql = "SELECT * FROM migrations"
_base = "UPDATE migrations SET"
_end = "= ? WHERE id = 1"
pre_init_set_sql = f"{_base} pre_init_version {_end}"
post_init_set_sql = f"{_base} post_init_version {_end}"
@classmethod
def get_preinit_version(cls) -> int:
@staticmethod
def get_version() -> int:
"""
Returns the latest userdata pre-init database version.
Returns the latest userdata database version.
"""
sql = "SELECT * FROM dbmigrations"
with SQLiteManager() as cur:
cur.execute(cls.all_get_sql)
return int(cur.fetchone()[1])
cur.execute(sql)
ver = int(cur.fetchone()[1])
cur.close()
@classmethod
def get_maindb_postinit_version(cls) -> int:
"""
Returns the latest maindb post-init database version.
"""
with SQLiteManager() as cur:
cur.execute(cls.all_get_sql)
return int(cur.fetchone()[2])
@classmethod
def get_userdatadb_postinit_version(cls) -> int:
"""
Returns the latest userdata post-init database version.
"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(cls.all_get_sql)
return cur.fetchone()[2]
return ver
# 👇 Setters 👇
@classmethod
def set_preinit_version(cls, version: int):
@staticmethod
def set_version(version: int):
"""
Sets the userdata pre-init database version.
"""
sql = "UPDATE dbmigrations SET version = ? WHERE id = 1"
with SQLiteManager() as cur:
cur.execute(cls.pre_init_set_sql, (version,))
@classmethod
def set_maindb_postinit_version(cls, version: int):
"""
Sets the maindb post-init database version.
"""
with SQLiteManager() as cur:
cur.execute(cls.post_init_set_sql, (version,))
@classmethod
def set_userdatadb_postinit_version(cls, version: int):
"""
Sets the userdata post-init database version.
"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(cls.post_init_set_sql, (version,))
cur.execute(sql, (version,))
cur.close()
+73 -33
View File
@@ -13,17 +13,25 @@ class SQLitePlaylistMethods:
This class contains methods for interacting with the playlists table.
"""
@staticmethod
def update_last_updated(playlist_id: int):
"""Updates the last updated date of a playlist."""
sql = """UPDATE playlists SET last_updated = ? WHERE id = ?"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (create_new_date(), playlist_id))
@staticmethod
def insert_one_playlist(playlist: dict):
# banner_pos,
# has_gif,
sql = """INSERT INTO playlists(
artisthashes,
banner_pos,
has_gif,
image,
last_updated,
name,
settings,
trackhashes
) VALUES(:artisthashes, :banner_pos, :has_gif, :image, :last_updated, :name, :trackhashes)
) VALUES(:image, :last_updated, :name, :settings, :trackhashes)
"""
playlist = OrderedDict(sorted(playlist.items()))
@@ -31,6 +39,7 @@ class SQLitePlaylistMethods:
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, playlist)
pid = cur.lastrowid
cur.close()
p_tuple = (pid, *playlist.values())
return tuple_to_playlist(p_tuple)
@@ -43,6 +52,7 @@ class SQLitePlaylistMethods:
cur.execute(sql, (name,))
data = cur.fetchone()
cur.close()
if data is not None:
return tuple_to_playlist(data)
@@ -57,6 +67,7 @@ class SQLitePlaylistMethods:
cur.execute(sql, (name,))
data = cur.fetchone()
cur.close()
return int(data[0])
@@ -65,6 +76,7 @@ class SQLitePlaylistMethods:
with SQLiteManager(userdata_db=True) as cur:
cur.execute("SELECT * FROM playlists")
playlists = cur.fetchall()
cur.close()
if playlists is not None:
return tuples_to_playlists(playlists)
@@ -79,6 +91,7 @@ class SQLitePlaylistMethods:
cur.execute(sql, (playlist_id,))
data = cur.fetchone()
cur.close()
if data is not None:
return tuple_to_playlist(data)
@@ -87,12 +100,11 @@ class SQLitePlaylistMethods:
# 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]):
@classmethod
def add_item_to_json_list(cls, playlist_id: int, field: str, items: set[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.
Takes the playlist ID, a field name, an item to add to the field.
"""
sql = f"SELECT {field} FROM playlists WHERE id = ?"
@@ -103,6 +115,7 @@ class SQLitePlaylistMethods:
if data is not None:
db_items: list[str] = json.loads(data[0])
# Remove duplicates, without changing the order.
for item in items:
if item in db_items:
items.remove(item)
@@ -113,36 +126,28 @@ class SQLitePlaylistMethods:
cur.execute(sql, (json.dumps(db_items), playlist_id))
return len(items)
cls.update_last_updated(playlist_id)
@classmethod
def add_tracks_to_playlist(cls, playlist_id: int, trackhashes: list[str]):
"""
Adds trackhashes to a playlist
"""
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):
def update_playlist(cls, playlist_id: int, playlist: dict):
sql = """UPDATE playlists SET
has_gif = ?,
image = ?,
last_updated = ?,
name = ?
name = ?,
settings = ?
WHERE id = ?
"""
del playlist["id"]
del playlist["trackhashes"]
del playlist["artisthashes"]
del playlist["banner_pos"]
playlist["settings"] = json.dumps(playlist["settings"])
playlist = OrderedDict(sorted(playlist.items()))
params = (*playlist.values(), playlist_id)
@@ -150,13 +155,16 @@ class SQLitePlaylistMethods:
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, params)
@staticmethod
def update_last_updated(playlist_id: int):
"""Updates the last updated date of a playlist."""
sql = """UPDATE playlists SET last_updated = ? WHERE id = ?"""
cls.update_last_updated(playlist_id)
@classmethod
def update_settings(cls, playlist_id: int, settings: dict):
sql = """UPDATE playlists SET settings = ? WHERE id = ?"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (create_new_date(), playlist_id))
cur.execute(sql, (json.dumps(settings), playlist_id))
cls.update_last_updated(playlist_id)
@staticmethod
def delete_playlist(pid: str):
@@ -166,8 +174,40 @@ class SQLitePlaylistMethods:
cur.execute(sql, (pid,))
@staticmethod
def update_banner_pos(playlistid: int, pos: int):
sql = """UPDATE playlists SET banner_pos = ? WHERE id = ?"""
def remove_banner(playlistid: int):
sql = """UPDATE playlists SET image = NULL WHERE id = ?"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (pos, playlistid))
cur.execute(sql, (playlistid,))
@classmethod
def remove_tracks_from_playlist(cls, playlistid: int, tracks: list[dict[str, int]]):
"""
Removes tracks from a playlist by trackhash and position.
"""
sql = """UPDATE playlists SET trackhashes = ? WHERE id = ?"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute("SELECT trackhashes FROM playlists WHERE id = ?", (playlistid,))
data = cur.fetchone()
if data is None:
return
trackhashes: list[str] = json.loads(data[0])
for track in tracks:
# {
# trackhash: str;
# index: int;
# }
index = trackhashes.index(track["trackhash"])
if index == track["index"]:
trackhashes.remove(track["trackhash"])
cur.execute(sql, (json.dumps(trackhashes), playlistid))
cls.update_last_updated(playlistid)
+30 -14
View File
@@ -3,15 +3,16 @@ This file contains the SQL queries to create the database tables.
"""
# banner_pos integer NOT NULL,
# has_gif integer,
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,
settings text,
trackhashes text
);
@@ -24,8 +25,22 @@ CREATE TABLE IF NOT EXISTS favorites (
CREATE TABLE IF NOT EXISTS settings (
id integer PRIMARY KEY,
root_dirs text NOT NULL,
exclude_dirs text
)
exclude_dirs text,
artist_separators text NOT NULL default '/,;',
extract_feat integer NOT NULL DEFAULT 1,
remove_prod integer NOT NULL DEFAULT 1,
clean_album_title integer NOT NULL DEFAULT 1,
remove_remaster integer NOT NULL DEFAULT 1,
merge_albums integer NOT NULL DEFAULT 0,
show_albums_as_singles integer NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS lastfm_similar_artists (
id integer PRIMARY KEY,
artisthash text NOT NULL,
similar_artists text NOT NULL,
UNIQUE (artisthash)
);
"""
CREATE_APPDB_TABLES = """
@@ -37,7 +52,7 @@ CREATE TABLE IF NOT EXISTS tracks (
artist text NOT NULL,
bitrate integer NOT NULL,
copyright text,
date text NOT NULL,
date integer NOT NULL,
disc integer NOT NULL,
duration integer NOT NULL,
filepath text NOT NULL,
@@ -46,6 +61,7 @@ CREATE TABLE IF NOT EXISTS tracks (
title text NOT NULL,
track integer NOT NULL,
trackhash text NOT NULL,
last_mod float NOT NULL,
UNIQUE (filepath)
);
@@ -56,8 +72,6 @@ CREATE TABLE IF NOT EXISTS albums (
UNIQUE (albumhash)
);
CREATE TABLE IF NOT EXISTS artists (
id integer PRIMARY KEY,
artisthash text NOT NULL,
@@ -73,14 +87,16 @@ CREATE TABLE IF NOT EXISTS folders (
);
"""
# changed from migrations to dbmigrations in v1.3.0
# to avoid conflicts with the previous migrations.
CREATE_MIGRATIONS_TABLE = """
CREATE TABLE IF NOT EXISTS migrations (
CREATE TABLE IF NOT EXISTS dbmigrations (
id integer PRIMARY KEY,
pre_init_version integer NOT NULL DEFAULT 0,
post_init_version integer NOT NULL DEFAULT 0
version integer NOT NULL DEFAULT 0
);
INSERT INTO migrations (pre_init_version, post_init_version)
SELECT 0, 0
WHERE NOT EXISTS (SELECT 1 FROM migrations);
INSERT INTO dbmigrations (version)
SELECT -1
WHERE NOT EXISTS (SELECT 1 FROM dbmigrations);
"""
+61
View File
@@ -1,4 +1,8 @@
from pprint import pprint
from typing import Any
from app.db.sqlite.utils import SQLiteManager
from app.settings import SessionVars
from app.utils.wintools import win_replace_slash
@@ -7,6 +11,26 @@ class SettingsSQLMethods:
Methods for interacting with the settings table.
"""
@staticmethod
def get_all_settings():
"""
Gets all settings from the database.
"""
sql = "SELECT * FROM settings WHERE id = 1"
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql)
settings = cur.fetchone()
cur.close()
# if root_dirs not set
if settings is None:
return []
# omit id, root_dirs, and exclude_dirs
return settings[3:]
@staticmethod
def get_root_dirs() -> list[str]:
"""
@@ -18,6 +42,7 @@ class SettingsSQLMethods:
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql)
dirs = cur.fetchall()
cur.close()
dirs = [_dir[0] for _dir in dirs]
return [win_replace_slash(d) for d in dirs]
@@ -87,3 +112,39 @@ class SettingsSQLMethods:
cur.execute(sql)
dirs = cur.fetchall()
return [_dir[0] for _dir in dirs]
@staticmethod
def get_settings() -> dict[str, Any]:
pass
@staticmethod
def set_setting(key: str, value: Any):
sql = f"UPDATE settings SET {key} = :value WHERE id = 1"
if type(value) == bool:
value = str(int(value))
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, {"value": value})
def load_settings():
s = SettingsSQLMethods.get_all_settings()
try:
db_separators: str = s[0]
db_separators = db_separators.replace(" ", "")
separators = db_separators.split(",")
separators = set(separators)
except IndexError:
separators = {";", "/"}
SessionVars.ARTIST_SEPARATORS = separators
# boolean settings
SessionVars.EXTRACT_FEAT = bool(s[1])
SessionVars.REMOVE_PROD = bool(s[2])
SessionVars.CLEAN_ALBUM_TITLE = bool(s[3])
SessionVars.REMOVE_REMASTER_FROM_TRACK = bool(s[4])
SessionVars.MERGE_ALBUM_VERSIONS = bool(s[5])
SessionVars.SHOW_ALBUMS_AS_SINGLES = bool(s[6])
+27 -6
View File
@@ -9,6 +9,7 @@ from sqlite3 import Cursor
from app.db.sqlite.utils import tuple_to_track, tuples_to_tracks
from .utils import SQLiteManager
from app.utils.unicode import handle_unicode
class SQLiteTrackMethods:
@@ -21,7 +22,7 @@ class SQLiteTrackMethods:
"""
Inserts a single track into the database.
"""
sql = """INSERT INTO tracks(
sql = """INSERT OR REPLACE INTO tracks(
album,
albumartist,
albumhash,
@@ -34,21 +35,37 @@ class SQLiteTrackMethods:
filepath,
folder,
genre,
last_mod,
title,
track,
trackhash
) VALUES(:album, :albumartist, :albumhash, :artist, :bitrate, :copyright,
:date, :disc, :duration, :filepath, :folder, :genre, :title, :track, :trackhash)
:date, :disc, :duration, :filepath, :folder, :genre, :last_mod, :title, :track, :trackhash)
"""
track = OrderedDict(sorted(track.items()))
cur.execute(sql, track)
track["artist"] = track["artists"]
track["albumartist"] = track["albumartists"]
del track["artists"]
del track["albumartists"]
try:
cur.execute(sql, track)
except UnicodeEncodeError:
# for each of the values in the track, call handle_unicode on it
for key, value in track.items():
track[key] = handle_unicode(value)
cur.execute(sql, track)
@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)
@@ -83,12 +100,16 @@ class SQLiteTrackMethods:
return None
@staticmethod
def remove_track_by_filepath(filepath: str):
def remove_tracks_by_filepaths(filepaths: str | set[str]):
"""
Removes a track from the database using its filepath.
Removes a track or tracks from the database using their filepaths.
"""
if isinstance(filepaths, str):
filepaths = {filepaths}
with SQLiteManager() as cur:
cur.execute("DELETE FROM tracks WHERE filepath=?", (filepath,))
for filepath in filepaths:
cur.execute("DELETE FROM tracks WHERE filepath=?", (filepath,))
@staticmethod
def remove_tracks_by_folders(folders: set[str]):
+26 -17
View File
@@ -5,9 +5,10 @@ Helper functions for use with the SQLite database.
import sqlite3
from sqlite3 import Connection, Cursor
import time
from typing import Optional
from app.models import Album, Playlist, Track
from app.settings import Db
from app import settings
def tuple_to_track(track: tuple):
@@ -61,14 +62,20 @@ class SQLiteManager:
for you. It also commits and closes the connection when you're done.
"""
def __init__(self, conn: Connection | None = None, userdata_db=False) -> None:
def __init__(
self,
conn: Optional[Connection] = None,
userdata_db=False,
test_db_path: str = None,
) -> 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.conn = conn
self.CLOSE_CONN = True
self.userdata_db = userdata_db
self.test_db_path = test_db_path
if conn:
self.conn = conn
@@ -78,10 +85,13 @@ class SQLiteManager:
if self.conn is not None:
return self.conn.cursor()
db_path = Db.get_app_db_path()
if self.test_db_path:
db_path = self.test_db_path
else:
db_path = settings.Db.get_app_db_path()
if self.userdata_db:
db_path = Db.get_userdata_db_path()
db_path = settings.Db.get_userdata_db_path()
self.conn = sqlite3.connect(
db_path,
@@ -90,19 +100,18 @@ class SQLiteManager:
return self.conn.cursor()
def __exit__(self, exc_type, exc_value, exc_traceback):
if self.conn:
trial_count = 0
trial_count = 0
while trial_count < 10:
try:
self.conn.commit()
while trial_count < 10:
try:
self.conn.commit()
if self.CLOSE_CONN:
self.conn.close()
if self.CLOSE_CONN:
self.conn.close()
return
except sqlite3.OperationalError:
trial_count += 1
time.sleep(3)
return
except sqlite3.OperationalError:
trial_count += 1
time.sleep(3)
self.conn.close()
self.conn.close()
+62
View File
@@ -0,0 +1,62 @@
from enum import Enum
class AlbumVersionEnum(Enum):
"""
Enum for album versions.
"""
Explicit = ("explicit",)
ANNIVERSARY_EDITION = ("anniversary",)
DIAMOND_EDITION = ("diamond",)
Centennial_EDITION = ("centennial",)
GOLDEN_EDITION = ("gold",)
PLATINUM_EDITION = ("platinum",)
SILVER_EDITION = ("silver",)
ULTIMATE_EDITION = ("ultimate",)
EXPANDED = ("expanded",)
EXTENDED = ("extended",)
DELUXE = ("deluxe",)
SUPER_DELUXE = ("super deluxe",)
COMPLETE = ("complete",)
LEGACY_EDITION = ("legacy",)
SPECIAL_EDITION = ("special",)
COLLECTORS_EDITION = ("collector",)
ARCHIVE_EDITION = ("archive",)
Acoustic = ("acoustic",)
instrumental = ("instrumental",)
DOUBLE_DISC = ("double disc", "double disk")
SUMMER_EDITION = ("summer",)
WINTER_EDITION = ("winter",)
SPRING_EDITION = ("spring",)
FALL_EDITION = ("fall",)
BONUS_EDITION = ("bonus",)
BONUS_TRACK = ("bonus track",)
ORIGINAL = ("original",)
INTL_VERSION = ("international",)
UK_VERSION = ("uk version",)
US_VERSION = ("us version",)
PARENTAL_ADVISORY = ("PA version",)
Limited_EDITION = ("limited",)
MONO = ("mono",)
STEREO = ("stereo",)
HI_RES = ("Hi-Res",)
RE_MIX = ("re-mix",)
RE_RECORDED = ("re-recorded", "rerecorded")
REISSUE = ("reissue",)
REMASTERED = ("remaster",)
def get_all_keywords():
return "|".join("|".join(i.value) for i in AlbumVersionEnum)
-40
View File
@@ -1,40 +0,0 @@
"""
This module contains functions for the server
"""
import time
from requests import ConnectionError as RequestConnectionError
from requests import ReadTimeout
from app.lib.artistlib import CheckArtistImages
from app.lib.populate import Populate, PopulateCancelledError
from app.lib.trackslib import validate_tracks
from app.logger import log
from app.utils.generators import get_random_str
from app.utils.network import Ping
from app.utils.threading import background
@background
def run_periodic_checks():
"""
Checks for new songs every N minutes.
"""
# ValidateAlbumThumbs()
# ValidatePlaylistThumbs()
validate_tracks()
while True:
try:
Populate(key=get_random_str())
except PopulateCancelledError:
pass
if Ping()():
try:
CheckArtistImages()
except (RequestConnectionError, ReadTimeout):
log.error(
"Internet connection lost. Downloading artist images stopped."
)
time.sleep(300)
+45 -1
View File
@@ -1,3 +1,47 @@
"""
Contains methods relating to albums.
"""
"""
from dataclasses import asdict
from typing import Any
from app.logger import log
from app.models.track import Track
from app.store.albums import AlbumStore
from app.store.tracks import TrackStore
def validate_albums():
"""
Removes albums that have no tracks.
Probably albums that were added from incompletely written files.
"""
album_hashes = {t.albumhash for t in TrackStore.tracks}
albums = AlbumStore.albums
for album in albums:
if album.albumhash not in album_hashes:
AlbumStore.remove_album(album)
def remove_duplicate_on_merge_versions(tracks: list[Track]) -> list[Track]:
"""
Removes duplicate tracks when merging versions of the same album.
"""
pass
def sort_by_track_no(tracks: list[Track]) -> list[dict[str, Any]]:
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
+62 -29
View File
@@ -1,19 +1,22 @@
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from io import BytesIO
from PIL import Image, UnidentifiedImageError
import requests
import os
import urllib
from concurrent.futures import ThreadPoolExecutor
from io import BytesIO
from pathlib import Path
from tqdm import tqdm
from requests.exceptions import ConnectionError as ReqConnError, ReadTimeout
import requests
from PIL import Image, UnidentifiedImageError
from requests.exceptions import ConnectionError as RequestConnectionError
from requests.exceptions import ReadTimeout
from app import settings
from app.models import Artist, Track, Album
from app.utils.hashing import create_hash
from app.models import Album, Artist, Track
from app.store import artists as artist_store
from app.utils.hashing import create_hash
from app.utils.progressbar import tqdm
CHECK_ARTIST_IMAGES_KEY = ""
def get_artist_image_link(artist: str):
@@ -36,7 +39,7 @@ def get_artist_image_link(artist: str):
return res["picture_big"]
return None
except (ReqConnError, ReadTimeout, IndexError, KeyError):
except (RequestConnectionError, ReadTimeout, IndexError, KeyError):
return None
@@ -73,24 +76,49 @@ class DownloadImage:
class CheckArtistImages:
def __init__(self):
with ThreadPoolExecutor() as pool:
list(
def __init__(self, instance_key: str):
global CHECK_ARTIST_IMAGES_KEY
CHECK_ARTIST_IMAGES_KEY = instance_key
# read all files in the artist image folder
path = settings.Paths.get_artist_img_sm_path()
processed = "".join(os.listdir(path)).replace("webp", "")
# filter out artists that already have an image
artists = filter(
lambda a: a.artisthash not in processed, artist_store.ArtistStore.artists
)
artists = list(artists)
# process the rest
key_artist_map = ((instance_key, artist) for artist in artists)
with ThreadPoolExecutor(max_workers=4) as executor:
res = list(
tqdm(
pool.map(self.download_image, artist_store.ArtistStore.artists),
total=len(artist_store.ArtistStore.artists),
desc="Downloading artist images",
executor.map(self.download_image, key_artist_map),
total=len(artists),
desc="Downloading missing artist images",
)
)
list(res)
@staticmethod
def download_image(artist: Artist):
def download_image(_map: tuple[str, Artist]):
"""
Checks if an artist image exists and downloads it if not.
:param artist: The artist name
"""
img_path = Path(settings.Paths.get_artist_img_sm_path()) / f"{artist.artisthash}.webp"
instance_key, artist = _map
if CHECK_ARTIST_IMAGES_KEY != instance_key:
return
img_path = (
Path(settings.Paths.get_artist_img_sm_path()) / f"{artist.artisthash}.webp"
)
if img_path.exists():
return
@@ -138,7 +166,7 @@ def get_artists_from_tracks(tracks: list[Track]) -> set[str]:
"""
artists = set()
master_artist_list = [[x.name for x in t.artist] for t in tracks]
master_artist_list = [[x.name for x in t.artists] for t in tracks]
artists = artists.union(*master_artist_list)
return artists
@@ -155,17 +183,22 @@ def get_albumartists(albums: list[Album]) -> set[str]:
return artists
def get_all_artists(
tracks: list[Track], albums: list[Album]
) -> list[Artist]:
def get_all_artists(tracks: list[Track], albums: list[Album]) -> list[Artist]:
artists_from_tracks = get_artists_from_tracks(tracks=tracks)
artist_from_albums = get_albumartists(albums=albums)
artists = list(artists_from_tracks.union(artist_from_albums))
artists = sorted(artists)
artists.sort()
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]
# Remove duplicates
artists_dup_free = set()
artist_hashes = set()
return [Artist(a) for a in artists]
for artist in artists:
artist_hash = create_hash(artist, decode=True)
if artist_hash not in artist_hashes:
artists_dup_free.add(artist)
artist_hashes.add(artist_hash)
return [Artist(a) for a in artists_dup_free]
+65 -30
View File
@@ -6,15 +6,20 @@ 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.albumcolors import SQLiteAlbumMethods as aldb
from app.db.sqlite.artistcolors import SQLiteArtistMethods as adb
from app.db.sqlite.utils import SQLiteManager
from app.store.artists import ArtistStore
from app.store.albums import AlbumStore
from app.logger import log
from app.lib.errors import PopulateCancelledError
from app.utils.progressbar import tqdm
PROCESS_ALBUM_COLORS_KEY = ""
PROCESS_ARTIST_COLORS_KEY = ""
def get_image_colors(image: str, count=1) -> list[str]:
@@ -34,7 +39,11 @@ def get_image_colors(image: str, count=1) -> list[str]:
def process_color(item_hash: str, is_album=True):
path = settings.Paths.get_sm_thumb_path() if is_album else settings.Paths.get_artist_img_sm_path()
path = (
settings.Paths.get_sm_thumb_path()
if is_album
else settings.Paths.get_artist_img_sm_path()
)
path = Path(path) / (item_hash + ".webp")
if not path.exists():
@@ -48,26 +57,42 @@ class ProcessAlbumColors:
Extracts the most dominant color from the album art and saves it to the database.
"""
def __init__(self) -> None:
albums = [a for a in AlbumStore.albums if len(a.colors) == 0]
def __init__(self, instance_key: str) -> None:
global PROCESS_ALBUM_COLORS_KEY
PROCESS_ALBUM_COLORS_KEY = instance_key
albums = [
a
for a in AlbumStore.albums
if a is not None and a.colors is not None and len(a.colors) == 0
]
with SQLiteManager() as cur:
for album in tqdm(albums, desc="Processing missing album colors"):
sql = "SELECT COUNT(1) FROM albums WHERE albumhash = ?"
cur.execute(sql, (album.albumhash,))
count = cur.fetchone()[0]
try:
for album in tqdm(albums, desc="Processing missing album colors"):
if PROCESS_ALBUM_COLORS_KEY != instance_key:
raise PopulateCancelledError(
"A newer 'ProcessAlbumColors' instance is running. Stopping this one."
)
if count != 0:
continue
# TODO: Stop hitting the database for every album.
# Instead, fetch all the data from the database and
# check from memory.
colors = process_color(album.albumhash)
exists = aldb.exists(album.albumhash, cur=cur)
if exists:
continue
if colors is None:
continue
colors = process_color(album.albumhash)
album.set_colors(colors)
color_str = json.dumps(colors)
db.insert_one_album(cur, album.albumhash, color_str)
if colors is None:
continue
album.set_colors(colors)
color_str = json.dumps(colors)
aldb.insert_one_album(cur, album.albumhash, color_str)
finally:
cur.close()
class ProcessArtistColors:
@@ -75,23 +100,33 @@ class ProcessArtistColors:
Extracts the most dominant color from the artist art and saves it to the database.
"""
def __init__(self) -> None:
def __init__(self, instance_key: str) -> None:
all_artists = [a for a in ArtistStore.artists if len(a.colors) == 0]
global PROCESS_ARTIST_COLORS_KEY
PROCESS_ARTIST_COLORS_KEY = instance_key
with SQLiteManager() as cur:
for artist in tqdm(all_artists, desc="Processing missing artist colors"):
sql = "SELECT COUNT(1) FROM artists WHERE artisthash = ?"
try:
for artist in tqdm(
all_artists, desc="Processing missing artist colors"
):
if PROCESS_ARTIST_COLORS_KEY != instance_key:
raise PopulateCancelledError(
"A newer 'ProcessArtistColors' instance is running. Stopping this one."
)
cur.execute(sql, (artist.artisthash,))
count = cur.fetchone()[0]
exists = adb.exists(artist.artisthash, cur=cur)
if count != 0:
continue
if exists:
continue
colors = process_color(artist.artisthash, is_album=False)
colors = process_color(artist.artisthash, is_album=False)
if colors is None:
continue
if colors is None:
continue
artist.set_colors(colors)
adb.insert_one_artist(cur, artist.artisthash, colors)
artist.set_colors(colors)
adb.insert_one_artist(cur, artist.artisthash, colors)
finally:
cur.close()
+7
View File
@@ -0,0 +1,7 @@
class PopulateCancelledError(Exception):
"""
Raised when the instance key of a looping function called
inside Populate is changed.
"""
pass
+3 -3
View File
@@ -19,7 +19,7 @@ def create_folder(path: str, count=0) -> Folder:
name=folder.name,
path=win_replace_slash(str(folder)),
is_sym=folder.is_symlink(),
count=count
count=count,
)
@@ -32,11 +32,11 @@ def get_folders(paths: list[str]):
for track in TrackStore.tracks:
for path in paths:
if track.filepath.startswith(path):
if track.folder.startswith(path):
count_dict[path] += 1
folders = [{"path": path, "count": count_dict[path]} for path in paths]
return [create_folder(f['path'], f['count']) for f in folders if f['count'] > 0]
return [create_folder(f["path"], f["count"]) for f in folders if f["count"] > 0]
class GetFilesAndDirs:
+14 -6
View File
@@ -16,7 +16,9 @@ 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.Paths.get_app_dir(), "images", "playlists", thumb_path)
full_thumb_path = os.path.join(
settings.Paths.get_app_dir(), "images", "playlists", thumb_path
)
aspect_ratio = image.width / image.height
@@ -33,7 +35,9 @@ 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.Paths.get_app_dir(), "images", "playlists", thumb_path)
full_thumb_path = os.path.join(
settings.Paths.get_app_dir(), "images", "playlists", thumb_path
)
frames = []
@@ -50,19 +54,22 @@ def create_gif_thumbnail(image: Any, img_path: str):
return thumb_path
def save_p_image(file, pid: str):
def save_p_image(
img: Image, pid: str, content_type: str = None, filename: str = None
) -> str:
"""
Saves a playlist banner image and returns the filepath.
"""
img = Image.open(file)
# img = Image.open(file)
random_str = "".join(random.choices(string.ascii_letters + string.digits, k=5))
filename = pid + str(random_str) + ".webp"
if not filename:
filename = pid + str(random_str) + ".webp"
full_img_path = os.path.join(settings.Paths.get_playlist_img_path(), filename)
if file.content_type == "image/gif":
if content_type == "image/gif":
frames = []
for frame in ImageSequence.Iterator(img):
@@ -78,6 +85,7 @@ def save_p_image(file, pid: str):
return filename
#
# class ValidatePlaylistThumbs:
# """
+235 -41
View File
@@ -1,31 +1,41 @@
import os
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from tqdm import tqdm
from typing import Generator
from requests import ConnectionError as RequestConnectionError
from requests import ReadTimeout
from app import settings
from app.db.sqlite.tracks import SQLiteTrackMethods
from app.db.sqlite.settings import SettingsSQLMethods as sdb
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.db.sqlite.lastfm.similar_artists import \
SQLiteLastFMSimilarArtists as lastfmdb
from app.db.sqlite.settings import SettingsSQLMethods as sdb
from app.db.sqlite.tracks import SQLiteTrackMethods
from app.lib.albumslib import validate_albums
from app.lib.artistlib import CheckArtistImages
from app.lib.colorlib import ProcessAlbumColors, ProcessArtistColors
from app.lib.errors import PopulateCancelledError
from app.lib.taglib import extract_thumb, get_tags
from app.lib.trackslib import validate_tracks
from app.logger import log
from app.models import Album, Artist, Track
from app.utils.filesystem import run_fast_scandir
from app.models.lastfm import SimilarArtist
from app.requests.artists import fetch_similar_artists
from app.store.albums import AlbumStore
from app.store.tracks import TrackStore
from app.store.artists import ArtistStore
from app.store.tracks import TrackStore
from app.utils.filesystem import run_fast_scandir
from app.utils.network import has_connection
from app.utils.progressbar import tqdm
get_all_tracks = SQLiteTrackMethods.get_all_tracks
insert_many_tracks = SQLiteTrackMethods.insert_many_tracks
remove_tracks_by_filepaths = SQLiteTrackMethods.remove_tracks_by_filepaths
POPULATE_KEY = ""
class PopulateCancelledError(Exception):
pass
class Populate:
"""
Populates the database with all songs in the music directory
@@ -34,20 +44,22 @@ class Populate:
also checks if the album art exists in the image path, if not tries to extract it.
"""
def __init__(self, key: str) -> None:
def __init__(self, instance_key: str) -> None:
global POPULATE_KEY
POPULATE_KEY = key
POPULATE_KEY = instance_key
validate_tracks()
validate_albums()
tracks = get_all_tracks()
tracks = list(tracks)
dirs_to_scan = sdb.get_root_dirs()
if len(dirs_to_scan) == 0:
log.warning(
(
"The root directory is not configured. "
+ "Open the app in your webbrowser to configure."
"The root directory is not configured. "
+ "Open the app in your webbrowser to configure."
)
)
return
@@ -58,32 +70,85 @@ class Populate:
except IndexError:
pass
files = []
files = set()
for _dir in dirs_to_scan:
files.extend(run_fast_scandir(_dir, full=True)[1])
files = files.union(run_fast_scandir(_dir, full=True)[1])
untagged = self.filter_untagged(tracks, files)
unmodified, modified_tracks = self.remove_modified(tracks)
untagged = files - unmodified
if len(untagged) == 0:
log.info("All clear, no unread files.")
if len(untagged) != 0:
self.tag_untagged(untagged, instance_key)
self.extract_thumb_with_overwrite(modified_tracks)
try:
ProcessTrackThumbnails(instance_key)
ProcessAlbumColors(instance_key)
ProcessArtistColors(instance_key)
except PopulateCancelledError as e:
log.warn(e)
return
self.tag_untagged(untagged, key)
tried_to_download_new_images = False
ProcessTrackThumbnails()
ProcessAlbumColors()
ProcessArtistColors()
if has_connection():
tried_to_download_new_images = True
try:
CheckArtistImages(instance_key)
except (RequestConnectionError, ReadTimeout) as e:
log.error(
"Internet connection lost. Downloading artist images stopped."
)
else:
log.warning(f"No internet connection. Downloading artist images stopped!")
# Re-process the new artist images.
if tried_to_download_new_images:
ProcessArtistColors(instance_key=instance_key)
if has_connection():
try:
FetchSimilarArtistsLastFM(instance_key)
except PopulateCancelledError as e:
log.warn(e)
return
ArtistStore.load_artists(instance_key)
@staticmethod
def filter_untagged(tracks: list[Track], files: list[str]):
tagged_files = [t.filepath for t in tracks]
return set(files) - set(tagged_files)
def remove_modified(tracks: Generator[Track, None, None]):
"""
Removes tracks from the database that have been modified
since they were added to the database.
"""
unmodified_paths = set()
modified_tracks: list[Track] = []
modified_paths = set()
for track in tracks:
try:
if track.last_mod == round(os.path.getmtime(track.filepath)):
unmodified_paths.add(track.filepath)
continue
except (FileNotFoundError, OSError) as e:
TrackStore.remove_track_obj(track)
remove_tracks_by_filepaths(track.filepath)
modified_paths.add(track.filepath)
modified_tracks.append(track)
TrackStore.remove_tracks_by_filepaths(modified_paths)
remove_tracks_by_filepaths(modified_paths)
return unmodified_paths, modified_tracks
@staticmethod
def tag_untagged(untagged: set[str], key: str):
log.info("Found %s new tracks", len(untagged))
tagged_tracks: list[dict] = []
tagged_tracks: deque[dict] = deque()
tagged_count = 0
fav_tracks = favdb.get_fav_tracks()
@@ -91,7 +156,8 @@ class Populate:
for file in tqdm(untagged, desc="Reading files"):
if POPULATE_KEY != key:
raise PopulateCancelledError("Populate key changed")
log.warning("'Populate.tag_untagged': Populate key changed")
return
tags = get_tags(file)
@@ -105,11 +171,11 @@ class Populate:
if not AlbumStore.album_exists(track.albumhash):
AlbumStore.add_album(AlbumStore.create_album(track))
for artist in track.artist:
for artist in track.artists:
if not ArtistStore.artist_exists(artist.artisthash):
ArtistStore.add_artist(Artist(artist.name))
for artist in track.albumartist:
for artist in track.albumartists:
if not ArtistStore.artist_exists(artist.artisthash):
ArtistStore.add_artist(Artist(artist.name))
@@ -118,27 +184,155 @@ class Populate:
log.warning("Could not read file: %s", file)
if len(tagged_tracks) > 0:
log.info("Adding %s tracks to database", len(tagged_tracks))
insert_many_tracks(tagged_tracks)
log.info("Added %s/%s tracks", tagged_count, len(untagged))
@staticmethod
def extract_thumb_with_overwrite(tracks: list[Track]):
"""
Extracts the thumbnail from a list of filepaths,
overwriting the existing thumbnail if it exists,
for modified files.
"""
for track in tracks:
try:
extract_thumb(track.filepath, track.image, overwrite=True)
except FileNotFoundError:
continue
def get_image(album: Album):
for track in TrackStore.tracks:
if track.albumhash == album.albumhash:
extract_thumb(track.filepath, track.image)
break
def get_image(_map: tuple[str, Album]):
"""
The function retrieves an image from an album by iterating through its tracks and extracting the thumbnail from the first track that has one.
:param album: An instance of the `Album` class representing the album to retrieve the image from.
:type album: Album
:return: None
"""
instance_key, album = _map
if POPULATE_KEY != instance_key:
raise PopulateCancelledError("'ProcessTrackThumbnails': Populate key changed")
matching_tracks = filter(
lambda t: t.albumhash == album.albumhash, TrackStore.tracks
)
try:
track = next(matching_tracks)
extracted = extract_thumb(track.filepath, track.image)
while not extracted:
try:
track = next(matching_tracks)
extracted = extract_thumb(track.filepath, track.image)
except StopIteration:
break
return
except StopIteration:
pass
CPU_COUNT = os.cpu_count() // 2
class ProcessTrackThumbnails:
def __init__(self) -> None:
with ThreadPoolExecutor(max_workers=4) as pool:
"""
Extracts the album art from all albums in album store.
"""
def __init__(self, instance_key: str) -> None:
"""
Filters out albums that already have thumbnails and
extracts the thumbnail for the other albums.
"""
path = settings.Paths.get_sm_thumb_path()
# read all the files in the thumbnail directory
processed = "".join(os.listdir(path)).replace("webp", "")
# filter out albums that already have thumbnails
albums = filter(
lambda album: album.albumhash not in processed, AlbumStore.albums
)
albums = list(albums)
# process the rest
key_album_map = ((instance_key, album) for album in albums)
with ThreadPoolExecutor(max_workers=CPU_COUNT) as executor:
results = list(
tqdm(
pool.map(get_image, AlbumStore.albums),
total=len(AlbumStore.albums),
executor.map(get_image, key_album_map),
total=len(albums),
desc="Extracting track images",
)
)
list(results)
def save_similar_artists(_map: tuple[str, Artist]):
"""
Downloads and saves similar artists to the database.
"""
instance_key, artist = _map
if POPULATE_KEY != instance_key:
raise PopulateCancelledError(
"'FetchSimilarArtistsLastFM': Populate key changed"
)
if lastfmdb.exists(artist.artisthash):
return
artist_hashes = fetch_similar_artists(artist.name)
artist_ = SimilarArtist(artist.artisthash, "~".join(artist_hashes))
if len(artist_.similar_artist_hashes) == 0:
return
lastfmdb.insert_one(artist_)
class FetchSimilarArtistsLastFM:
"""
Fetches similar artists from LastFM using a thread pool.
"""
def __init__(self, instance_key: str) -> None:
# read all artists from db
processed = lastfmdb.get_all()
processed = ".".join(a.artisthash for a in processed)
# filter out artists that already have similar artists
artists = filter(lambda a: a.artisthash not in processed, ArtistStore.artists)
artists = list(artists)
# process the rest
key_artist_map = ((instance_key, artist) for artist in artists)
with ThreadPoolExecutor(max_workers=CPU_COUNT) as executor:
try:
results = list(
tqdm(
executor.map(save_similar_artists, key_artist_map),
total=len(artists),
desc="Fetching similar artists",
)
)
list(results)
except PopulateCancelledError as e:
raise e
# any exception that can be raised by the pool
except Exception as e:
log.warn(e)
return
+177 -47
View File
@@ -1,21 +1,26 @@
"""
This library contains all the functions related to the search functionality.
"""
from typing import List, Generator, TypeVar, Any
import itertools
from typing import Any, Generator, List, TypeVar
from rapidfuzz import fuzz, process
from rapidfuzz import process, utils
from unidecode import unidecode
from app import models
from app.utils.remove_duplicates import remove_duplicates
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.models.enums import FavType
from app.models.track import Track
from app.serializers.album import serialize_for_card as serialize_album
from app.serializers.album import serialize_for_card_many as serialize_albums
from app.serializers.artist import serialize_for_cards
from app.serializers.track import serialize_track, serialize_tracks
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
from app.store.tracks import TrackStore
from app.utils.remove_duplicates import remove_duplicates
ratio = fuzz.ratio
wratio = fuzz.WRatio
# ratio = fuzz.ratio
# wratio = fuzz.WRatio
class Cutoff:
@@ -56,6 +61,7 @@ class SearchTracks:
track_titles,
score_cutoff=Cutoff.tracks,
limit=Limit.tracks,
processor=utils.default_process,
)
tracks = [self.tracks[i[2]] for i in results]
@@ -67,7 +73,7 @@ class SearchArtists:
self.query = query
self.artists = ArtistStore.artists
def __call__(self) -> list:
def __call__(self):
"""
Gets all artists with a given name.
"""
@@ -78,6 +84,7 @@ class SearchArtists:
artists,
score_cutoff=Cutoff.artists,
limit=Limit.artists,
processor=utils.default_process,
)
return [self.artists[i[2]] for i in results]
@@ -100,17 +107,11 @@ class SearchAlbums:
albums,
score_cutoff=Cutoff.albums,
limit=Limit.albums,
processor=utils.default_process,
)
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:
@@ -124,12 +125,13 @@ class SearchPlaylists:
playlists,
score_cutoff=Cutoff.playlists,
limit=Limit.playlists,
processor=utils.default_process,
)
return [self.playlists[i[2]] for i in results]
_type = List[models.Track | models.Album | models.Artist]
_type = models.Track | models.Album | models.Artist
_S2 = TypeVar("_S2")
_ResultType = int | float
@@ -140,7 +142,6 @@ def get_titles(items: _type):
text = item.og_title
elif isinstance(item, models.Album):
text = item.title
# print(text)
elif isinstance(item, models.Artist):
text = item.name
else:
@@ -149,7 +150,7 @@ def get_titles(items: _type):
yield text
class SearchAll:
class TopResults:
"""
Joins all tracks, albums and artists
then fuzzy searches them as a single unit.
@@ -157,11 +158,11 @@ class SearchAll:
@staticmethod
def collect_all():
all_items: _type = []
all_items: list[_type] = []
all_items.extend(ArtistStore.artists)
all_items.extend(TrackStore.tracks)
all_items.extend(AlbumStore.albums)
all_items.extend(ArtistStore.artists)
return all_items, get_titles(all_items)
@@ -170,44 +171,173 @@ class SearchAll:
items = list(items)
results = process.extract(
query=query,
choices=items,
score_cutoff=Cutoff.tracks,
limit=20
query=query, choices=items, score_cutoff=Cutoff.tracks, limit=1
)
return results
@staticmethod
def sort_results(items: _type):
def map_with_type(item: _type):
"""
Separates results into differrent lists using itertools.groupby.
Map the results to their respective types.
"""
mapped_items = [
{"type": "track", "item": item} if isinstance(item, models.Track) else
{"type": "album", "item": item} if isinstance(item, models.Album) else
{"type": "artist", "item": item} if isinstance(item, models.Artist) else
{"type": "Unknown", "item": item} for item in items
]
if isinstance(item, models.Track):
return {"type": "track", "item": item}
mapped_items.sort(key=lambda x: x["type"])
if isinstance(item, models.Album):
tracks = TrackStore.get_tracks_by_albumhash(item.albumhash)
tracks = remove_duplicates(tracks)
groups = [
list(group) for key, group in
itertools.groupby(mapped_items, lambda x: x["type"])
]
item.get_date_from_tracks(tracks)
try:
item.duration = sum((t.duration for t in tracks))
except AttributeError:
item.duration = 0
# merge items of a group into a dict that looks like: {"albums": [album1, ...]}
groups = [
{f"{group[0]['type']}s": [i['item'] for i in group]} for group in groups
]
item.check_is_single(tracks)
return groups
if not item.is_single:
item.check_type()
item.is_favorite = favdb.check_is_favorite(
item.albumhash, fav_type=FavType.album
)
return {"type": "album", "item": item}
if isinstance(item, models.Artist):
track_count = 0
duration = 0
for track in TrackStore.get_tracks_by_artisthash(item.artisthash):
track_count += 1
duration += track.duration
album_count = AlbumStore.count_albums_by_artisthash(item.artisthash)
item.set_trackcount(track_count)
item.set_albumcount(album_count)
item.set_duration(duration)
return {"type": "artist", "item": item}
@staticmethod
def search(query: str):
items, titles = SearchAll.collect_all()
results = SearchAll.get_results(titles, query)
results = [items[i[2]] for i in results]
def get_track_items(item: dict[str, _type], query: str, limit=5):
tracks: list[Track] = []
return SearchAll.sort_results(results)
if item["type"] == "track":
tracks.extend(SearchTracks(query)())
if item["type"] == "album":
t = TrackStore.get_tracks_by_albumhash(item["item"].albumhash)
t.sort(key=lambda x: x.last_mod)
# if there are less than the limit, get more tracks
if len(t) < limit:
remainder = limit - len(t)
more_tracks = SearchTracks(query)()
t.extend(more_tracks[:remainder])
tracks.extend(t)
if item["type"] == "artist":
t = TrackStore.get_tracks_by_artisthash(item["item"].artisthash)
# if there are less than the limit, get more tracks
if len(t) < limit:
remainder = limit - len(t)
more_tracks = SearchTracks(query)()
t.extend(more_tracks[:remainder])
tracks.extend(t)
return tracks[:limit]
@staticmethod
def get_album_items(item: dict[str, _type], query: str, limit=6):
if item["type"] == "track":
return SearchAlbums(query)()[:limit]
if item["type"] == "album":
return SearchAlbums(query)()[:limit]
if item["type"] == "artist":
albums = AlbumStore.get_albums_by_artisthash(item["item"].artisthash)
# if there are less than the limit, get more albums
if len(albums) < limit:
remainder = limit - len(albums)
more_albums = SearchAlbums(query)()
albums.extend(more_albums[:remainder])
return albums[:limit]
@staticmethod
def search(
query: str,
limit: int = None,
albums_only=False,
tracks_only=False,
in_quotes=False,
):
items, titles = TopResults.collect_all()
results = TopResults.get_results(titles, query)
tracks_limit = Limit.tracks if tracks_only else 4
albums_limit = Limit.albums if albums_only else limit
artists_limit = limit
# map results to their respective items
try:
result = [items[i[2]] for i in results][0]
except IndexError:
if tracks_only:
return []
if albums_only:
return []
return {
"top_result": None,
"tracks": [],
"artists": [],
"albums": [],
}
result = TopResults.map_with_type(result)
if in_quotes:
top_tracks = SearchTracks(query)()[:tracks_limit]
else:
top_tracks = TopResults.get_track_items(result, query, limit=tracks_limit)
top_tracks = serialize_tracks(top_tracks)
if tracks_only:
return top_tracks
if in_quotes:
albums = SearchAlbums(query)()[:albums_limit]
else:
albums = TopResults.get_album_items(result, query, limit=albums_limit)
albums = serialize_albums(albums)
if albums_only:
return albums
artists = SearchArtists(query)()[:artists_limit]
artists = serialize_for_cards(artists)
if result["type"] == "track":
result["item"] = serialize_track(result["item"])
if result["type"] == "album":
result["item"] = serialize_album(result["item"])
return {
"top_result": result,
"tracks": top_tracks,
"artists": artists,
"albums": albums,
}
+48 -15
View File
@@ -1,13 +1,13 @@
import datetime
import os
from io import BytesIO
import pendulum
from PIL import Image, UnidentifiedImageError
from tinytag import TinyTag
from app.settings import Defaults, Paths
from app.utils.hashing import create_hash
from app.utils.parsers import parse_title_from_filename, parse_artist_from_filename
from app.utils.parsers import parse_artist_from_filename, parse_title_from_filename
from app.utils.wintools import win_replace_slash
@@ -23,22 +23,32 @@ def parse_album_art(filepath: str):
return None
def extract_thumb(filepath: str, webp_path: str) -> bool:
def extract_thumb(filepath: str, webp_path: str, overwrite=False) -> bool:
"""
Extracts the thumbnail from an audio file. Returns the path to the thumbnail.
Extracts the thumbnail from an audio file.
Returns the path to the thumbnail.
"""
img_path = os.path.join(Paths.get_lg_thumb_path(), webp_path)
original_img_path = os.path.join(Paths.get_original_thumb_path(), webp_path)
lg_img_path = os.path.join(Paths.get_lg_thumb_path(), webp_path)
sm_img_path = os.path.join(Paths.get_sm_thumb_path(), webp_path)
tsize = Defaults.THUMB_SIZE
sm_tsize = Defaults.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")
width, height = img.size
ratio = width / height
if os.path.exists(img_path):
img_size = os.path.getsize(img_path)
img.save(original_img_path, "webp")
img.resize((tsize, int(tsize / ratio)), Image.ANTIALIAS).save(
lg_img_path, "webp"
)
img.resize((sm_tsize, int(sm_tsize / ratio)), Image.ANTIALIAS).save(
sm_img_path, "webp"
)
if not overwrite and os.path.exists(sm_img_path):
img_size = os.path.getsize(sm_img_path)
if img_size > 0:
return True
@@ -64,18 +74,30 @@ def extract_thumb(filepath: str, webp_path: str) -> bool:
return False
def extract_date(date_str: str | None, filepath: str) -> int:
def parse_date(date_str: str | None) -> int | None:
"""
Extracts the date from a string and returns a timestamp.
"""
try:
return int(date_str.split("-")[0])
except: # pylint: disable=bare-except
# TODO: USE FILEPATH LAST-MOD DATE instead of current date
return datetime.date.today().today().year
date = pendulum.parse(date_str, strict=False)
return int(date.timestamp())
except Exception as e:
return None
def get_tags(filepath: str):
"""
Returns the tags for a given audio file.
"""
filetype = filepath.split(".")[-1]
filename = (filepath.split("/")[-1]).replace(f".{filetype}", "")
try:
last_mod = round(os.path.getmtime(filepath))
except FileNotFoundError:
return None
try:
tags = TinyTag.get(filepath)
except: # noqa: E722
@@ -141,9 +163,17 @@ def get_tags(filepath: str):
tags.image = f"{tags.albumhash}.webp"
tags.folder = win_replace_slash(os.path.dirname(filepath))
tags.date = extract_date(tags.year, filepath)
tags.date = parse_date(tags.year) or int(last_mod)
tags.filepath = win_replace_slash(filepath)
tags.filetype = filetype
tags.last_mod = last_mod
tags.artists = tags.artist
tags.albumartists = tags.albumartist
# sub underscore with space
tags.title = tags.title.replace("_", " ")
tags.album = tags.album.replace("_", " ")
tags = tags.__dict__
@@ -163,6 +193,9 @@ def get_tags(filepath: str):
"samplerate",
"track_total",
"year",
"bitdepth",
"artist",
"albumartist",
]
for tag in to_delete:
+5 -7
View File
@@ -3,18 +3,16 @@ This library contains all the functions related to tracks.
"""
import os
from tqdm import tqdm
from app.db.sqlite.tracks import SQLiteTrackMethods as tdb
from app.store.tracks import TrackStore
from app.utils.progressbar import tqdm
def validate_tracks() -> None:
"""
Gets all songs under the ~/ directory.
Removes track records whose files no longer exist.
"""
for track in tqdm(TrackStore.tracks, desc="Removing deleted tracks"):
for track in tqdm(TrackStore.tracks, desc="Validating tracks"):
if not os.path.exists(track.filepath):
print(f"Removing {track.filepath}")
TrackStore.tracks.remove(track)
tdb.remove_track_by_filepath(track.filepath)
TrackStore.remove_track_obj(track)
tdb.remove_tracks_by_filepaths(track.filepath)
+89 -28
View File
@@ -1,6 +1,7 @@
"""
This library contains the classes and functions related to the watchdog file watcher.
"""
import json
import os
import sqlite3
import time
@@ -8,18 +9,18 @@ import time
from watchdog.events import PatternMatchingEventHandler
from watchdog.observers import Observer
from app.logger import log
from app.lib.taglib import get_tags
from app.models import Artist, Track
from app import settings
from app.db.sqlite.albumcolors import SQLiteAlbumMethods as aldb
from app.db.sqlite.settings import SettingsSQLMethods as sdb
from app.db.sqlite.tracks import SQLiteManager
from app.db.sqlite.tracks import SQLiteTrackMethods as db
from app.db.sqlite.settings import SettingsSQLMethods as sdb
from app.store.tracks import TrackStore
from app.lib.colorlib import process_color
from app.lib.taglib import extract_thumb, get_tags
from app.logger import log
from app.models import Artist, Track
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
from app.store.tracks import TrackStore
class Watcher:
@@ -94,7 +95,7 @@ class Watcher:
)
return
except OSError as e:
log.error('Failed to start watchdog. %s', e)
log.error("Failed to start watchdog. %s", e)
return
try:
@@ -125,34 +126,64 @@ class Watcher:
self.run()
def handle_colors(cur: sqlite3.Cursor, albumhash: str):
exists = aldb.exists(albumhash, cur)
if exists:
return
colors = process_color(albumhash, is_album=True)
if colors is None:
return
aldb.insert_one_album(cur=cur, albumhash=albumhash, colors=json.dumps(colors))
return colors
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.
"""
TrackStore.remove_track_by_filepath(filepath)
tags = get_tags(filepath)
if tags is None:
# if the track is somehow invalid, return
if tags is None or tags["bitrate"] == 0 or tags["duration"] == 0:
return
colors = None
with SQLiteManager() as cur:
db.remove_track_by_filepath(tags["filepath"])
db.insert_one_track(tags, cur)
extracted = extract_thumb(filepath, tags["albumhash"] + ".webp")
if not extracted:
return
colors = handle_colors(cur, tags["albumhash"])
track = Track(**tags)
TrackStore.add_track(track)
if not AlbumStore.album_exists(track.albumhash):
album = AlbumStore.create_album(track)
album.set_colors(colors)
AlbumStore.add_album(album)
artists: list[Artist] = track.artist + track.albumartist # type: ignore
artists: list[Artist] = track.artists + track.albumartists # type: ignore
for artist in artists:
if not ArtistStore.artist_exists(artist.artisthash):
ArtistStore.add_artist(Artist(artist.name))
extract_thumb(filepath, track.image, overwrite=True)
def remove_track(filepath: str) -> None:
"""
@@ -163,15 +194,15 @@ def remove_track(filepath: str) -> None:
except IndexError:
return
db.remove_track_by_filepath(filepath)
db.remove_tracks_by_filepaths(filepath)
TrackStore.remove_track_by_filepath(filepath)
empty_album = TrackStore.count_tracks_by_hash(track.albumhash) > 0
empty_album = TrackStore.count_tracks_by_trackhash(track.albumhash) > 0
if empty_album:
AlbumStore.remove_album_by_hash(track.albumhash)
artists: list[Artist] = track.artist + track.albumartist # type: ignore
artists: list[Artist] = track.artists + track.albumartists # type: ignore
for artist in artists:
empty_artist = not ArtistStore.artist_has_tracks(artist.artisthash)
@@ -183,6 +214,7 @@ def remove_track(filepath: str) -> None:
class Handler(PatternMatchingEventHandler):
files_to_process = []
files_to_process_windows = []
file_sizes = {}
root_dirs = []
dir_map = []
@@ -212,6 +244,11 @@ class Handler(PatternMatchingEventHandler):
"""
Fired when a supported file is created.
"""
try:
self.file_sizes[event.src_path] = os.path.getsize(event.src_path)
except FileNotFoundError:
return
self.files_to_process.append(event.src_path)
self.files_to_process_windows.append(event.src_path)
@@ -253,7 +290,11 @@ class Handler(PatternMatchingEventHandler):
if os.path.getsize(event.src_path) > 0:
path = self.get_abs_path(event.src_path)
add_track(path)
except FileNotFoundError:
# file was closed and deleted.
pass
except ValueError:
# file was removed from the list by another event handler.
pass
def on_modified(self, event):
@@ -264,18 +305,38 @@ class Handler(PatternMatchingEventHandler):
if event.src_path not in self.files_to_process_windows:
return
file_size = -1
while file_size != os.path.getsize(event.src_path):
file_size = os.path.getsize(event.src_path)
time.sleep(0.1)
# Check if file write operation is complete
try:
os.rename(event.src_path, event.src_path)
path = self.get_abs_path(event.src_path)
remove_track(path)
add_track(path)
self.files_to_process_windows.remove(event.src_path)
except OSError:
# File is locked, skipping
pass
current_size = os.path.getsize(event.src_path)
except FileNotFoundError:
# File was deleted or moved
return
previous_size = self.file_sizes.get(event.src_path, -1)
if current_size == previous_size:
# Wait for a short duration to ensure the file write operation is complete
time.sleep(5)
# Check the file size again
try:
current_size = os.path.getsize(event.src_path)
except FileNotFoundError:
# File was deleted or moved
return
if current_size == previous_size:
try:
os.rename(event.src_path, event.src_path)
path = self.get_abs_path(event.src_path)
remove_track(path)
add_track(path)
self.files_to_process_windows.remove(event.src_path)
del self.file_sizes[event.src_path]
except OSError:
# File is locked
pass
return
# Update the file size for the next iteration
self.file_sizes[event.src_path] = current_size
+1
View File
@@ -2,6 +2,7 @@
Logger module
"""
from app.settings import IS_BUILD
import logging
+28 -21
View File
@@ -6,14 +6,28 @@ Reads and applies the latest database migrations.
PLEASE NOTE: OLDER MIGRATIONS CAN NEVER BE DELETED.
ONLY MODIFY OLD MIGRATIONS FOR BUG FIXES OR ENHANCEMENTS ONLY
[TRY NOT TO MODIFY BEHAVIOR, UNLESS YOU KNOW WHAT YOU'RE DOING].
PS: Fuck that! Do what you want.
"""
from app.db.sqlite.migrations import MigrationManager
from app.logger import log
from app.migrations import v1_3_0
from app.migrations.base import Migration
from .main import main_db_migrations
from .userdata import userdata_db_migrations
migrations: list[list[Migration]] = [
[
# v1.3.0
v1_3_0.RemoveSmallThumbnailFolder,
v1_3_0.RemovePlaylistArtistHashes,
v1_3_0.AddSettingsToPlaylistTable,
v1_3_0.AddLastUpdatedToTrackTable,
v1_3_0.MovePlaylistsAndFavoritesTo10BitHashes,
v1_3_0.RemoveAllTracks,
v1_3_0.UpdateAppSettingsTable,
]
]
def apply_migrations():
@@ -21,24 +35,17 @@ def apply_migrations():
Applies the latest database migrations.
"""
userdb_version = MigrationManager.get_userdatadb_postinit_version()
maindb_version = MigrationManager.get_maindb_postinit_version()
version = MigrationManager.get_version()
for migration in main_db_migrations:
if migration.version > maindb_version:
log.info("Running new MAIN-DB post-init migration: %s", migration.name)
migration.migrate()
if version != len(migrations):
# run migrations after the previous migration version
for migration in migrations[(version - 1) :]:
for m in migration:
try:
m.migrate()
log.info("Applied migration: %s", m.__name__)
except:
log.error("Failed to run migration: %s", m.__name__)
for migration in userdata_db_migrations:
if migration.version > userdb_version:
log.info("Running new USERDATA-DB post-init migration: %s", migration.name)
migration.migrate()
def set_postinit_migration_versions():
"""
Sets the post-init migration versions.
"""
# TODO: Don't forget to remove the zeros below when you add a valid migration 👇.
MigrationManager.set_maindb_postinit_version(0)
MigrationManager.set_userdatadb_postinit_version(0)
# bump migration version
MigrationManager.set_version(len(migrations))
-41
View File
@@ -1,41 +0,0 @@
"""
Pre-init migrations are executed before the database is created.
Useful when you need to move files or folders before the database is created.
`Example use cases: Moving files around, dropping tables, etc.`
PLEASE NOTE: OLDER MIGRATIONS CAN NEVER BE DELETED.
ONLY MODIFY OLD MIGRATIONS FOR BUG FIXES OR ENHANCEMENTS ONLY.
[TRY NOT TO MODIFY BEHAVIOR, UNLESS YOU KNOW WHAT YOU'RE DOING].
"""
from sqlite3 import OperationalError
from app.db.sqlite.migrations import MigrationManager
from app.logger import log
from .drop_artist_and_album_color_tables import DropArtistAndAlbumColorTables
from .move_to_xdg_folder import MoveToXdgFolder
all_preinits = [MoveToXdgFolder, DropArtistAndAlbumColorTables]
def run_preinit_migrations():
"""
Runs all pre-init migrations.
"""
try:
userdb_version = MigrationManager.get_preinit_version()
except OperationalError:
userdb_version = 0
for migration in all_preinits:
if migration.version > userdb_version:
log.warn("Running new pre-init migration: %s", migration.name)
migration.migrate()
def set_preinit_migration_versions():
"""
Sets the migration versions.
"""
MigrationManager.set_preinit_version(all_preinits[-1].version)
@@ -1,24 +0,0 @@
"""
Another shot at attempting to fix duplicate album and artist color entries.
This release should finally fix the issue. The migration script will now remove
the album and artist color tables and recreate them.
"""
from app.db.sqlite.utils import SQLiteManager
from app.logger import log
class DropArtistAndAlbumColorTables:
version = 2
name = "DropArtistAndAlbumColorTables"
@staticmethod
def migrate():
with SQLiteManager() as cur:
tables = ["artists", "albums"]
for table in tables:
cur.execute(f"DROP TABLE IF EXISTS {table}")
cur.execute("VACUUM")
log.info("Deleted artist and album color data to fix a few bugs. ✅")
@@ -1,49 +0,0 @@
"""
This migration handles moving the config folder to the XDG standard location.
It also handles moving the userdata and the downloaded artist images to the new location.
"""
import os
import shutil
from app.settings import Paths
from app.logger import log
class MoveToXdgFolder:
version = 1
name = "MoveToXdgFolder"
@staticmethod
def migrate():
old_config_dir = os.path.join(Paths.USER_HOME_DIR, ".swing")
new_config_dir = Paths.get_app_dir()
if not os.path.exists(old_config_dir):
log.info("No old config folder found. Skipping migration.")
return
log.info("Found old config folder: %s", old_config_dir)
old_imgs_dir = os.path.join(old_config_dir, "images")
# move images to new location
if os.path.exists(old_imgs_dir):
shutil.copytree(
old_imgs_dir,
os.path.join(new_config_dir, "images"),
copy_function=shutil.copy2,
dirs_exist_ok=True,
)
log.warn("Moved artist images to: %s", new_config_dir)
# move userdata.db to new location
userdata_db = os.path.join(old_config_dir, "userdata.db")
if os.path.exists(userdata_db):
shutil.copy2(userdata_db, new_config_dir)
log.warn("Moved userdata.db to: %s", new_config_dir)
log.warn("Migration complete. ✅")
# swing.db is not moved because the new code fixes bugs which require
# the whole database to be recreated anyway. (ie. the bug which caused duplicate album and artist color entries)
+11
View File
@@ -0,0 +1,11 @@
class Migration:
"""
Base migration class.
"""
@staticmethod
def migrate():
"""
Code to run when migrating, override this method.
"""
pass
-10
View File
@@ -1,10 +0,0 @@
"""
Migrations for the main database.
PLEASE NOTE: OLDER MIGRATIONS CAN NEVER BE DELETED.
ONLY MODIFY OLD MIGRATIONS FOR BUG FIXES OR ENHANCEMENTS ONLY
[TRY NOT TO MODIFY BEHAVIOR, UNLESS YOU KNOW WHAT YOU'RE DOING].
"""
main_db_migrations = []
-10
View File
@@ -1,10 +0,0 @@
"""
Migrations for the userdata database.
PLEASE NOTE: OLDER MIGRATIONS CAN NEVER BE DELETED.
ONLY MODIFY OLD MIGRATIONS FOR BUG FIXES OR ENHANCEMENTS ONLY
[TRY NOT TO MODIFY BEHAVIOR, UNLESS YOU KNOW WHAT YOU'RE DOING].
"""
userdata_db_migrations = []
+314
View File
@@ -0,0 +1,314 @@
import json
import os
import shutil
import time
from collections import OrderedDict
from sqlite3 import OperationalError
from typing import Generator
from app.db.sqlite.utils import SQLiteManager
from app.migrations.base import Migration
from app.settings import Paths
from app.utils.decorators import coroutine
from app.utils.hashing import create_hash
from app.telemetry import Telemetry
from app.utils.threading import background
# playlists table
# ---------------
# 0: id
# 1: banner_pos
# 2: has_gif
# 3: image
# 4: last_updated
# 5: name
# 6: trackhashes
@background
def send_telemetry():
Telemetry.send_app_installed()
class RemoveSmallThumbnailFolder(Migration):
"""
Removes the small thumbnail folder.
Because we are added a new folder "original" in the same directory, and the small thumbs folder is used to check if an album's thumbnail is already extracted.
So we need to remove it, to force the app to extract thumbnails for all albums.
"""
@staticmethod
def migrate():
send_telemetry()
thumbs_sm_path = Paths.get_sm_thumb_path()
thumbs_lg_path = Paths.get_lg_thumb_path()
for path in [thumbs_sm_path, thumbs_lg_path]:
if os.path.exists(path):
shutil.rmtree(path)
for path in [thumbs_sm_path, thumbs_lg_path]:
os.makedirs(path, exist_ok=True)
class RemovePlaylistArtistHashes(Migration):
"""
removes the artisthashes column from the playlists table.
"""
@staticmethod
def migrate():
# remove artisthashes column
sql = "ALTER TABLE playlists DROP COLUMN artisthashes"
with SQLiteManager(userdata_db=True) as cur:
try:
cur.execute(sql)
except OperationalError:
pass
cur.close()
class AddSettingsToPlaylistTable(Migration):
"""
adds the settings column and removes the banner_pos and has_gif columns
to the playlists table.
"""
@staticmethod
def migrate():
select_playlists_sql = "SELECT * FROM playlists"
with SQLiteManager(userdata_db=True) as cur:
create_playlist_table_sql = """CREATE TABLE IF NOT EXISTS playlists (
id integer PRIMARY KEY,
image text,
last_updated text not null,
name text not null,
settings text,
trackhashes text
);"""
insert_playlist_sql = """INSERT INTO playlists(
image,
last_updated,
name,
settings,
trackhashes
) VALUES(:image, :last_updated, :name, :settings, :trackhashes)
"""
cur.execute(select_playlists_sql)
# load all playlists
playlists = cur.fetchall()
# drop old playlists table
cur.execute("DROP TABLE playlists")
# create new playlists table
cur.execute(create_playlist_table_sql)
def transform_playlists(pipeline: Generator, playlists: tuple):
for playlist in playlists:
# create dict that matches the new schema
p = {
"id": playlist[0],
"name": playlist[5],
"image": playlist[3],
"trackhashes": playlist[6],
"last_updated": playlist[4],
"settings": json.dumps(
{
"has_gif": False,
"banner_pos": playlist[1],
"square_img": False,
"pinned": False,
}
),
}
pipeline.send(p)
@coroutine
def insert_playlist():
while True:
playlist = yield
p = OrderedDict(sorted(playlist.items()))
cur.execute(insert_playlist_sql, p)
# insert playlists using a coroutine
# (my first coroutine)
pipeline = insert_playlist()
transform_playlists(pipeline, playlists)
pipeline.close()
cur.close()
class AddLastUpdatedToTrackTable(Migration):
"""
adds the last modified column to the tracks table.
"""
@staticmethod
def migrate():
# add last_mod column and default to current timestamp
timestamp = time.time()
sql = f"ALTER TABLE tracks ADD COLUMN last_mod text not null DEFAULT '{timestamp}'"
with SQLiteManager() as cur:
try:
cur.execute(sql)
except OperationalError:
pass
cur.close()
class MovePlaylistsAndFavoritesTo10BitHashes(Migration):
"""
moves the playlists and favorites to 10 bit hashes.
"""
@staticmethod
def migrate():
def get_track_data_by_hash(trackhash: str, tracks: list[tuple]) -> tuple:
for track in tracks:
# trackhash is the 15th bit hash
if track[15] == trackhash:
# return artist, album, title
return track[4], track[1], track[13]
def get_track_by_albumhash(albumhash: str, tracks: list[tuple]) -> tuple:
for track in tracks:
# albumhash is the 3rd bit hash
if track[3] == albumhash:
# return album, albumartist
return track[1], track[2]
_base = "SELECT * FROM"
fetch_playlists_sql = f"{_base} playlists"
fetch_tracks_sql = f"{_base} tracks"
update_playlist_hashes_sql = (
"UPDATE playlists SET trackhashes = :trackhashes WHERE id = :id"
)
fetch_favorites_sql = f"{_base} favorites"
update_fav_sql = "UPDATE favorites SET hash = :hash WHERE id = :id"
remove_fav_sql = "DELETE FROM favorites WHERE id = :id"
db_tracks = []
# read tracks from db
with SQLiteManager() as cur:
cur.execute(fetch_tracks_sql)
db_tracks.extend(cur.fetchall())
cur.close()
# update playlists
with SQLiteManager(userdata_db=True) as cur:
cur.execute(fetch_playlists_sql)
playlists = cur.fetchall()
# for each playlist
for p in playlists:
pid = p[0]
# load trackhashes
trackhashes: list[str] = json.loads(p[5])
for index, t in enumerate(trackhashes):
(artist, album, title) = get_track_data_by_hash(t, db_tracks)
# create new hash
new_hash = create_hash(artist, album, title, decode=True, limit=10)
trackhashes[index] = new_hash
# convert to string
trackhashes = json.dumps(trackhashes)
# save to db
cur.execute(
update_playlist_hashes_sql, {"trackhashes": trackhashes, "id": pid}
)
cur.close()
# update favorites
with SQLiteManager(userdata_db=True) as cur:
cur.execute(fetch_favorites_sql)
favorites = cur.fetchall()
# for each favorite
for f in favorites:
fid = f[0]
fhash: str = f[1]
ftype: str = f[2] # "track" || "album"
if ftype == "album":
(album, albumartist) = get_track_by_albumhash(fhash, db_tracks)
# create new hash
new_hash = create_hash(album, albumartist, decode=True, limit=10)
# save to db
cur.execute(update_fav_sql, {"hash": new_hash, "id": fid})
continue
if ftype == "track":
(artist, album, title) = get_track_data_by_hash(fhash, db_tracks)
# create new hash
new_hash = create_hash(artist, album, title, decode=True, limit=10)
# save to db
cur.execute(update_fav_sql, {"hash": new_hash, "id": fid})
continue
# remove favorites that are not track or album. ie. artists
cur.execute(remove_fav_sql, {"id": fid})
cur.close()
class RemoveAllTracks(Migration):
"""
removes all tracks from the tracks table.
"""
@staticmethod
def migrate():
sql = "DELETE FROM tracks"
with SQLiteManager() as cur:
cur.execute(sql)
cur.close()
class UpdateAppSettingsTable(Migration):
@staticmethod
def migrate():
drop_table_sql = "DROP TABLE settings"
create_table_sql = """
CREATE TABLE IF NOT EXISTS settings (
id integer PRIMARY KEY,
root_dirs text NOT NULL,
exclude_dirs text,
artist_separators text NOT NULL default '/,;',
extract_feat integer NOT NULL DEFAULT 1,
remove_prod integer NOT NULL DEFAULT 1,
clean_album_title integer NOT NULL DEFAULT 1,
remove_remaster integer NOT NULL DEFAULT 1,
merge_albums integer NOT NULL DEFAULT 0,
show_albums_as_singles integer NOT NULL DEFAULT 0
);
"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(drop_table_sql)
cur.execute(create_table_sql)
+79 -25
View File
@@ -1,12 +1,13 @@
import dataclasses
import datetime
from dataclasses import dataclass
from .track import Track
from .artist import Artist
from ..utils.hashing import create_hash
from ..utils.parsers import parse_feat_from_title
from app.settings import SessionVarKeys, get_flag
from app.settings import FromFlags
from ..utils.hashing import create_hash
from ..utils.parsers import get_base_title_and_versions, parse_feat_from_title
from .artist import Artist
from .track import Track
@dataclass(slots=True)
@@ -27,28 +28,56 @@ class Album:
date: str = ""
og_title: str = ""
base_title: str = ""
is_soundtrack: bool = False
is_compilation: bool = False
is_single: bool = False
is_EP: bool = False
is_favorite: bool = False
is_live: bool = False
genres: list[str] = dataclasses.field(default_factory=list)
versions: list[str] = dataclasses.field(default_factory=list)
def __post_init__(self):
self.og_title = self.title
self.image = self.albumhash + ".webp"
if FromFlags.EXTRACT_FEAT:
# Fetch album artists from title
if get_flag(SessionVarKeys.EXTRACT_FEAT):
featured, self.title = parse_feat_from_title(self.title)
if len(featured) > 0:
original_lower = "-".join([a.name.lower() for a in self.albumartists])
self.albumartists.extend([Artist(a) for a in featured if a.lower() not in original_lower])
self.albumartists.extend(
[Artist(a) for a in featured if a.lower() not in original_lower]
)
from ..store.tracks import TrackStore
TrackStore.append_track_artists(self.albumhash, featured, self.title)
# Handle album version data
if get_flag(SessionVarKeys.CLEAN_ALBUM_TITLE):
get_versions = not get_flag(SessionVarKeys.MERGE_ALBUM_VERSIONS)
self.title, self.versions = get_base_title_and_versions(
self.title, get_versions=get_versions
)
self.base_title = self.title
if "super_deluxe" in self.versions:
self.versions.remove("deluxe")
if "original" in self.versions and self.check_is_soundtrack():
self.versions.remove("original")
self.versions = [v.replace("_", " ") for v in self.versions]
else:
self.base_title = get_base_title_and_versions(
self.title, get_versions=False
)[0]
self.albumartists_hashes = "-".join(a.artisthash for a in self.albumartists)
def set_colors(self, colors: list[str]):
@@ -78,7 +107,7 @@ class Album:
"""
keywords = ["motion picture", "soundtrack"]
for keyword in keywords:
if keyword in self.title.lower():
if keyword in self.og_title.lower():
return True
return False
@@ -93,10 +122,19 @@ class Album:
if "various artists" in artists:
return True
substrings = [
"the essential", "best of", "greatest hits", "#1 hits", "number ones", "super hits",
"ultimate collection", "anthology", "great hits", "biggest hits", "the hits"
]
substrings = {
"the essential",
"best of",
"greatest hits",
"#1 hits",
"number ones",
"super hits",
"ultimate collection",
"anthology",
"great hits",
"biggest hits",
"the hits",
}
for substring in substrings:
if substring in self.title.lower():
@@ -108,9 +146,9 @@ class Album:
"""
Checks if the album is a live album.
"""
keywords = ["live from", "live at", "live in"]
keywords = ["live from", "live at", "live in", "live on", "mtv unplugged"]
for keyword in keywords:
if keyword in self.title.lower():
if keyword in self.og_title.lower():
return True
return False
@@ -122,27 +160,43 @@ class Album:
return self.title.strip().endswith(" EP")
def check_is_single(self, tracks: list[Track]):
"""
Checks if the album is a single.
"""
keywords = ["single version", "- single"]
show_albums_as_singles = get_flag(SessionVarKeys.SHOW_ALBUMS_AS_SINGLES)
for keyword in keywords:
if keyword in self.title.lower():
if keyword in self.og_title.lower():
self.is_single = True
return
if show_albums_as_singles and len(tracks) == 1:
self.is_single = True
return
if (
len(tracks) == 1
and create_hash(tracks[0].title) == create_hash(self.title) # if they have the same title
# and tracks[0].track == 1
# and tracks[0].disc == 1
# TODO: Review -> Are the above commented checks necessary?
len(tracks) == 1
and (
create_hash(tracks[0].title) == create_hash(self.title)
or create_hash(tracks[0].title) == create_hash(self.og_title)
) # if they have the same title
# and tracks[0].track == 1
# and tracks[0].disc == 1
# TODO: Review -> Are the above commented checks necessary?
):
self.is_single = True
def get_date_from_tracks(self, tracks: list[Track]):
for track in tracks:
if track.date != "Unknown":
self.date = track.date
break
"""
Gets the date of the album its tracks.
Args:
tracks (list[Track]): The tracks of the album.
"""
if self.date:
return
dates = (int(t.date) for t in tracks if t.date)
self.date = datetime.datetime.fromtimestamp(min(dates)).year
+4 -1
View File
@@ -1,4 +1,3 @@
import json
import dataclasses
from dataclasses import dataclass
@@ -20,6 +19,10 @@ class ArtistMinimal:
self.artisthash = create_hash(self.name, decode=True)
self.image = self.artisthash + ".webp"
# hack to override all the variations from unreleased files (sorry guys!)
if self.artisthash == "5a37d5315e":
self.name = "Juice WRLD"
@dataclass(slots=True)
class Artist(ArtistMinimal):
+13
View File
@@ -0,0 +1,13 @@
from dataclasses import dataclass
@dataclass
class SimilarArtist:
artisthash: str
similar_artist_hashes: str
def get_artist_hash_set(self) -> set[str]:
"""
Returns a set of similar artists.
"""
return set(self.similar_artist_hashes.split("~"))
+13 -10
View File
@@ -11,12 +11,10 @@ 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
settings: str | dict
trackhashes: str | list[str]
thumb: str = ""
@@ -24,16 +22,19 @@ class Playlist:
duration: int = 0
has_image: bool = False
images: list[str] = dataclasses.field(default_factory=list)
pinned: bool = False
def __post_init__(self):
self.trackhashes = json.loads(str(self.trackhashes))
# self.artisthashes = json.loads(str(self.artisthashes))
# commentted until we need it 👆
self.artisthashes = []
self.count = len(self.trackhashes)
self.has_gif = bool(int(self.has_gif))
self.has_image = (Path(settings.Paths.get_playlist_img_path()) / str(self.image)).exists()
if isinstance(self.settings, str):
self.settings = dict(json.loads(self.settings))
self.pinned = self.settings.get("pinned", False)
self.has_image = (
Path(settings.Paths.get_playlist_img_path()) / str(self.image)
).exists()
if self.image is not None:
self.thumb = "thumb_" + self.image
@@ -44,10 +45,12 @@ class Playlist:
def set_duration(self, duration: int):
self.duration = duration
def set_count(self, count: int):
self.count = count
def clear_lists(self):
"""
Removes data from lists to make it lighter for sending
over the API.
"""
self.trackhashes = []
self.artisthashes = []
+88 -27
View File
@@ -1,10 +1,16 @@
import dataclasses
from dataclasses import dataclass
from app.settings import FromFlags
from .artist import ArtistMinimal
from app.settings import SessionVarKeys, get_flag
from app.utils.hashing import create_hash
from app.utils.parsers import split_artists, remove_prod, parse_feat_from_title
from app.utils.parsers import (
clean_title,
get_base_title_and_versions,
parse_feat_from_title,
remove_prod,
split_artists,
)
from .artist import ArtistMinimal
@dataclass(slots=True)
@@ -14,12 +20,12 @@ class Track:
"""
album: str
albumartist: str | list[ArtistMinimal]
albumartists: str | list[ArtistMinimal]
albumhash: str
artist: str | list[ArtistMinimal]
artists: str | list[ArtistMinimal]
bitrate: int
copyright: str
date: str
date: int
disc: int
duration: int
filepath: str
@@ -28,46 +34,86 @@ class Track:
title: str
track: int
trackhash: str
last_mod: str | int
filetype: str = ""
image: str = ""
artist_hashes: str = ""
is_favorite: bool = False
# temporary attributes
_pos: int = 0 # for sorting tracks by disc and track number
_ati: str = "" # (album track identifier) for removing duplicates when merging album versions
og_title: str = ""
og_album: str = ""
def __post_init__(self):
self.og_title = self.title
self.og_album = self.album
self.last_mod = int(self.last_mod)
self.date = int(self.date)
if self.artist is not None:
artists = split_artists(self.artist)
if self.artists is not None:
artists = split_artists(self.artists)
new_title = self.title
if FromFlags.EXTRACT_FEAT:
if get_flag(SessionVarKeys.EXTRACT_FEAT):
featured, new_title = parse_feat_from_title(self.title)
original_lower = "-".join([a.lower() for a in artists])
artists.extend([a for a in featured if a.lower() not in original_lower])
original_lower = "-".join([create_hash(a) for a in artists])
artists.extend(
a for a in featured if create_hash(a) not in original_lower
)
if FromFlags.REMOVE_PROD:
self.artist_hashes = "-".join(create_hash(a, decode=True) for a in artists)
self.artists = [ArtistMinimal(a) for a in artists]
albumartists = split_artists(self.albumartists)
if not albumartists:
self.albumartists = self.artists[:1]
else:
self.albumartists = [ArtistMinimal(a) for a in albumartists]
if get_flag(SessionVarKeys.REMOVE_PROD):
new_title = remove_prod(new_title)
# if track is a single
if self.og_title == self.album:
self.album = new_title
self.rename_album(new_title)
if get_flag(SessionVarKeys.REMOVE_REMASTER_FROM_TRACK):
new_title = clean_title(new_title)
self.title = new_title
self.artist_hashes = "-".join(create_hash(a, decode=True) for a in artists)
self.artist = [ArtistMinimal(a) for a in artists]
if get_flag(SessionVarKeys.CLEAN_ALBUM_TITLE):
self.album, _ = get_base_title_and_versions(
self.album, get_versions=False
)
albumartists = split_artists(self.albumartist)
self.albumartist = [ArtistMinimal(a) for a in albumartists]
if get_flag(SessionVarKeys.MERGE_ALBUM_VERSIONS):
self.recreate_albumhash()
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("/", ",").replace(";", ",")
self.genre = str(self.genre).lower().split(",")
if self.genre is not None and self.genre != "":
self.genre = self.genre.lower()
separators = {"/", ";", "&"}
contains_rnb = "r&b" in self.genre
contains_rock = "rock & roll" in self.genre
if contains_rnb:
self.genre = self.genre.replace("r&b", "RnB")
if contains_rock:
self.genre = self.genre.replace("rock & roll", "rock")
for s in separators:
self.genre: str = self.genre.replace(s, ",")
self.genre = self.genre.split(",")
self.genre = [g.strip() for g in self.genre]
self.recreate_hash()
@@ -77,21 +123,36 @@ class Track:
Recreates a track hash if the track title was altered
to prevent duplicate tracks having different hashes.
"""
if self.og_title == self.title:
if self.og_title == self.title and self.og_album == self.album:
return
self.trackhash = create_hash(", ".join([a.name for a in self.artist]), self.album, self.title)
self.trackhash = create_hash(
", ".join(a.name for a in self.artists), self.og_album, self.title
)
def recreate_artists_hash(self):
self.artist_hashes = "-".join(a.artisthash for a in self.artist)
"""
Recreates a track's artist hashes if the artist list was altered
"""
self.artist_hashes = "-".join(a.artisthash for a in self.artists)
def recreate_albumhash(self):
"""
Recreates an albumhash of a track to merge all versions of an album.
"""
albumartists = (a.name for a in self.albumartists)
self.albumhash = create_hash(self.album, *albumartists)
def rename_album(self, new_album: str):
"""
Renames an album
"""
self.album = new_album
def add_artists(self, artists: list[str], new_album_title: str):
for artist in artists:
if create_hash(artist) not in self.artist_hashes:
self.artist.append(ArtistMinimal(artist))
if create_hash(artist, decode=True) not in self.artist_hashes:
self.artists.append(ArtistMinimal(artist))
self.recreate_artists_hash()
self.rename_album(new_album_title)
+32
View File
@@ -0,0 +1,32 @@
"""
This module contains functions for the server
"""
import time
from app.lib.populate import Populate, PopulateCancelledError
from app.settings import SessionVarKeys, get_flag, get_scan_sleep_time
from app.utils.generators import get_random_str
from app.utils.threading import background
from app.logger import log
@background
def run_periodic_scans():
"""
Runs periodic scans.
"""
# ValidateAlbumThumbs()
# ValidatePlaylistThumbs()
run_periodic_scan = True
while run_periodic_scan:
run_periodic_scan = get_flag(SessionVarKeys.DO_PERIODIC_SCANS)
try:
Populate(instance_key=get_random_str())
except PopulateCancelledError:
log.error("'run_periodic_scans': Periodic scan cancelled.")
pass
sleep_time = get_scan_sleep_time()
time.sleep(sleep_time)
+24 -11
View File
@@ -1,19 +1,32 @@
from app.settings import ALLARGS
from tabulate import tabulate
args = ALLARGS
help_args_list = [
["--help", "-h", "Show this help message"],
["--version", "-v", "Show the app version"],
["--host", "", "Set the host"],
["--port", "", "Set the port"],
["--config", "", "Set the config path"],
["--no-periodic-scan", "-nps", "Disable periodic scan"],
[
"--scan-interval",
"-psi",
"Set the scan interval in seconds. Default 600s (10 minutes)",
],
[
"--build",
"",
"Build the application (in development)",
],
]
HELP_MESSAGE = f"""
Usage: swingmusic [options]
Swing Music is a beautiful, self-hosted music player for your
local audio files. Like a cooler Spotify ... but bring your own music.
Options:
{args.build}: Build the application (in development)
{args.host}: Set the host
{args.port}: Set the port
{args.config}: Set the config path
{', '.join(args.show_feat)}: Do not extract featured artists from the song title
{', '.join(args.show_prod)}: Do not hide producers in the song title
Usage: swingmusic [options] [args]
{', '.join(args.help)}: Show this help message
{', '.join(args.version)}: Show the app version
{tabulate(help_args_list, headers=["Option", "Short", "Description"], tablefmt="simple_grid", maxcolwidths=[None, None, 40])}
"""
+13 -5
View File
@@ -5,20 +5,28 @@ import requests
from app import settings
from app.utils.hashing import create_hash
from requests import ConnectionError, HTTPError, ReadTimeout
import urllib.parse
def fetch_similar_artists(name: str):
"""
Fetches similar artists from Last.fm
"""
url = f"https://ws.audioscrobbler.com/2.0/?method=artist.getsimilar&artist={name}&api_key=" \
f"{settings.Keys.LASTFM_API}&format=json&limit=250"
url = f"https://ws.audioscrobbler.com/2.0/?method=artist.getsimilar&artist={urllib.parse.quote_plus(name, safe='')}&api_key={settings.Keys.LASTFM_API}&format=json&limit=250"
response = requests.get(url, timeout=10)
response.raise_for_status()
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
except (ConnectionError, ReadTimeout, HTTPError):
return []
data = response.json()
artists = data["similarartists"]["artist"]
try:
artists = data["similarartists"]["artist"]
except KeyError:
return []
for artist in artists:
yield create_hash(artist["name"])
View File
+33
View File
@@ -0,0 +1,33 @@
from dataclasses import asdict
from app.models import Album
def album_serializer(album: Album, to_remove: set[str]) -> dict:
album_dict = asdict(album)
to_remove.update(key for key in album_dict.keys() if key.startswith("is_"))
for key in to_remove:
album_dict.pop(key, None)
# remove artist images
for artist in album_dict["albumartists"]:
artist.pop("image", None)
return album_dict
def serialize_for_card(album: Album):
props_to_remove = {
"duration",
"count",
"albumartists_hashes",
"og_title",
"base_title",
"genres",
}
return album_serializer(album, props_to_remove)
def serialize_for_card_many(albums: list[Album]):
return [serialize_for_card(a) for a in albums]
+23
View File
@@ -0,0 +1,23 @@
from dataclasses import asdict
from app.models.artist import Artist
def serialize_for_card(artist: Artist):
artist_dict = asdict(artist)
props_to_remove = {
"is_favorite",
"trackcount",
"duration",
"albumcount",
}
for key in props_to_remove:
artist_dict.pop(key, None)
return artist_dict
def serialize_for_cards(artists: list[Artist]):
return [serialize_for_card(a) for a in artists]
+40
View File
@@ -0,0 +1,40 @@
from app.models import Album, Artist, Track
def recent_fav_track_serializer(track: Track) -> dict:
"""
Simplifies a track object into a dictionary to remove unused
properties on the client.
"""
return {
"image": track.image,
"title": track.title,
"trackhash": track.trackhash,
"filepath": track.filepath,
}
def recent_fav_album_serializer(album: Album) -> dict:
"""
Simplifies an album object into a dictionary to remove unused
properties on the client.
"""
return {
"image": album.image,
"title": album.og_title,
"albumhash": album.albumhash,
"artist": album.albumartists[0].name,
"colors": album.colors,
}
def recent_fav_artist_serializer(artist: Artist) -> dict:
"""
Simplifies an artist object into a dictionary to remove unused
properties on the client.
"""
return {
"image": artist.image,
"name": artist.name,
"artisthash": artist.artisthash,
}
+41
View File
@@ -0,0 +1,41 @@
from dataclasses import asdict
from app.models.track import Track
def serialize_track(track: Track, to_remove: set = {}, remove_disc=True) -> dict:
album_dict = asdict(track)
props = {
"date",
"genre",
"last_mod",
"og_title",
"og_album",
"copyright",
"disc",
"track",
"artist_hashes",
}.union(to_remove)
if not remove_disc:
props.remove("disc")
props.remove("track")
props.update(key for key in album_dict.keys() if key.startswith(("is_", "_")))
props.remove("is_favorite")
for key in props:
album_dict.pop(key, None)
to_remove_images = ["artists", "albumartists"]
for key in to_remove_images:
for artist in album_dict[key]:
artist.pop("image", None)
return album_dict
def serialize_tracks(
tracks: list[Track], _remove: set = {}, remove_disc=True
) -> list[dict]:
return [serialize_track(t, _remove, remove_disc) for t in tracks]
+102 -8
View File
@@ -2,12 +2,21 @@
Contains default configs
"""
import os
import sys
from typing import Any
from app import configs
join = os.path.join
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
IS_BUILD = True
else:
IS_BUILD = False
class Release:
APP_VERSION = "v1.2.1"
APP_VERSION = "1.3.0"
class Paths:
@@ -26,7 +35,9 @@ class Paths:
@classmethod
def get_config_folder(cls):
return "swingmusic" if cls.get_config_dir() != cls.USER_HOME_DIR else ".swingmusic"
return (
"swingmusic" if cls.get_config_dir() != cls.USER_HOME_DIR else ".swingmusic"
)
@classmethod
def get_app_dir(cls):
@@ -64,6 +75,10 @@ class Paths:
def get_lg_thumb_path(cls):
return join(cls.get_thumbs_path(), "large")
@classmethod
def get_original_thumb_path(cls):
return join(cls.get_thumbs_path(), "original")
@classmethod
def get_assets_path(cls):
return join(Paths.get_app_dir(), "assets")
@@ -71,7 +86,7 @@ class Paths:
# defaults
class Defaults:
THUMB_SIZE = 400
THUMB_SIZE = 500
SM_THUMB_SIZE = 64
SM_ARTIST_IMG_SIZE = 64
"""
@@ -105,6 +120,22 @@ class FLASKVARS:
FLASK_PORT = 1970
FLASK_HOST = "localhost"
@classmethod
def get_flask_port(cls):
return cls.FLASK_PORT
@classmethod
def get_flask_host(cls):
return cls.FLASK_HOST
@classmethod
def set_flask_port(cls, port):
cls.FLASK_PORT = port
@classmethod
def set_flask_host(cls, host):
cls.FLASK_HOST = host
class ALLARGS:
"""
@@ -115,13 +146,23 @@ class ALLARGS:
port = "--port"
host = "--host"
config = "--config"
show_feat = ["--show-feat", "-sf"]
show_prod = ["--show-prod", "-sp"]
help = ["--help", "-h"]
version = ["--version", "-v"]
show_feat = ("--show-feat", "-sf")
show_prod = ("--show-prod", "-sp")
dont_clean_albums = ("--no-clean-albums", "-nca")
dont_clean_tracks = ("--no-clean-tracks", "-nct")
no_periodic_scan = ("--no-periodic-scan", "-nps")
periodic_scan_interval = ("--scan-interval", "-psi")
help = ("--help", "-h")
version = ("--version", "-v")
class FromFlags:
class SessionVars:
"""
Variables that can be altered per session.
"""
EXTRACT_FEAT = True
"""
Whether to extract the featured artists from the song title.
@@ -132,6 +173,44 @@ class FromFlags:
Whether to remove the producers from the song title.
"""
CLEAN_ALBUM_TITLE = True
REMOVE_REMASTER_FROM_TRACK = True
DO_PERIODIC_SCANS = True
PERIODIC_SCAN_INTERVAL = 600 # 10 minutes
"""
The interval between periodic scans in seconds.
"""
MERGE_ALBUM_VERSIONS = False
ARTIST_SEPARATORS = set()
SHOW_ALBUMS_AS_SINGLES = False
# TODO: Find a way to eliminate this class without breaking typings
class SessionVarKeys:
EXTRACT_FEAT = "EXTRACT_FEAT"
REMOVE_PROD = "REMOVE_PROD"
CLEAN_ALBUM_TITLE = "CLEAN_ALBUM_TITLE"
REMOVE_REMASTER_FROM_TRACK = "REMOVE_REMASTER_FROM_TRACK"
DO_PERIODIC_SCANS = "DO_PERIODIC_SCANS"
PERIODIC_SCAN_INTERVAL = "PERIODIC_SCAN_INTERVAL"
MERGE_ALBUM_VERSIONS = "MERGE_ALBUM_VERSIONS"
ARTIST_SEPARATORS = "ARTIST_SEPARATORS"
SHOW_ALBUMS_AS_SINGLES = "SHOW_ALBUMS_AS_SINGLES"
def get_flag(key: SessionVarKeys) -> bool:
return getattr(SessionVars, key)
def set_flag(key: SessionVarKeys, value: Any):
setattr(SessionVars, key, value)
def get_scan_sleep_time() -> int:
return SessionVars.PERIODIC_SCAN_INTERVAL
class TCOLOR:
"""
@@ -153,3 +232,18 @@ class TCOLOR:
class Keys:
# get last fm api key from os environment
LASTFM_API = os.environ.get("LASTFM_API_KEY")
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY")
@classmethod
def load(cls):
if IS_BUILD:
cls.LASTFM_API = configs.LASTFM_API_KEY
cls.POSTHOG_API_KEY = configs.POSTHOG_API_KEY
cls.verify_keys()
@classmethod
def verify_keys(cls):
if not cls.LASTFM_API:
print("ERROR: LASTFM_API_KEY not set in environment")
sys.exit(0)
+15 -6
View File
@@ -1,12 +1,13 @@
"""
Prepares the server for use.
"""
from app.db.sqlite.settings import load_settings
from app.setup.files import create_config_dir
from app.setup.sqlite import setup_sqlite, run_migrations
from app.setup.sqlite import run_migrations, setup_sqlite
from app.store.albums import AlbumStore
from app.store.tracks import TrackStore
from app.store.artists import ArtistStore
from app.store.tracks import TrackStore
from app.utils.generators import get_random_str
def run_setup():
@@ -14,6 +15,14 @@ def run_setup():
setup_sqlite()
run_migrations()
TrackStore.load_all_tracks()
AlbumStore.load_albums()
ArtistStore.load_artists()
try:
load_settings()
except IndexError:
# settings table is empty
pass
instance_key = get_random_str()
TrackStore.load_all_tracks(instance_key)
AlbumStore.load_albums(instance_key)
ArtistStore.load_artists(instance_key)
+6 -13
View File
@@ -5,29 +5,20 @@ create the config directory and copy the assets to the app directory.
import os
import shutil
from configparser import ConfigParser
from app import settings
from app.utils.filesystem 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."""
"""
Copies assets to the app directory.
"""
def __init__(self) -> None:
assets_dir = "assets"
if IS_BUILD:
if settings.IS_BUILD:
assets_dir = get_home_res_path("assets")
files = [
@@ -63,6 +54,7 @@ def create_config_dir() -> None:
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")
original_thumb_path = os.path.join(thumb_path, "original")
artist_img_path = os.path.join("images", "artists")
small_artist_img_path = os.path.join(artist_img_path, "small")
@@ -76,6 +68,7 @@ def create_config_dir() -> None:
thumb_path,
small_thumb_path,
large_thumb_path,
original_thumb_path,
artist_img_path,
small_artist_img_path,
large_artist_img_path,
+11 -19
View File
@@ -4,12 +4,17 @@ Applies migrations.
"""
from app.db.sqlite import create_connection, create_tables, queries
from app.migrations import apply_migrations, set_postinit_migration_versions
from app.migrations.__preinit import run_preinit_migrations, set_preinit_migration_versions
from app.migrations import apply_migrations
from app.settings import Db
def run_migrations():
"""
Run migrations and updates migration version.
"""
apply_migrations()
def setup_sqlite():
"""
Create Sqlite databases and tables.
@@ -17,25 +22,12 @@ def setup_sqlite():
# if os.path.exists(DB_PATH):
# os.remove(DB_PATH)
run_preinit_migrations()
app_db_conn = create_connection(Db.get_app_db_path())
playlist_db_conn = create_connection(Db.get_userdata_db_path())
user_db_conn = create_connection(Db.get_userdata_db_path())
create_tables(app_db_conn, queries.CREATE_APPDB_TABLES)
create_tables(playlist_db_conn, queries.CREATE_USERDATA_TABLES)
create_tables(user_db_conn, queries.CREATE_USERDATA_TABLES)
create_tables(app_db_conn, queries.CREATE_MIGRATIONS_TABLE)
create_tables(playlist_db_conn, queries.CREATE_MIGRATIONS_TABLE)
app_db_conn.close()
playlist_db_conn.close()
def run_migrations():
"""
Run migrations and updates migration version.
"""
apply_migrations()
set_preinit_migration_versions()
set_postinit_migration_versions()
user_db_conn.close()
+6 -25
View File
@@ -1,6 +1,6 @@
import os
from app.settings import TCOLOR, Release, FLASKVARS, Paths, FromFlags
from app.settings import FLASKVARS, TCOLOR, Paths, Release
from app.utils.network import get_ip
@@ -12,39 +12,20 @@ def log_startup_info():
print(lines)
print(f"{TCOLOR.HEADER}SwingMusic {Release.APP_VERSION} {TCOLOR.ENDC}")
adresses = [FLASKVARS.FLASK_HOST]
adresses = [FLASKVARS.get_flask_host()]
if FLASKVARS.FLASK_HOST == "0.0.0.0":
if FLASKVARS.get_flask_host() == "0.0.0.0":
adresses = ["localhost", get_ip()]
print("Started app on:")
for address in adresses:
# noinspection HttpUrlsUsage
print(
f"{TCOLOR.OKGREEN}http://{address}:{FLASKVARS.FLASK_PORT}{TCOLOR.ENDC}"
f"{TCOLOR.OKGREEN}http://{address}:{FLASKVARS.get_flask_port()}{TCOLOR.ENDC}"
)
print(lines)
print("\n")
print(lines+"\n")
to_print = [
[
"Extract featured artists from titles",
FromFlags.EXTRACT_FEAT
],
[
"Remove prod. from titles",
FromFlags.REMOVE_PROD
]
]
for item in to_print:
print(
f"{item[0]}: {TCOLOR.FAIL}{item[1]}{TCOLOR.ENDC}"
)
print(
f"{TCOLOR.YELLOW}Data folder: {Paths.get_app_dir()}{TCOLOR.ENDC}"
)
print(f"{TCOLOR.YELLOW}Data folder: {Paths.get_app_dir()}{TCOLOR.ENDC}")
print("\n")
+41 -23
View File
@@ -1,11 +1,15 @@
import json
import random
from tqdm import tqdm
from app.db.sqlite.albumcolors import SQLiteAlbumMethods as aldb
from app.models import Album, Track
from app.db.sqlite.albums import SQLiteAlbumMethods as aldb
from ..utils.hashing import create_hash
from .tracks import TrackStore
from app.utils.progressbar import tqdm
ALBUM_LOAD_KEY = ""
class AlbumStore:
@@ -18,21 +22,26 @@ class AlbumStore:
"""
return Album(
albumhash=track.albumhash,
albumartists=track.albumartist, # type: ignore
title=track.album,
albumartists=track.albumartists, # type: ignore
title=track.og_album,
)
@classmethod
def load_albums(cls):
def load_albums(cls, instance_key: str):
"""
Loads all albums from the database into the store.
"""
global ALBUM_LOAD_KEY
ALBUM_LOAD_KEY = instance_key
cls.albums = []
albumhashes = set(t.albumhash for t in TrackStore.tracks)
for albumhash in tqdm(albumhashes, desc="Loading albums"):
for albumhash in tqdm(albumhashes, desc=f"Loading albums"):
if instance_key != ALBUM_LOAD_KEY:
return
for track in TrackStore.tracks:
if track.albumhash == albumhash:
cls.albums.append(cls.create_album(track))
@@ -40,7 +49,7 @@ class AlbumStore:
db_albums: list[tuple] = aldb.get_all_albums()
for album in tqdm(db_albums, desc="Mapping album colors"):
for album in db_albums:
albumhash = album[1]
colors = json.loads(album[2])
@@ -65,15 +74,21 @@ class AlbumStore:
@classmethod
def get_albums_by_albumartist(
cls, artisthash: str, limit: int, exclude: str
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.albumartists_hashes]
albums = [
album for album in cls.albums if artisthash in album.albumartists_hashes
]
albums = [album for album in albums if album.albumhash != exclude]
albums = [
album
for album in albums
if create_hash(album.base_title) != create_hash(exclude)
]
if len(albums) > limit:
random.shuffle(albums)
@@ -86,10 +101,11 @@ class AlbumStore:
"""
Returns an album by its hash.
"""
try:
return [a for a in cls.albums if a.albumhash == albumhash][0]
except IndexError:
return None
for album in cls.albums:
if album.albumhash == albumhash:
return album
return None
@classmethod
def get_albums_by_hashes(cls, albumhashes: list[str]) -> list[Album]:
@@ -108,21 +124,16 @@ class AlbumStore:
"""
Returns all albums by the given artist.
"""
return [album for album in cls.albums if artisthash in album.albumartists_hashes]
return [
album for album in cls.albums if artisthash in album.albumartists_hashes
]
@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)
master_string = "-".join(a.albumartists_hashes for a in cls.albums)
return master_string.count(artisthash)
@classmethod
@@ -132,6 +143,13 @@ class AlbumStore:
"""
return albumhash in "-".join([a.albumhash for a in cls.albums])
@classmethod
def remove_album(cls, album: Album):
"""
Removes an album from the store.
"""
cls.albums.remove(album)
@classmethod
def remove_album_by_hash(cls, albumhash: str):
"""
+20 -11
View File
@@ -1,29 +1,35 @@
import json
from tqdm import tqdm
from app.db.sqlite.artists import SQLiteArtistMethods as ardb
from app.db.sqlite.artistcolors import SQLiteArtistMethods as ardb
from app.lib.artistlib import get_all_artists
from app.models import Artist
from app.utils.bisection import UseBisection
from .tracks import TrackStore
from app.utils.progressbar import tqdm
from .albums import AlbumStore
from .tracks import TrackStore
ARTIST_LOAD_KEY = ""
class ArtistStore:
artists: list[Artist] = []
@classmethod
def load_artists(cls):
def load_artists(cls, instance_key: str):
"""
Loads all artists from the database into the store.
"""
global ARTIST_LOAD_KEY
ARTIST_LOAD_KEY = instance_key
cls.artists = get_all_artists(TrackStore.tracks, AlbumStore.albums)
db_artists: list[tuple] = list(ardb.get_all_artists())
for artist in ardb.get_all_artists():
if instance_key != ARTIST_LOAD_KEY:
return
for art in tqdm(db_artists, desc="Loading artists"):
cls.map_artist_color(art)
cls.map_artist_color(artist)
@classmethod
def map_artist_color(cls, artist_tuple: tuple):
@@ -61,8 +67,11 @@ class ArtistStore:
Returns an artist by its hash.P
"""
artists = sorted(cls.artists, key=lambda x: x.artisthash)
artist = UseBisection(artists, "artisthash", [artisthash])()[0]
return artist
try:
artist = UseBisection(artists, "artisthash", [artisthash])()[0]
return artist
except IndexError:
return None
@classmethod
def get_artists_by_hashes(cls, artisthashes: list[str]) -> list[Artist]:
@@ -89,7 +98,7 @@ class ArtistStore:
for track in TrackStore.tracks:
artists.update(track.artist_hashes)
album_artists: list[str] = [a.artisthash for a in track.albumartist]
album_artists: list[str] = [a.artisthash for a in track.albumartists]
artists.update(album_artists)
master_hash = "-".join(artists)
+51 -18
View File
@@ -1,27 +1,36 @@
from tqdm import tqdm
# from tqdm import tqdm
from app.models import Track
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.db.sqlite.tracks import SQLiteTrackMethods as tdb
from app.models import Track
from app.utils.bisection import UseBisection
from app.utils.remove_duplicates import remove_duplicates
from app.utils.progressbar import tqdm
TRACKS_LOAD_KEY = ""
class TrackStore:
tracks: list[Track] = []
@classmethod
def load_all_tracks(cls):
def load_all_tracks(cls, instance_key: str):
"""
Loads all tracks from the database into the store.
"""
global TRACKS_LOAD_KEY
TRACKS_LOAD_KEY = instance_key
cls.tracks = list(tdb.get_all_tracks())
fav_hashes = favdb.get_fav_tracks()
fav_hashes = " ".join([t[1] for t in fav_hashes])
print("\n") # adds space between progress bars and startup info
for track in tqdm(cls.tracks, desc="Loading tracks"):
if instance_key != TRACKS_LOAD_KEY:
return
if track.trackhash in fav_hashes:
track.is_favorite = True
@@ -41,6 +50,16 @@ class TrackStore:
cls.tracks.extend(tracks)
@classmethod
def remove_track_obj(cls, track: Track):
"""
Removes a single track from the store.
"""
try:
cls.tracks.remove(track)
except ValueError:
pass
@classmethod
def remove_track_by_filepath(cls, filepath: str):
"""
@@ -49,9 +68,19 @@ class TrackStore:
for track in cls.tracks:
if track.filepath == filepath:
cls.tracks.remove(track)
cls.remove_track_obj(track)
break
@classmethod
def remove_tracks_by_filepaths(cls, filepaths: set[str]):
"""
Removes multiple tracks from the store by their filepaths.
"""
for track in cls.tracks:
if track.filepath in filepaths:
cls.remove_track_obj(track)
@classmethod
def remove_tracks_by_dir_except(cls, dirs: list[str]):
"""Removes all tracks not in the root directories."""
@@ -64,18 +93,11 @@ class TrackStore:
tdb.remove_tracks_by_folders(to_remove)
@classmethod
def count_tracks_by_hash(cls, trackhash: str) -> int:
def count_tracks_by_trackhash(cls, trackhash: str) -> int:
"""
Counts the number of tracks with a specific hash.
Counts the number of tracks with a specific trackhash.
"""
count = 0
for track in cls.tracks:
if track.trackhash == trackhash:
count += 1
return count
return sum(1 for track in cls.tracks if track.trackhash == trackhash)
@classmethod
def make_track_fav(cls, trackhash: str):
@@ -98,7 +120,9 @@ class TrackStore:
track.is_favorite = False
@classmethod
def append_track_artists(cls, albumhash: str, artists: list[str], new_album_title:str):
def append_track_artists(
cls, albumhash: str, artists: list[str], new_album_title: str
):
tracks = cls.get_tracks_by_albumhash(albumhash)
for track in tracks:
@@ -135,12 +159,21 @@ class TrackStore:
Returns all tracks matching the given album hash.
"""
tracks = [t for t in cls.tracks if t.albumhash == album_hash]
return remove_duplicates(tracks)
return remove_duplicates(tracks, is_album_tracks=True)
@classmethod
def get_tracks_by_artist(cls, artisthash: str) -> list[Track]:
def get_tracks_by_artisthash(cls, artisthash: str):
"""
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)
tracks = remove_duplicates(tracks)
tracks.sort(key=lambda x: x.last_mod)
return tracks
@classmethod
def get_tracks_in_path(cls, path: str):
"""
Returns all tracks in the given path.
"""
return (t for t in cls.tracks if t.folder.startswith(path))
+76
View File
@@ -0,0 +1,76 @@
import uuid as UUID
from posthog import Posthog
from app.logger import log
from app.settings import Keys, Paths, Release
from app.utils.hashing import create_hash
from app.utils.network import has_connection
class Telemetry:
"""
Handles sending telemetry data to posthog.
"""
user_id = ""
off = False
@classmethod
def init(cls) -> None:
try:
cls.posthog = Posthog(
project_api_key=Keys.POSTHOG_API_KEY,
host="https://app.posthog.com",
disable_geoip=False,
)
cls.create_userid()
except AssertionError:
cls.disable_telemetry()
@classmethod
def create_userid(cls):
"""
Creates a unique user id for the user and saves it to a file.
"""
uuid_path = Paths.get_app_dir() + "/userid.txt"
try:
with open(uuid_path, "r") as f:
cls.user_id = f.read().strip()
except FileNotFoundError:
uuid = str(UUID.uuid4())
cls.user_id = "user_" + create_hash(uuid, limit=15)
with open(uuid_path, "w") as f:
f.write(cls.user_id)
@classmethod
def disable_telemetry(cls):
cls.off = True
@classmethod
def send_event(cls, event: str):
"""
Sends an event to posthog.
"""
if cls.off:
return
if has_connection():
cls.posthog.capture(cls.user_id, event=f"v{Release.APP_VERSION}-{event}")
@classmethod
def send_app_installed(cls):
"""
Sends an event to posthog when the app is installed.
"""
cls.send_event("app-installed")
@classmethod
def send_artist_visited(cls):
"""
Sends an event to posthog when an artist page is visited.
"""
cls.send_event("artist-page-visited")
+18 -23
View File
@@ -1,3 +1,6 @@
from app.models.track import Track
class UseBisection:
"""
Uses bisection to find a list of items in another list.
@@ -6,10 +9,13 @@ class UseBisection:
items.
"""
def __init__(self, source: list, search_from: str, queries: list[str]) -> None:
def __init__(
self, source: list, search_from: str, queries: list[str], limit=-1
) -> None:
self.source_list = source
self.queries_list = queries
self.attr = search_from
self.limit = limit
def find(self, query: str):
left = 0
@@ -26,32 +32,21 @@ class UseBisection:
return None
def __call__(self) -> list:
def __call__(self):
if len(self.source_list) == 0:
return [None]
return []
return [self.find(query) for query in self.queries_list]
results: list[Track] = []
for query in self.queries_list:
res = self.find(query)
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
if res is None:
continue
strings = sorted(strings)
results.append(res)
left = 0
right = len(strings) - 1
while left <= right:
middle = (left + right) // 2
if strings[middle] == target:
return strings[middle]
if self.limit != -1 and len(results) >= self.limit:
break
if strings[middle] < target:
left = middle + 1
else:
right = middle - 1
return None
return results
+3 -39
View File
@@ -1,3 +1,4 @@
import pendulum
from datetime import datetime
_format = "%Y-%m-%d %H:%M:%S"
@@ -21,44 +22,7 @@ def date_string_to_time_passed(prev_date: str) -> str:
diff = now - then
seconds = diff.seconds
print(seconds)
if seconds < 0:
return "from the future 🛸"
now = pendulum.now()
return now.subtract(seconds=seconds).diff_for_humans()
if seconds < 15:
return "now"
if seconds < 60:
return f"{int(seconds)} seconds ago"
if seconds < 3600:
return f"{int(seconds // 60)} minutes ago"
if seconds < 86400:
return f"{int(seconds // 3600)} hours ago"
days = diff.days
if days == 1:
return "yesterday"
if days < 7:
return f"{days} days ago"
if days < 14:
return "1 week ago"
if days < 30:
return f"{int(days // 7)} weeks ago"
if days < 60:
return "1 month ago"
if days < 365:
return f"{int(days // 30)} months ago"
if days < 730:
return "1 year ago"
return f"{int(days // 365)} years ago"
+11
View File
@@ -0,0 +1,11 @@
def coroutine(func):
"""
Decorator: primes `func` by advancing to first `yield`
"""
def start(*args, **kwargs):
cr = func(*args, **kwargs)
next(cr)
return cr
return start
+12 -16
View File
@@ -3,29 +3,25 @@ import hashlib
from unidecode import unidecode
def create_hash(*args: str, decode=False, limit=7) -> str:
def create_hash(*args: str, decode=False, limit=10) -> str:
"""
Creates a simple hash for an album
"""
str_ = "".join(args)
def remove_non_alnum(token: str) -> str:
token = token.lower().strip().replace(" ", "")
t = "".join(t for t in token if t.isalnum())
if t == "":
return token
return t
str_ = "".join(remove_non_alnum(t) for t in args)
if decode:
str_ = unidecode(str_)
str_ = str_.lower().strip().replace(" ", "")
str_ = "".join(t for t in str_ if t.isalnum())
str_ = str_.encode("utf-8")
str_ = hashlib.sha256(str_).hexdigest()
return str_[-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)
+10 -12
View File
@@ -1,19 +1,18 @@
import requests
import socket as Socket
class Ping:
def has_connection(host="8.8.8.8", port=53, timeout=3):
"""
Checks if there is a connection to the internet by pinging google.com
Host: 8.8.8.8 (google-public-dns-a.google.com)
OpenPort: 53/tcp
Service: domain (DNS/TCP)
"""
@staticmethod
def __call__() -> bool:
try:
requests.get("https://google.com", timeout=10)
return True
except (requests.exceptions.ConnectionError, requests.Timeout):
return False
try:
Socket.setdefaulttimeout(timeout)
Socket.socket(Socket.AF_INET, Socket.SOCK_STREAM).connect((host, port))
return True
except Socket.error as ex:
return False
def get_ip():
@@ -26,4 +25,3 @@ def get_ip():
soc.close()
return ip_address
+125 -5
View File
@@ -1,11 +1,21 @@
import re
from app.enums.album_versions import AlbumVersionEnum, get_all_keywords
from app.settings import SessionVarKeys, get_flag
def split_artists(src: str, with_and: bool = False):
exp = r"\s*(?: and |&|,|;)\s*" if with_and else r"\s*[,;]\s*"
artists = re.split(exp, src)
return [a.strip() for a in artists]
def split_artists(src: str):
"""
Splits a string of artists into a list of artists.
"""
separators: set = get_flag(SessionVarKeys.ARTIST_SEPARATORS)
for sep in separators:
src = src.replace(sep, ",")
artists = src.split(",")
artists = [a.strip() for a in artists]
return [a for a in artists if a]
def parse_artist_from_filename(title: str):
@@ -79,8 +89,118 @@ def parse_feat_from_title(title: str) -> tuple[list[str], str]:
return [], title
artists = match.group(1)
artists = split_artists(artists, with_and=True)
artists = split_artists(artists)
# remove "feat" group from title
new_title = re.sub(regex, "", title, flags=re.IGNORECASE)
return artists, new_title
def get_base_album_title(string: str) -> tuple[str, str | None]:
"""
Extracts the base album title from a string.
"""
pattern = re.compile(
rf"\s*(\(|\[)[^\)\]]*?({get_all_keywords()})[^\)\]]*?(\)|\])$",
re.IGNORECASE,
)
# TODO: Fix "Redundant character escape '\]' in RegExp "
match = pattern.search(string)
if match:
removed_block = match.group(0)
title = string.replace(removed_block, "")
return title.strip(), removed_block.strip()
return string, None
def get_anniversary(text: str) -> str | None:
"""
Extracts anniversary from text using regex.
"""
_end = "anniversary"
match = re.search(r"\b\d+\w*(?= anniversary)", text, re.IGNORECASE)
if match:
return match.group(0).strip().lower() + f" {_end}"
else:
return _end
def get_album_info(bracket_text: str | None) -> list[str]:
"""
Extracts album version info from the bracketed text on an album title string using regex.
"""
if not bracket_text:
return []
# replace all non-alphanumeric characters with an empty string
bracket_text = re.sub(r"[^a-zA-Z0-9\s]", "", bracket_text)
versions = []
for version_keywords in AlbumVersionEnum:
for keyword in version_keywords.value:
if re.search(keyword, bracket_text, re.IGNORECASE):
versions.append(version_keywords.name.lower())
break
if "anniversary" in versions:
anniversary = get_anniversary(bracket_text)
versions.insert(0, anniversary)
versions.remove("anniversary")
return versions
def get_base_title_and_versions(
original_album_title: str, get_versions=True
) -> tuple[str, list[str]]:
"""
Extracts the base album title and version info from an album title string using regex.
"""
album_title, version_block = get_base_album_title(original_album_title)
if version_block is None:
return original_album_title, []
if not get_versions:
return album_title, []
versions = get_album_info(version_block)
# if no version info could be extracted, accept defeat!
if len(versions) == 0:
album_title = original_album_title
return album_title, versions
def remove_bracketed_remaster(text: str):
"""
Removes remaster info from a track title that contains brackets using regex.
"""
return re.sub(
r"\s*[\\[(][^)\]]*remaster[^)\]]*[)\]]\s*", "", text, flags=re.IGNORECASE
).strip()
def remove_hyphen_remasters(text: str):
"""
Removes remaster info from a track title that contains a hypen (-) using regex.
"""
return re.sub(
r"\s-\s*[^-]*\bremaster[^-]*\s*", "", text, flags=re.IGNORECASE
).strip()
def clean_title(title: str) -> str:
"""
Removes remaster info from a track title using regex.
"""
if "remaster" not in title.lower():
return title
rem_1 = remove_bracketed_remaster(title)
rem_2 = remove_hyphen_remasters(title)
return rem_1 if len(rem_2) > len(rem_1) else rem_2
+16
View File
@@ -0,0 +1,16 @@
from tqdm import tqdm as _tqdm
def tqdm(*args, **kwargs):
"""
Wrapper for tqdm that sets globals.
"""
bar_format = "{percentage:3.0f}%|{bar:45}|{n_fmt}/{total_fmt}{desc}"
kwargs["bar_format"] = bar_format
if "desc" in kwargs:
print(f'INFO|{kwargs["desc"].capitalize()} ...')
kwargs["desc"] = ""
return _tqdm(*args, **kwargs)
+31 -4
View File
@@ -2,21 +2,48 @@ from collections import defaultdict
from operator import attrgetter
from app.models import Track
from app.utils.hashing import create_hash
def remove_duplicates(tracks: list[Track]) -> list[Track]:
def remove_duplicates(tracks: list[Track], is_album_tracks=False) -> list[Track]:
"""
Remove duplicates from a list of Track objects based on the trackhash attribute.
Retain objects with the highest bitrate.
"""
hash_to_tracks = defaultdict(list)
tracks_dict = defaultdict(list)
# if is_album_tracks, sort by disc and track number
if is_album_tracks:
for t in tracks:
# _pos is used for sorting tracks by disc and track number
t._pos = int(f"{t.disc}{str(t.track).zfill(3)}")
# _ati is used to remove duplicates when merging album versions
t._ati = f"{t._pos}{create_hash(t.title)}"
# create groups of tracks with the same _ati
for track in tracks:
tracks_dict[track._ati].append(track)
tracks = []
# pick the track with max bitrate for each group
for track_group in tracks_dict.values():
max_bitrate_track = max(track_group, key=attrgetter("bitrate"))
tracks.append(max_bitrate_track)
return sorted(tracks, key=lambda t: t._pos)
# else, sort by trackhash
for track in tracks:
hash_to_tracks[track.trackhash].append(track)
# create groups of tracks with the same trackhash
tracks_dict[track.trackhash].append(track)
tracks = []
for track_group in hash_to_tracks.values():
# pick the track with max bitrate for each trackhash group
for track_group in tracks_dict.values():
max_bitrate_track = max(track_group, key=attrgetter("bitrate"))
tracks.append(max_bitrate_track)
+1 -2
View File
@@ -3,8 +3,7 @@ import threading
def background(func):
"""
a threading decorator
use @background above the function you want to run in the background
Runs the decorated function in a background thread.
"""
def background_func(*a, **kw):
+5
View File
@@ -0,0 +1,5 @@
def handle_unicode(string: str):
"""
Handles Unicode errors by ignoring unicode characters
"""
return string.encode("utf-16", "ignore").decode("utf-16")
+3 -1
View File
@@ -1,12 +1,14 @@
import platform
IS_WIN = platform.system() == "Windows"
# TODO: Check is_windows on app start in settings.py
def is_windows():
"""
Returns True if the OS is Windows.
"""
return platform.system() == "Windows"
return IS_WIN
def win_replace_slash(path: str):
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+54 -10
View File
@@ -2,17 +2,36 @@
This file is used to run the application.
"""
import logging
import mimetypes
import os
import setproctitle
from flask import request
from app.telemetry import Telemetry
from app.api import create_api
from app.arg_handler import HandleArgs
from app.functions import run_periodic_checks
from app.lib.watchdogg import Watcher as WatchDog
from app.settings import FLASKVARS
from app.periodic_scan import run_periodic_scans
from app.settings import FLASKVARS, Keys
from app.setup import run_setup
from app.start_info_logger import log_startup_info
from app.utils.filesystem import get_home_res_path
from app.utils.threading import background
mimetypes.add_type("text/css", ".css")
mimetypes.add_type("text/javascript", ".js")
mimetypes.add_type("text/plain", ".txt")
mimetypes.add_type("text/html", ".html")
mimetypes.add_type("image/webp", ".webp")
mimetypes.add_type("image/svg+xml", ".svg")
mimetypes.add_type("image/png", ".png")
mimetypes.add_type("image/vnd.microsoft.icon", ".ico")
mimetypes.add_type("image/gif", ".gif")
mimetypes.add_type("font/woff", ".woff")
mimetypes.add_type("application/manifest+json", ".webmanifest")
werkzeug = logging.getLogger("werkzeug")
werkzeug.setLevel(logging.ERROR)
@@ -20,12 +39,25 @@ app = create_api()
app.static_folder = get_home_res_path("client")
# @app.route("/", defaults={"path": ""})
@app.route("/<path:path>")
def serve_client_files(path):
def serve_client_files(path: str):
"""
Serves the static files in the client folder.
"""
return app.send_static_file(path)
js_or_css = path.endswith(".js") or path.endswith(".css")
if not js_or_css:
return app.send_static_file(path)
gzipped_path = path + ".gz"
if request.headers.get("Accept-Encoding", "").find("gzip") >= 0:
if os.path.exists(os.path.join(app.static_folder, gzipped_path)):
response = app.make_response(app.send_static_file(gzipped_path))
response.headers["Content-Encoding"] = "gzip"
return response
else:
return app.send_static_file(path)
@app.route("/")
@@ -39,7 +71,7 @@ def serve_client():
@background
def bg_run_setup() -> None:
run_setup()
run_periodic_checks()
run_periodic_scans()
@background
@@ -47,18 +79,30 @@ def start_watchdog():
WatchDog().run()
if __name__ == "__main__":
@background
def init_telemetry():
Telemetry.init()
def run_swingmusic():
Keys.load()
HandleArgs()
log_startup_info()
bg_run_setup()
start_watchdog()
init_telemetry()
setproctitle.setproctitle(
f"swingmusic - {FLASKVARS.FLASK_HOST}:{FLASKVARS.FLASK_PORT}"
)
if __name__ == "__main__":
run_swingmusic()
app.run(
debug=False,
threaded=True,
host=FLASKVARS.FLASK_HOST,
port=FLASKVARS.FLASK_PORT,
host=FLASKVARS.get_flask_host(),
port=FLASKVARS.get_flask_port(),
use_reloader=False,
)
# TODO: Organize code in this file: move args to new file, etc.
Generated
+980 -507
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
[DEFAULT]
build = False
+10 -1
View File
@@ -18,18 +18,27 @@ rapidfuzz = "^2.13.7"
tinytag = "^1.8.1"
Unidecode = "^1.3.6"
psutil = "^5.9.4"
show-in-file-manager = "^1.1.4"
pendulum = "^2.1.2"
flask-compress = "^1.13"
tabulate = "^0.9.0"
setproctitle = "^1.3.2"
posthog = "^3.0.2"
[tool.poetry.dev-dependencies]
pylint = "^2.15.5"
pytest = "^7.1.3"
hypothesis = "^6.56.3"
pyinstaller = "^5.9.0"
[tool.poetry.dev-dependencies.black]
version = "^22.6.0"
allow-prereleases = true
[tool.poetry.group.dev.dependencies]
pyinstaller = "^5.9.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
-12
View File
@@ -1,12 +0,0 @@
### Open the following links in new tabs (`ctrl + click`) to view screenshots:
> These images were taken on v1.10, they might be a lil bit outdated.
1. [Album page](https://github.com/geoffrey45/swingmusic/blob/master/screenshots/album-page.png?raw=true)
2. [Artist page](https://github.com/geoffrey45/swingmusic/blob/master/screenshots/artist-page-1.png?raw=true)
3. [Artist page continuation](https://github.com/geoffrey45/swingmusic/blob/master/screenshots/artist-page-2.png?raw=true)
4. [Artist discography](https://github.com/geoffrey45/swingmusic/blob/master/screenshots/artist-discography-page.png?raw=true)
5. [Folder page](https://github.com/geoffrey45/swingmusic/blob/master/screenshots/folder-page.png?raw=true)
6. [Favorite page](https://github.com/geoffrey45/swingmusic/blob/master/screenshots/favorites-page.png?raw=true)
7. [Playlist page](https://github.com/geoffrey45/swingmusic/blob/master/screenshots/playlist-page.png?raw=true)
8. [Queue page](https://github.com/geoffrey45/swingmusic/blob/master/screenshots/queue-page.png?raw=true)
9. [Search page](https://github.com/geoffrey45/swingmusic/blob/master/screenshots/search-page.png?raw=true)
10. [Settings page](https://github.com/geoffrey45/swingmusic/blob/master/screenshots/settings-page.png?raw=true)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 658 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Some files were not shown because too many files have changed in this diff Show More