mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-03 20:13:02 +00:00
Merge branch 'swingmx:master' into master
This commit is contained in:
+4
-39
@@ -1,40 +1,5 @@
|
|||||||
# What's New?
|
# Bug fixes
|
||||||
|
|
||||||
<!-- TODO: ELABORATE -->
|
- Embedded thumbnails are now used when found in tracks
|
||||||
|
- Handle `AttributeError` on indexing tracks
|
||||||
- Auth
|
- Add print on error in favorites
|
||||||
- New artists/albums Sort by: last played, no. of streams, total stream duration
|
|
||||||
- Option to show now playing track info on tab title. Go to Settings > Appearance to enable
|
|
||||||
- You can select which disc to play in an album
|
|
||||||
- Internal Backup and restore
|
|
||||||
|
|
||||||
## Improvements
|
|
||||||
|
|
||||||
- The context menu now doesn't take forever to open up
|
|
||||||
- Merged "Save as Playlist" with "Add to Playlist" > "New Playlist"
|
|
||||||
|
|
||||||
## Bug fixes
|
|
||||||
|
|
||||||
- Add to queue adding to last index -1
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
- Rewritten the whole DB layer to move stores from memory to the database.
|
|
||||||
|
|
||||||
## THE BIG ONE API CHANGES
|
|
||||||
|
|
||||||
- genre is no longer a string, but a struct:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface Genre {
|
|
||||||
name: str;
|
|
||||||
genrehash: str;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Pairing via QR Code has been split into 2 endpoint:
|
|
||||||
|
|
||||||
1. `/getpaircode`
|
|
||||||
2. `/pair`
|
|
||||||
|
|
||||||
-
|
|
||||||
|
|||||||
@@ -31,3 +31,4 @@ logs.txt
|
|||||||
testdata.py
|
testdata.py
|
||||||
test.py
|
test.py
|
||||||
nohup.out
|
nohup.out
|
||||||
|
*s.json
|
||||||
|
|||||||
@@ -97,3 +97,72 @@ Swing Music is looking for contributors. If you're interested, please join us at
|
|||||||
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.
|
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.
|
||||||
|
|
||||||
**[MIT License](https://opensource.org/licenses/MIT) | Copyright (c) 2023 Mungai Njoroge**
|
**[MIT License](https://opensource.org/licenses/MIT) | Copyright (c) 2023 Mungai Njoroge**
|
||||||
|
|
||||||
|
### Contributors
|
||||||
|
|
||||||
|
Shout out to the following code contributors who have helped maintain and improve Swing Music:
|
||||||
|
|
||||||
|
<div align="left">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/cwilvx">
|
||||||
|
<img src="https://github.com/cwilvx.png" width="80px;"/>
|
||||||
|
<br />
|
||||||
|
<sub><b>@cwilvx</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Ericgacoki">
|
||||||
|
<img src="https://github.com/Ericgacoki.png" width="80px;" alt=""/>
|
||||||
|
<br />
|
||||||
|
<sub><b>@Ericgacoki</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Simonh2o">
|
||||||
|
<img src="https://github.com/Simonh2o.png" width="80px;"/>
|
||||||
|
<br />
|
||||||
|
<sub><b>@Simonh2o</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/tcsenpai">
|
||||||
|
<img src="https://github.com/tcsenpai.png" width="80px;"/>
|
||||||
|
<br />
|
||||||
|
<sub><b>@tcsenpai</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/jensgrunzer1">
|
||||||
|
<img src="https://github.com/jensgrunzer1.png" width="80px;"/>
|
||||||
|
<br />
|
||||||
|
<sub><b>@jensgrunzer1</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Type-Delta">
|
||||||
|
<img src="https://github.com/Type-Delta.png" width="80px;" alt=""/>
|
||||||
|
<br />
|
||||||
|
<sub><b>@Type-Delta</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/MarcOrfilaCarreras">
|
||||||
|
<img src="https://github.com/MarcOrfilaCarreras.png" width="80px;" alt=""/>
|
||||||
|
<br />
|
||||||
|
<sub><b>@MarcOrfilaCarreras</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/tralph3">
|
||||||
|
<img src="https://github.com/tralph3.png" width="80px;" alt=""/>
|
||||||
|
<br />
|
||||||
|
<sub><b>@tralph3</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.config import UserConfig
|
|||||||
from app.db.userdata import UserTable
|
from app.db.userdata import UserTable
|
||||||
from app.settings import Info as AppInfo
|
from app.settings import Info as AppInfo
|
||||||
from .plugins import lyrics as lyrics_plugin
|
from .plugins import lyrics as lyrics_plugin
|
||||||
|
from .plugins import mixes as mixes_plugin
|
||||||
from app.api import (
|
from app.api import (
|
||||||
album,
|
album,
|
||||||
artist,
|
artist,
|
||||||
@@ -113,6 +114,7 @@ def create_api():
|
|||||||
# Plugins
|
# Plugins
|
||||||
app.register_api(plugins.api)
|
app.register_api(plugins.api)
|
||||||
app.register_api(lyrics_plugin.api)
|
app.register_api(lyrics_plugin.api)
|
||||||
|
app.register_api(mixes_plugin.api)
|
||||||
|
|
||||||
# Logger
|
# Logger
|
||||||
app.register_api(scrobble.api)
|
app.register_api(scrobble.api)
|
||||||
|
|||||||
+5
-3
@@ -7,6 +7,7 @@ from pprint import pprint
|
|||||||
import random
|
import random
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from flask_openapi3 import APIBlueprint, Tag
|
from flask_openapi3 import APIBlueprint, Tag
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
@@ -72,6 +73,7 @@ def get_artist(path: ArtistHashSchema, query: GetArtistQuery):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
year = 0
|
year = 0
|
||||||
|
|
||||||
|
genres = [*artist.genres]
|
||||||
decade = None
|
decade = None
|
||||||
|
|
||||||
if year:
|
if year:
|
||||||
@@ -79,7 +81,7 @@ def get_artist(path: ArtistHashSchema, query: GetArtistQuery):
|
|||||||
decade = str(decade)[2:] + "s"
|
decade = str(decade)[2:] + "s"
|
||||||
|
|
||||||
if decade:
|
if decade:
|
||||||
artist.genres.insert(0, {"name": decade, "genrehash": decade})
|
genres.insert(0, {"name": decade, "genrehash": decade})
|
||||||
|
|
||||||
stats = get_track_group_stats(tracks)
|
stats = get_track_group_stats(tracks)
|
||||||
duration = sum(t.duration for t in tracks) if tracks else 0
|
duration = sum(t.duration for t in tracks) if tracks else 0
|
||||||
@@ -105,7 +107,7 @@ def get_artist(path: ArtistHashSchema, query: GetArtistQuery):
|
|||||||
"duration": duration,
|
"duration": duration,
|
||||||
"trackcount": tcount,
|
"trackcount": tcount,
|
||||||
"albumcount": artist.albumcount,
|
"albumcount": artist.albumcount,
|
||||||
"genres": artist.genres,
|
"genres": genres,
|
||||||
"is_favorite": artist.is_favorite,
|
"is_favorite": artist.is_favorite,
|
||||||
},
|
},
|
||||||
"tracks": tracks,
|
"tracks": tracks,
|
||||||
@@ -150,7 +152,7 @@ def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery):
|
|||||||
albums = [a for a in albumdict.values()]
|
albums = [a for a in albumdict.values()]
|
||||||
all_albums = sorted(albums, key=lambda a: a.date, reverse=True)
|
all_albums = sorted(albums, key=lambda a: a.date, reverse=True)
|
||||||
|
|
||||||
res = {
|
res: dict[str, Any] = {
|
||||||
"albums": [],
|
"albums": [],
|
||||||
"appearances": [],
|
"appearances": [],
|
||||||
"compilations": [],
|
"compilations": [],
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ def toggle_favorite(body: FavoritesAddBody):
|
|||||||
FavoritesTable.insert_item(
|
FavoritesTable.insert_item(
|
||||||
{"hash": body.hash, "type": body.type, "extra": extra}
|
{"hash": body.hash, "type": body.type, "extra": extra}
|
||||||
)
|
)
|
||||||
except:
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
return {"msg": "Failed! An error occured"}, 500
|
return {"msg": "Failed! An error occured"}, 500
|
||||||
|
|
||||||
toggle_fav(body.type, body.hash)
|
toggle_fav(body.type, body.hash)
|
||||||
@@ -93,7 +94,8 @@ def remove_favorite(body: FavoritesAddBody):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
FavoritesTable.remove_item({"hash": body.hash, "type": body.type})
|
FavoritesTable.remove_item({"hash": body.hash, "type": body.type})
|
||||||
except:
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
return {"msg": "Failed! An error occured"}, 500
|
return {"msg": "Failed! An error occured"}, 500
|
||||||
|
|
||||||
toggle_fav(body.type, body.hash)
|
toggle_fav(body.type, body.hash)
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
from flask_jwt_extended import current_user
|
|
||||||
from flask_openapi3 import Tag
|
from flask_openapi3 import Tag
|
||||||
from flask_openapi3 import APIBlueprint
|
from flask_openapi3 import APIBlueprint
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.api.apischemas import GenericLimitSchema
|
from app.api.apischemas import GenericLimitSchema
|
||||||
from app.lib.home.recentlyadded import get_recently_added_items
|
from app.lib.home.recentlyadded import get_recently_added_items
|
||||||
from app.lib.home.recentlyplayed import get_recently_played
|
from app.lib.home.get_recently_played import get_recently_played
|
||||||
|
from app.store.homepage import HomepageStore
|
||||||
|
|
||||||
bp_tag = Tag(name="Home", description="Homepage items")
|
bp_tag = Tag(name="Home", description="Homepage items")
|
||||||
api = APIBlueprint("home", __name__, url_prefix="/home", abp_tags=[bp_tag])
|
api = APIBlueprint("home", __name__, url_prefix="/nothome", abp_tags=[bp_tag])
|
||||||
|
|
||||||
|
|
||||||
@api.get("/recents/added")
|
@api.get("/recents/added")
|
||||||
@@ -24,3 +25,14 @@ def get_recent_plays(query: GenericLimitSchema):
|
|||||||
Get recently played
|
Get recently played
|
||||||
"""
|
"""
|
||||||
return {"items": get_recently_played(query.limit)}
|
return {"items": get_recently_played(query.limit)}
|
||||||
|
|
||||||
|
|
||||||
|
class HomepageItem(BaseModel):
|
||||||
|
limit: int = Field(
|
||||||
|
default=9, description="The max number of items per group to return"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/")
|
||||||
|
def homepage_items(query: HomepageItem):
|
||||||
|
return HomepageStore.get_homepage_items(limit=query.limit)
|
||||||
+126
-9
@@ -1,3 +1,4 @@
|
|||||||
|
from fileinput import filename
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from flask_openapi3 import Tag
|
from flask_openapi3 import Tag
|
||||||
from flask_openapi3 import APIBlueprint
|
from flask_openapi3 import APIBlueprint
|
||||||
@@ -5,6 +6,10 @@ from pydantic import BaseModel, Field
|
|||||||
from flask import send_from_directory
|
from flask import send_from_directory
|
||||||
|
|
||||||
from app.settings import Defaults, Paths
|
from app.settings import Defaults, Paths
|
||||||
|
from app.store.albums import AlbumStore
|
||||||
|
from app.store.tracks import TrackStore
|
||||||
|
from app.utils.threading import background
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
bp_tag = Tag(
|
bp_tag = Tag(
|
||||||
name="Images", description="Image filenames are constructured as '{itemhash}.webp'"
|
name="Images", description="Image filenames are constructured as '{itemhash}.webp'"
|
||||||
@@ -12,6 +17,74 @@ bp_tag = Tag(
|
|||||||
api = APIBlueprint("imgserver", __name__, url_prefix="/img", abp_tags=[bp_tag])
|
api = APIBlueprint("imgserver", __name__, url_prefix="/img", abp_tags=[bp_tag])
|
||||||
|
|
||||||
|
|
||||||
|
@background
|
||||||
|
def cache_thumbnails(filepath: Path, trackhash: str):
|
||||||
|
"""
|
||||||
|
Resizes the image and stores it in the cache directory.
|
||||||
|
"""
|
||||||
|
image = Image.open(filepath)
|
||||||
|
path = Path(Paths.get_image_cache_path())
|
||||||
|
aspect_ratio = image.width / image.height
|
||||||
|
|
||||||
|
sizes = {
|
||||||
|
"xsmall": 64,
|
||||||
|
"small": 96,
|
||||||
|
"medium": 256,
|
||||||
|
"large": 512,
|
||||||
|
}
|
||||||
|
|
||||||
|
for size, width in sizes.items():
|
||||||
|
width = min(width, image.width)
|
||||||
|
height = int(width / aspect_ratio)
|
||||||
|
|
||||||
|
resized_path = path / size / (trackhash + ".webp")
|
||||||
|
resized_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
image.resize((width, height)).save(resized_path, format="webp")
|
||||||
|
|
||||||
|
|
||||||
|
def find_thumbnail(albumhash: str, pathhash: str):
|
||||||
|
# entry = TrackStore.trackhashmap.get(albumhash)
|
||||||
|
entry = AlbumStore.albummap.get(albumhash)
|
||||||
|
|
||||||
|
if entry is None:
|
||||||
|
return None, None, ""
|
||||||
|
|
||||||
|
track_file = None
|
||||||
|
|
||||||
|
tracks = TrackStore.get_tracks_by_trackhashes(entry.trackhashes)
|
||||||
|
for track in tracks:
|
||||||
|
if track.pathhash == pathhash:
|
||||||
|
track_file = track
|
||||||
|
break
|
||||||
|
|
||||||
|
if track_file is None:
|
||||||
|
return None, None, ""
|
||||||
|
|
||||||
|
folder = Path(track_file.folder)
|
||||||
|
|
||||||
|
# INFO: Check if the folder has image files
|
||||||
|
extensions = [".jpg", ".jpeg", ".png", ".webp"]
|
||||||
|
hierarchy = ["cover", "front", "back", "folder", "album", "artwork"]
|
||||||
|
|
||||||
|
images: list[Path] = []
|
||||||
|
for item in folder.iterdir():
|
||||||
|
if item.suffix in extensions:
|
||||||
|
images.append(item)
|
||||||
|
|
||||||
|
if len(images) == 0:
|
||||||
|
return None, None, ""
|
||||||
|
|
||||||
|
# INFO: Check if the folder has image files in the hierarchy
|
||||||
|
for item in hierarchy:
|
||||||
|
for image in images:
|
||||||
|
if image.name.lower().startswith(item.lower()):
|
||||||
|
return image.parent, image.name, track_file.albumhash
|
||||||
|
|
||||||
|
# INFO: If no image falls in the hierarchy, return the first image
|
||||||
|
first_image = images[0]
|
||||||
|
return first_image.parent, first_image.name, track_file.albumhash
|
||||||
|
|
||||||
|
|
||||||
def send_fallback_img(filename: str = "default.webp"):
|
def send_fallback_img(filename: str = "default.webp"):
|
||||||
"""
|
"""
|
||||||
Returns the fallback image from the assets folder.
|
Returns the fallback image from the assets folder.
|
||||||
@@ -25,7 +98,9 @@ def send_fallback_img(filename: str = "default.webp"):
|
|||||||
return send_from_directory(folder, filename)
|
return send_from_directory(folder, filename)
|
||||||
|
|
||||||
|
|
||||||
def send_file_or_fallback(folder: str, filename: str, fallback: str = "default.webp"):
|
def send_file_or_fallback(
|
||||||
|
folder: str, filename: str, fallback: str = "default.webp", pathhash: str = ""
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Returns the file from the folder or the fallback image.
|
Returns the file from the folder or the fallback image.
|
||||||
"""
|
"""
|
||||||
@@ -34,6 +109,22 @@ def send_file_or_fallback(folder: str, filename: str, fallback: str = "default.w
|
|||||||
if fpath.exists():
|
if fpath.exists():
|
||||||
return send_from_directory(folder, filename)
|
return send_from_directory(folder, filename)
|
||||||
|
|
||||||
|
if pathhash != "":
|
||||||
|
# INFO: Check if the image is in the cache
|
||||||
|
cache_path = Path(Paths.get_image_cache_path()) / fpath.parent.name / filename
|
||||||
|
if cache_path.exists():
|
||||||
|
return send_from_directory(cache_path.parent, cache_path.name)
|
||||||
|
|
||||||
|
# INFO: Find the thumbnail
|
||||||
|
parent, file, albumhash = find_thumbnail(
|
||||||
|
filename.replace(".webp", ""), pathhash
|
||||||
|
)
|
||||||
|
|
||||||
|
# INFO: Cache and send the thumbnail
|
||||||
|
if file is not None and parent is not None:
|
||||||
|
cache_thumbnails(parent / file, albumhash)
|
||||||
|
return send_from_directory(parent, file)
|
||||||
|
|
||||||
return send_fallback_img(fallback)
|
return send_fallback_img(fallback)
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +135,13 @@ class ImagePath(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageQuery(BaseModel):
|
||||||
|
pathhash: str = Field(
|
||||||
|
description="The path hash used to find the thumbnail",
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# @api.get("/t/o/<imgpath>")
|
# @api.get("/t/o/<imgpath>")
|
||||||
# def send_original_thumbnail(path: ImagePath):
|
# def send_original_thumbnail(path: ImagePath):
|
||||||
# """
|
# """
|
||||||
@@ -60,39 +158,39 @@ class ImagePath(BaseModel):
|
|||||||
|
|
||||||
# TRACK THUMBNAILS
|
# TRACK THUMBNAILS
|
||||||
@api.get("/thumbnail/<imgpath>")
|
@api.get("/thumbnail/<imgpath>")
|
||||||
def send_lg_thumbnail(path: ImagePath):
|
def send_lg_thumbnail(path: ImagePath, query: ImageQuery):
|
||||||
"""
|
"""
|
||||||
Get large thumbnail (500 x 500)
|
Get large thumbnail (500 x 500)
|
||||||
"""
|
"""
|
||||||
folder = Paths.get_lg_thumb_path()
|
folder = Paths.get_lg_thumb_path()
|
||||||
return send_file_or_fallback(folder, path.imgpath)
|
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
|
||||||
|
|
||||||
|
|
||||||
@api.get("/thumbnail/xsmall/<imgpath>")
|
@api.get("/thumbnail/xsmall/<imgpath>")
|
||||||
def send_xsm_thumbnail(path: ImagePath):
|
def send_xsm_thumbnail(path: ImagePath, query: ImageQuery):
|
||||||
"""
|
"""
|
||||||
Get extra small thumbnail (64px)
|
Get extra small thumbnail (64px)
|
||||||
"""
|
"""
|
||||||
folder = Paths.get_xsm_thumb_path()
|
folder = Paths.get_xsm_thumb_path()
|
||||||
return send_file_or_fallback(folder, path.imgpath)
|
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
|
||||||
|
|
||||||
|
|
||||||
@api.get("/thumbnail/small/<imgpath>")
|
@api.get("/thumbnail/small/<imgpath>")
|
||||||
def send_sm_thumbnail(path: ImagePath):
|
def send_sm_thumbnail(path: ImagePath, query: ImageQuery):
|
||||||
"""
|
"""
|
||||||
Get small thumbnail (96px)
|
Get small thumbnail (96px)
|
||||||
"""
|
"""
|
||||||
folder = Paths.get_sm_thumb_path()
|
folder = Paths.get_sm_thumb_path()
|
||||||
return send_file_or_fallback(folder, path.imgpath)
|
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
|
||||||
|
|
||||||
|
|
||||||
@api.get("/thumbnail/medium/<imgpath>")
|
@api.get("/thumbnail/medium/<imgpath>")
|
||||||
def send_md_thumbnail(path: ImagePath):
|
def send_md_thumbnail(path: ImagePath, query: ImageQuery):
|
||||||
"""
|
"""
|
||||||
Get medium thumbnail (256px)
|
Get medium thumbnail (256px)
|
||||||
"""
|
"""
|
||||||
folder = Paths.get_md_thumb_path()
|
folder = Paths.get_md_thumb_path()
|
||||||
return send_file_or_fallback(folder, path.imgpath)
|
return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash)
|
||||||
|
|
||||||
|
|
||||||
# ARTISTS
|
# ARTISTS
|
||||||
@@ -140,3 +238,22 @@ def send_playlist_image(path: PlaylistImagePath):
|
|||||||
"""
|
"""
|
||||||
folder = Paths.get_playlist_img_path()
|
folder = Paths.get_playlist_img_path()
|
||||||
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
|
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
|
||||||
|
|
||||||
|
|
||||||
|
# MIXES
|
||||||
|
@api.get("/mix/medium/<imgpath>")
|
||||||
|
def send_md_mix_image(path: ImagePath):
|
||||||
|
"""
|
||||||
|
Get medium mix image
|
||||||
|
"""
|
||||||
|
folder = Paths.get_md_mixes_img_path()
|
||||||
|
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/mix/small/<imgpath>")
|
||||||
|
def send_sm_mix_image(path: ImagePath):
|
||||||
|
"""
|
||||||
|
Get small mix image
|
||||||
|
"""
|
||||||
|
folder = Paths.get_sm_mixes_img_path()
|
||||||
|
return send_file_or_fallback(folder, path.imgpath, "playlist.svg")
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ from flask_openapi3 import Tag
|
|||||||
from flask_openapi3 import APIBlueprint
|
from flask_openapi3 import APIBlueprint
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from app.api.auth import admin_required
|
from app.api.auth import admin_required
|
||||||
|
from app.config import UserConfig
|
||||||
from app.db.userdata import PluginTable
|
from app.db.userdata import PluginTable
|
||||||
|
from app.plugins.lastfm import LastFmPlugin
|
||||||
|
from app.utils.auth import get_current_userid
|
||||||
|
|
||||||
bp_tag = Tag(name="Plugins", description="Manage plugins")
|
bp_tag = Tag(name="Plugins", description="Manage plugins")
|
||||||
api = APIBlueprint("plugins", __name__, url_prefix="/plugins", abp_tags=[bp_tag])
|
api = APIBlueprint("plugins", __name__, url_prefix="/plugins", abp_tags=[bp_tag])
|
||||||
@@ -61,3 +64,40 @@ def update_plugin_settings(body: PluginSettingsBody):
|
|||||||
plugin = PluginTable.get_by_name(plugin)
|
plugin = PluginTable.get_by_name(plugin)
|
||||||
|
|
||||||
return {"status": "success", "settings": plugin.settings}
|
return {"status": "success", "settings": plugin.settings}
|
||||||
|
|
||||||
|
|
||||||
|
class LastFmSessionBody(BaseModel):
|
||||||
|
token: str = Field(description="The token to use to create the session")
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/lastfm/session/create")
|
||||||
|
def create_lastfm_session(body: LastFmSessionBody):
|
||||||
|
"""
|
||||||
|
Create a Last.fm session
|
||||||
|
"""
|
||||||
|
if not body.token:
|
||||||
|
return {"error": "Missing token"}, 400
|
||||||
|
|
||||||
|
lastfm = LastFmPlugin()
|
||||||
|
session_key = lastfm.get_session_key(body.token)
|
||||||
|
|
||||||
|
if session_key:
|
||||||
|
config = UserConfig()
|
||||||
|
current_user = get_current_userid()
|
||||||
|
config.lastfmSessionKeys[str(current_user)] = session_key
|
||||||
|
config.lastfmSessionKeys = config.lastfmSessionKeys
|
||||||
|
|
||||||
|
return {"status": "success", "session_key": session_key}
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/lastfm/session/delete")
|
||||||
|
def delete_lastfm_session():
|
||||||
|
"""
|
||||||
|
Delete the Last.fm session
|
||||||
|
"""
|
||||||
|
config = UserConfig()
|
||||||
|
current_user = get_current_userid()
|
||||||
|
config.lastfmSessionKeys[str(current_user)] = ""
|
||||||
|
config.lastfmSessionKeys = config.lastfmSessionKeys
|
||||||
|
|
||||||
|
return {"status": "success"}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
from typing import Literal
|
||||||
|
from flask_openapi3 import Tag
|
||||||
|
from flask_openapi3 import APIBlueprint
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.db.userdata import MixTable
|
||||||
|
from app.plugins.mixes import MixesPlugin
|
||||||
|
from app.store.homepage import HomepageStore
|
||||||
|
from app.store.tracks import TrackStore
|
||||||
|
|
||||||
|
|
||||||
|
bp_tag = Tag(name="Mixes Plugin", description="Mixes plugin hehe")
|
||||||
|
api = APIBlueprint(
|
||||||
|
"mixesplugin", __name__, url_prefix="/plugins/mixes", abp_tags=[bp_tag]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GetMixesBody(BaseModel):
|
||||||
|
mixtype: Literal["artists", "tracks"] = Field(description="The type of mix")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/<mixtype>")
|
||||||
|
def get_artist_mixes(path: GetMixesBody):
|
||||||
|
srcmixes = MixTable.get_all(with_userid=True)
|
||||||
|
mixes = []
|
||||||
|
|
||||||
|
if path.mixtype == "artists":
|
||||||
|
mixes = [mix.to_dict(convert_timestamp=True) for mix in srcmixes]
|
||||||
|
elif path.mixtype == "tracks":
|
||||||
|
plugin = MixesPlugin()
|
||||||
|
|
||||||
|
for mix in srcmixes:
|
||||||
|
custom_mix = plugin.get_track_mix(mix)
|
||||||
|
if custom_mix:
|
||||||
|
mixes.append(custom_mix.to_dict(convert_timestamp=True))
|
||||||
|
|
||||||
|
seen_mixids = set()
|
||||||
|
|
||||||
|
# filter duplicates by trackshash
|
||||||
|
final_mixes = []
|
||||||
|
for mix in mixes:
|
||||||
|
# INFO: Ignore duplicates for artist mixes
|
||||||
|
if mix["id"] in seen_mixids and path.mixtype == "tracks":
|
||||||
|
continue
|
||||||
|
|
||||||
|
final_mixes.append(mix)
|
||||||
|
seen_mixids.add(mix["id"])
|
||||||
|
|
||||||
|
return final_mixes
|
||||||
|
|
||||||
|
|
||||||
|
class MixQuery(BaseModel):
|
||||||
|
mixid: str = Field(description="The mix id")
|
||||||
|
sourcehash: str = Field(description="The sourcehash of the mix")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/")
|
||||||
|
def get_mix(query: MixQuery):
|
||||||
|
mixtype = ""
|
||||||
|
|
||||||
|
match query.mixid[0]:
|
||||||
|
case "a":
|
||||||
|
mixtype = "artist_mixes"
|
||||||
|
case "t":
|
||||||
|
mixtype = "custom_mixes"
|
||||||
|
case _:
|
||||||
|
return {"msg": "Invalid mix ID"}, 400
|
||||||
|
|
||||||
|
# INFO: Check if the mix is already in the homepage store
|
||||||
|
mix = HomepageStore.get_mix(mixtype, query.mixid)
|
||||||
|
if mix and mix["sourcehash"] == query.sourcehash:
|
||||||
|
return mix, 200
|
||||||
|
|
||||||
|
# INF0: Get the mix from the db
|
||||||
|
mix = MixTable.get_by_sourcehash(query.sourcehash)
|
||||||
|
|
||||||
|
if not mix:
|
||||||
|
return {"msg": "Mix not found"}, 404
|
||||||
|
|
||||||
|
if mixtype == "custom_mixes":
|
||||||
|
mix = MixesPlugin.get_track_mix(mix)
|
||||||
|
|
||||||
|
if not mix:
|
||||||
|
return {"msg": "Mix not found"}, 404
|
||||||
|
|
||||||
|
return mix.to_full_dict(), 200
|
||||||
|
|
||||||
|
|
||||||
|
class SaveMixRequest(BaseModel):
|
||||||
|
mixid: str = Field(description="The id of the mix")
|
||||||
|
type: str = Field(description="The type of mix")
|
||||||
|
sourcehash: str = Field(description="The sourcehash of the mix")
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/save")
|
||||||
|
def save_mix(body: SaveMixRequest):
|
||||||
|
mix_type = body.type
|
||||||
|
mix_sourcehash = body.sourcehash
|
||||||
|
|
||||||
|
if mix_type == "artist":
|
||||||
|
state = MixTable.save_artist_mix(mix_sourcehash)
|
||||||
|
elif mix_type == "track":
|
||||||
|
state = MixTable.save_track_mix(mix_sourcehash)
|
||||||
|
|
||||||
|
mix = HomepageStore.find_mix(body.mixid)
|
||||||
|
|
||||||
|
if mix:
|
||||||
|
mix.saved = state
|
||||||
|
return {"msg": "Mixes saved"}, 200
|
||||||
@@ -1,23 +1,19 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
from gettext import ngettext
|
from gettext import ngettext
|
||||||
from itertools import groupby
|
|
||||||
from math import e
|
|
||||||
from pprint import pprint
|
|
||||||
from flask_openapi3 import Tag
|
from flask_openapi3 import Tag
|
||||||
from flask_openapi3 import APIBlueprint
|
from flask_openapi3 import APIBlueprint
|
||||||
import pendulum
|
import pendulum
|
||||||
from pydantic import Field, BaseModel
|
from pydantic import Field, BaseModel
|
||||||
from app.api.apischemas import TrackHashSchema
|
from app.api.apischemas import TrackHashSchema
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from collections import defaultdict
|
|
||||||
import locale
|
import locale
|
||||||
|
|
||||||
from app.db.userdata import FavoritesTable, ScrobbleTable
|
from app.db.userdata import FavoritesTable, ScrobbleTable
|
||||||
from app.lib.extras import get_extra_info
|
from app.lib.extras import get_extra_info
|
||||||
|
from app.lib.recipes.recents import RecentlyPlayed
|
||||||
from app.models.album import Album
|
from app.models.album import Album
|
||||||
from app.models.stats import StatItem
|
from app.models.stats import StatItem
|
||||||
from app.models.track import Track
|
from app.models.track import Track
|
||||||
|
from app.plugins.lastfm import LastFmPlugin
|
||||||
from app.serializers.artist import serialize_for_card
|
from app.serializers.artist import serialize_for_card
|
||||||
from app.serializers.album import serialize_for_card as serialize_for_album_card
|
from app.serializers.album import serialize_for_card as serialize_for_album_card
|
||||||
from app.serializers.track import serialize_track, serialize_tracks
|
from app.serializers.track import serialize_track, serialize_tracks
|
||||||
@@ -77,9 +73,14 @@ def log_track(body: LogTrackBody):
|
|||||||
return {"msg": "Track not found."}, 404
|
return {"msg": "Track not found."}, 404
|
||||||
|
|
||||||
scrobble_data = dict(body)
|
scrobble_data = dict(body)
|
||||||
|
# REVIEW: Do we need to store the extra info in the database?
|
||||||
|
# OR .... can we just write it to the backup file on demand?
|
||||||
scrobble_data["extra"] = get_extra_info(body.trackhash, "track")
|
scrobble_data["extra"] = get_extra_info(body.trackhash, "track")
|
||||||
ScrobbleTable.add(scrobble_data)
|
ScrobbleTable.add(scrobble_data)
|
||||||
|
|
||||||
|
# NOTE: Update the recently played homepage for this userid
|
||||||
|
RecentlyPlayed(userid=scrobble_data["userid"])
|
||||||
|
|
||||||
# Update play data on the in-memory stores
|
# Update play data on the in-memory stores
|
||||||
track = trackentry.tracks[0]
|
track = trackentry.tracks[0]
|
||||||
album = AlbumStore.albummap.get(track.albumhash)
|
album = AlbumStore.albummap.get(track.albumhash)
|
||||||
@@ -93,9 +94,20 @@ def log_track(body: LogTrackBody):
|
|||||||
if artist:
|
if artist:
|
||||||
artist.increment_playcount(duration, timestamp)
|
artist.increment_playcount(duration, timestamp)
|
||||||
|
|
||||||
track = TrackStore.trackhashmap.get(body.trackhash)
|
trackentry.increment_playcount(duration, timestamp)
|
||||||
if track:
|
track = trackentry.tracks[0]
|
||||||
track.increment_playcount(duration, timestamp)
|
|
||||||
|
lastfm = LastFmPlugin()
|
||||||
|
|
||||||
|
print(track.duration / 2, 240, body.duration, "\n")
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastfm.enabled
|
||||||
|
and track.duration > 30
|
||||||
|
and body.duration >= min(track.duration / 2, 240)
|
||||||
|
# SEE: https://www.last.fm/api/scrobbling#when-is-a-scrobble-a-scrobble
|
||||||
|
):
|
||||||
|
lastfm.scrobble(trackentry.tracks[0], timestamp)
|
||||||
|
|
||||||
return {"msg": "recorded"}, 201
|
return {"msg": "recorded"}, 201
|
||||||
|
|
||||||
@@ -344,7 +356,11 @@ def get_stats():
|
|||||||
if len(tracks) > 0
|
if len(tracks) > 0
|
||||||
else "—"
|
else "—"
|
||||||
),
|
),
|
||||||
tracks[0].image if len(tracks) > 0 else None,
|
(
|
||||||
|
tracks[0].image
|
||||||
|
if len(tracks) > 0
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
fav_count = FavoritesTable.count_favs_in_period(start_time, end_time)
|
fav_count = FavoritesTable.count_favs_in_period(start_time, end_time)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.db.userdata import PluginTable
|
|||||||
from app.lib.index import index_everything
|
from app.lib.index import index_everything
|
||||||
from app.settings import Info
|
from app.settings import Info
|
||||||
from app.config import UserConfig
|
from app.config import UserConfig
|
||||||
|
from app.utils.auth import get_current_userid
|
||||||
|
|
||||||
bp_tag = Tag(name="Settings", description="Customize stuff")
|
bp_tag = Tag(name="Settings", description="Customize stuff")
|
||||||
api = APIBlueprint("settings", __name__, url_prefix="/notsettings", abp_tags=[bp_tag])
|
api = APIBlueprint("settings", __name__, url_prefix="/notsettings", abp_tags=[bp_tag])
|
||||||
@@ -98,6 +99,10 @@ def get_all_settings():
|
|||||||
config["plugins"] = plugins
|
config["plugins"] = plugins
|
||||||
config["version"] = Info.SWINGMUSIC_APP_VERSION
|
config["version"] = Info.SWINGMUSIC_APP_VERSION
|
||||||
|
|
||||||
|
# hide lastfmSessionKeys for other users
|
||||||
|
current_user = get_current_userid()
|
||||||
|
config["lastfmSessionKey"] = config["lastfmSessionKeys"].get(str(current_user), "")
|
||||||
|
del config["lastfmSessionKeys"]
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ class UserConfig:
|
|||||||
"AC/DC",
|
"AC/DC",
|
||||||
"Bob marley & the wailers",
|
"Bob marley & the wailers",
|
||||||
"Crosby, Stills, Nash & Young",
|
"Crosby, Stills, Nash & Young",
|
||||||
|
"Smith & Thell",
|
||||||
|
"Peter, Paul & Mary",
|
||||||
|
"Simon & Garfunkel",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
genreSeparators: set[str] = field(default_factory=lambda: {"/", ";", "&"})
|
genreSeparators: set[str] = field(default_factory=lambda: {"/", ";", "&"})
|
||||||
@@ -48,6 +51,9 @@ class UserConfig:
|
|||||||
|
|
||||||
# plugins
|
# plugins
|
||||||
enablePlugins: bool = True
|
enablePlugins: bool = True
|
||||||
|
lastfmApiKey: str = "0553005e93f9a4b4819d835182181806"
|
||||||
|
lastfmApiSecret: str = "5e5306fbf3e8e3bc92f039b6c6c4bd4e"
|
||||||
|
lastfmSessionKeys: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import time
|
||||||
|
import schedule
|
||||||
|
|
||||||
|
from app.crons.mixes import Mixes
|
||||||
|
from app.lib.recipes.recents import RecentlyAdded, RecentlyPlayed
|
||||||
|
from app.lib.recipes.topstreamed import TopArtists
|
||||||
|
from app.utils.threading import background
|
||||||
|
|
||||||
|
|
||||||
|
@background
|
||||||
|
def start_cron_jobs():
|
||||||
|
"""
|
||||||
|
This is the function that triggers the cron jobs.
|
||||||
|
"""
|
||||||
|
# NOTE: RecentlyPlayed is not a CRON job, it's triggered here to
|
||||||
|
# populate the values for the very first time.
|
||||||
|
RecentlyPlayed()
|
||||||
|
RecentlyAdded()
|
||||||
|
|
||||||
|
# Initialized CRON jobs
|
||||||
|
TopArtists()
|
||||||
|
TopArtists(duration="week")
|
||||||
|
Mixes()
|
||||||
|
|
||||||
|
# Trigger all CRON jobs when the app is started.
|
||||||
|
schedule.run_all()
|
||||||
|
|
||||||
|
# Run all CRON jobs on a loop.
|
||||||
|
while True:
|
||||||
|
schedule.run_pending()
|
||||||
|
time.sleep(1)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import schedule
|
||||||
|
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
|
class CronJob(ABC):
|
||||||
|
"""
|
||||||
|
A cron job that will be run on a regular interval.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
hours: int = 1
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
schedule.every(self.hours).hours.do(self.run)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def run(self):
|
||||||
|
"""
|
||||||
|
The function that will be called by the cron job.
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from app.crons.cron import CronJob
|
||||||
|
from app.lib.recipes.artistmixes import ArtistMixes
|
||||||
|
from app.lib.recipes.because import BecauseYouListened
|
||||||
|
|
||||||
|
|
||||||
|
class Mixes(CronJob):
|
||||||
|
"""
|
||||||
|
This cron job creates mixes displayed on the homepage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = "mixes"
|
||||||
|
hours: int = 6
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""
|
||||||
|
Creates the artist mixes
|
||||||
|
"""
|
||||||
|
print("⭐⭐⭐⭐ Mixes cron job running")
|
||||||
|
ArtistMixes()
|
||||||
|
|
||||||
|
# INFO: Because you listened to artist items are generated using
|
||||||
|
# the artist mixes, so run them after the artist mixes are created.
|
||||||
|
BecauseYouListened()
|
||||||
@@ -8,92 +8,6 @@ from sqlalchemy.orm import Mapped, mapped_column
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
# def create_all():
|
|
||||||
# """
|
|
||||||
# Create all the tables defined in this file.
|
|
||||||
|
|
||||||
# NOTE: We need this function because the MasterBase does not collect
|
|
||||||
# the tables defined here (as they are grand-children of the MasterBase)
|
|
||||||
# """
|
|
||||||
# Base.metadata.create_all(DbEngine.engine)
|
|
||||||
|
|
||||||
|
|
||||||
# class Base(MasterBase, DeclarativeBase):
|
|
||||||
# pass
|
|
||||||
# @classmethod
|
|
||||||
# def get_all_hashes(cls, create_date: int | None = None):
|
|
||||||
# with DbEngine.manager() as conn:
|
|
||||||
# if create_date:
|
|
||||||
# if cls.__tablename__ == "track":
|
|
||||||
# stmt = select(TrackTable.trackhash).where(
|
|
||||||
# cls.last_mod < create_date
|
|
||||||
# )
|
|
||||||
# elif cls.__tablename__ == "album":
|
|
||||||
# stmt = select(AlbumTable.albumhash).where(
|
|
||||||
# cls.created_date < create_date
|
|
||||||
# )
|
|
||||||
# elif cls.__tablename__ == "artist":
|
|
||||||
# stmt = select(ArtistTable.artisthash).where(
|
|
||||||
# cls.created_date < create_date
|
|
||||||
# )
|
|
||||||
# else:
|
|
||||||
# if cls.__tablename__ == "track":
|
|
||||||
# stmt = select(TrackTable.trackhash)
|
|
||||||
# elif cls.__tablename__ == "album":
|
|
||||||
# stmt = select(AlbumTable.albumhash)
|
|
||||||
# elif cls.__tablename__ == "artist":
|
|
||||||
# stmt = select(ArtistTable.artisthash)
|
|
||||||
|
|
||||||
# result = conn.execute(stmt)
|
|
||||||
# return {row[0] for row in result.fetchall()}
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def set_is_favorite(cls, hash: str, is_favorite: bool):
|
|
||||||
# """
|
|
||||||
# Set the 'is_favorite' flag for a specific hash.
|
|
||||||
|
|
||||||
# Args:
|
|
||||||
# hash (str): The hash value.
|
|
||||||
# is_favorite (bool): The value of the 'is_favorite' flag.
|
|
||||||
# """
|
|
||||||
# with DbEngine.manager(commit=True) as conn:
|
|
||||||
# if cls.__tablename__ == "track":
|
|
||||||
# stmt = (
|
|
||||||
# update(cls)
|
|
||||||
# .where(TrackTable.trackhash == hash)
|
|
||||||
# .values(is_favorite=is_favorite)
|
|
||||||
# )
|
|
||||||
# elif cls.__tablename__ == "album":
|
|
||||||
# stmt = (
|
|
||||||
# update(cls)
|
|
||||||
# .where(AlbumTable.albumhash == hash)
|
|
||||||
# .values(is_favorite=is_favorite)
|
|
||||||
# )
|
|
||||||
# elif cls.__tablename__ == "artist":
|
|
||||||
# stmt = (
|
|
||||||
# update(cls)
|
|
||||||
# .where(ArtistTable.artisthash == hash)
|
|
||||||
# .values(is_favorite=is_favorite)
|
|
||||||
# )
|
|
||||||
|
|
||||||
# conn.execute(stmt)
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def increment_scrobblecount(
|
|
||||||
# cls, table: Any, field: Any, hash: str, duration: int, timestamp: int
|
|
||||||
# ):
|
|
||||||
# cls.execute(
|
|
||||||
# update(table)
|
|
||||||
# .where(field == hash)
|
|
||||||
# .values(
|
|
||||||
# playcount=table.playcount + 1,
|
|
||||||
# playduration=table.playduration + duration,
|
|
||||||
# lastplayed=timestamp,
|
|
||||||
# ),
|
|
||||||
# commit=True,
|
|
||||||
# )
|
|
||||||
|
|
||||||
|
|
||||||
class TrackTable(Base):
|
class TrackTable(Base):
|
||||||
__tablename__ = "track"
|
__tablename__ = "track"
|
||||||
|
|
||||||
@@ -101,7 +15,6 @@ class TrackTable(Base):
|
|||||||
album: Mapped[str] = mapped_column(String())
|
album: Mapped[str] = mapped_column(String())
|
||||||
albumartists: Mapped[str] = mapped_column(String())
|
albumartists: Mapped[str] = mapped_column(String())
|
||||||
albumhash: Mapped[str] = mapped_column(String(), index=True)
|
albumhash: Mapped[str] = mapped_column(String(), index=True)
|
||||||
# artisthashes: Mapped[list[str]] = mapped_column(JSON(), index=True)
|
|
||||||
artists: Mapped[str] = mapped_column(String())
|
artists: Mapped[str] = mapped_column(String())
|
||||||
bitrate: Mapped[int] = mapped_column(Integer())
|
bitrate: Mapped[int] = mapped_column(Integer())
|
||||||
copyright: Mapped[Optional[str]] = mapped_column(String())
|
copyright: Mapped[Optional[str]] = mapped_column(String())
|
||||||
@@ -110,11 +23,8 @@ class TrackTable(Base):
|
|||||||
duration: Mapped[int] = mapped_column(Integer())
|
duration: Mapped[int] = mapped_column(Integer())
|
||||||
filepath: Mapped[str] = mapped_column(String(), index=True, unique=True)
|
filepath: Mapped[str] = mapped_column(String(), index=True, unique=True)
|
||||||
folder: Mapped[str] = mapped_column(String(), index=True)
|
folder: Mapped[str] = mapped_column(String(), index=True)
|
||||||
# genrehashes: Mapped[list[str]] = mapped_column(JSON(), index=True)
|
|
||||||
genres: Mapped[Optional[str]] = mapped_column(String())
|
genres: Mapped[Optional[str]] = mapped_column(String())
|
||||||
last_mod: Mapped[float] = mapped_column(Integer())
|
last_mod: Mapped[float] = mapped_column(Integer())
|
||||||
# og_album: Mapped[str] = mapped_column(String())
|
|
||||||
# og_title: Mapped[str] = mapped_column(String())
|
|
||||||
title: Mapped[str] = mapped_column(String())
|
title: Mapped[str] = mapped_column(String())
|
||||||
track: Mapped[int] = mapped_column(Integer())
|
track: Mapped[int] = mapped_column(Integer())
|
||||||
trackhash: Mapped[str] = mapped_column(String(), index=True)
|
trackhash: Mapped[str] = mapped_column(String(), index=True)
|
||||||
@@ -141,45 +51,6 @@ class TrackTable(Base):
|
|||||||
)
|
)
|
||||||
return tracks_to_dataclasses(result.fetchall())
|
return tracks_to_dataclasses(result.fetchall())
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def get_tracks_by_albumhash(cls, albumhash: str):
|
|
||||||
# with DbEngine.manager() as conn:
|
|
||||||
# result = conn.execute(
|
|
||||||
# select(TrackTable).where(TrackTable.albumhash == albumhash)
|
|
||||||
# )
|
|
||||||
# tracks = tracks_to_dataclasses(result.fetchall())
|
|
||||||
# return remove_duplicates(tracks, is_album_tracks=True)
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def get_track_by_trackhash(cls, hash: str, filepath: str = ""):
|
|
||||||
# with DbEngine.manager() as conn:
|
|
||||||
# if filepath:
|
|
||||||
# result = conn.execute(
|
|
||||||
# select(TrackTable)
|
|
||||||
# .where(
|
|
||||||
# (TrackTable.trackhash == hash)
|
|
||||||
# & (TrackTable.filepath == filepath),
|
|
||||||
# )
|
|
||||||
# .order_by(TrackTable.bitrate.desc())
|
|
||||||
# )
|
|
||||||
# else:
|
|
||||||
# result = conn.execute(
|
|
||||||
# select(TrackTable).where(TrackTable.trackhash == hash)
|
|
||||||
# )
|
|
||||||
|
|
||||||
# track = result.fetchone()
|
|
||||||
|
|
||||||
# if track:
|
|
||||||
# return track_to_dataclass(track)
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def get_tracks_by_artisthash(cls, artisthash: str):
|
|
||||||
# with DbEngine.manager() as conn:
|
|
||||||
# result = conn.execute(
|
|
||||||
# select(TrackTable).where(TrackTable.artists.contains(artisthash))
|
|
||||||
# )
|
|
||||||
# return tracks_to_dataclasses(result.fetchall())
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_tracks_in_path(cls, path: str):
|
def get_tracks_in_path(cls, path: str):
|
||||||
with DbEngine.manager() as conn:
|
with DbEngine.manager() as conn:
|
||||||
@@ -190,230 +61,7 @@ class TrackTable(Base):
|
|||||||
)
|
)
|
||||||
return tracks_to_dataclasses(result.fetchall())
|
return tracks_to_dataclasses(result.fetchall())
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def get_tracks_by_trackhashes(cls, hashes: Iterable[str], limit: int | None = None):
|
|
||||||
# with DbEngine.manager() as conn:
|
|
||||||
# result = conn.execute(
|
|
||||||
# select(TrackTable)
|
|
||||||
# .where(TrackTable.trackhash.in_(hashes))
|
|
||||||
# .group_by(TrackTable.trackhash)
|
|
||||||
# .limit(limit)
|
|
||||||
# )
|
|
||||||
# tracks = tracks_to_dataclasses(result.fetchall())
|
|
||||||
|
|
||||||
# # order the tracks in the same order as the hashes
|
|
||||||
# if type(hashes) == list:
|
|
||||||
# return sorted(tracks, key=lambda x: hashes.index(x.trackhash))
|
|
||||||
|
|
||||||
# return tracks
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def get_recently_added(cls, start: int, limit: int):
|
|
||||||
# with DbEngine.manager() as conn:
|
|
||||||
# result = conn.execute(
|
|
||||||
# select(TrackTable)
|
|
||||||
# .order_by(TrackTable.last_mod.desc())
|
|
||||||
# .offset(start)
|
|
||||||
# .limit(limit)
|
|
||||||
# )
|
|
||||||
|
|
||||||
# return tracks_to_dataclasses(result.fetchall())
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
# def get_recently_played(cls, limit: int):
|
|
||||||
# result = cls.execute(
|
|
||||||
# select(cls)
|
|
||||||
# .group_by(cls.trackhash)
|
|
||||||
# .order_by(cls.lastplayed.desc())
|
|
||||||
# .limit(limit)
|
|
||||||
# )
|
|
||||||
# return tracks_to_dataclasses(result.fetchall())
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def remove_tracks_by_filepaths(cls, filepaths: set[str]):
|
def remove_tracks_by_filepaths(cls, filepaths: set[str]):
|
||||||
with DbEngine.manager(commit=True) as conn:
|
with DbEngine.manager(commit=True) as conn:
|
||||||
conn.execute(delete(TrackTable).where(TrackTable.filepath.in_(filepaths)))
|
conn.execute(delete(TrackTable).where(TrackTable.filepath.in_(filepaths)))
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def increment_playcount(cls, trackhash: str, duration: int, timestamp: int):
|
|
||||||
# cls.increment_scrobblecount(
|
|
||||||
# TrackTable, TrackTable.trackhash, trackhash, duration, timestamp
|
|
||||||
# )
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def update_artist_separators(cls, separators: set[str]):
|
|
||||||
# tracks = cls.get_all()
|
|
||||||
|
|
||||||
# with DbEngine.manager(commit=True) as conn:
|
|
||||||
# for track in tracks:
|
|
||||||
# track.split_artists(separators)
|
|
||||||
# conn.execute(
|
|
||||||
# update(cls)
|
|
||||||
# .where(cls.trackhash == track.trackhash)
|
|
||||||
# .values(artists=track.artists, artisthashes=track.artisthashes)
|
|
||||||
# )
|
|
||||||
|
|
||||||
|
|
||||||
# class AlbumTable(Base):
|
|
||||||
# __tablename__ = "album"
|
|
||||||
|
|
||||||
# id: Mapped[int] = mapped_column(primary_key=True)
|
|
||||||
# albumartists: Mapped[list[dict[str, str]]] = mapped_column(JSON(), index=True)
|
|
||||||
# artisthashes: Mapped[list[str]] = mapped_column(JSON(), index=True)
|
|
||||||
# albumhash: Mapped[str] = mapped_column(String(), unique=True, index=True)
|
|
||||||
# base_title: Mapped[str] = mapped_column(String())
|
|
||||||
# color: Mapped[Optional[str]] = mapped_column(String())
|
|
||||||
# created_date: Mapped[int] = mapped_column(Integer())
|
|
||||||
# date: Mapped[int] = mapped_column(Integer())
|
|
||||||
# duration: Mapped[int] = mapped_column(Integer())
|
|
||||||
# genrehashes: Mapped[list[str]] = mapped_column(JSON(), nullable=True, index=True)
|
|
||||||
# genres: Mapped[str] = mapped_column(JSON())
|
|
||||||
# og_title: Mapped[str] = mapped_column(String())
|
|
||||||
# title: Mapped[str] = mapped_column(String())
|
|
||||||
# trackcount: Mapped[int] = mapped_column(Integer())
|
|
||||||
# lastplayed: Mapped[int] = mapped_column(Integer(), default=0)
|
|
||||||
# playcount: Mapped[int] = mapped_column(Integer(), default=0)
|
|
||||||
# playduration: Mapped[int] = mapped_column(Integer(), default=0)
|
|
||||||
# extra: Mapped[Optional[dict[str, Any]]] = mapped_column(
|
|
||||||
# JSON(), default_factory=dict
|
|
||||||
# )
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def get_all(cls):
|
|
||||||
# with DbEngine.manager() as conn:
|
|
||||||
# result = conn.execute(select(AlbumTable))
|
|
||||||
# all = result.fetchall()
|
|
||||||
# return albums_to_dataclasses(all)
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def get_album_by_albumhash(cls, hash: str):
|
|
||||||
# with DbEngine.manager() as conn:
|
|
||||||
# result = conn.execute(
|
|
||||||
# select(AlbumTable).where(AlbumTable.albumhash == hash)
|
|
||||||
# )
|
|
||||||
# album = result.fetchone()
|
|
||||||
|
|
||||||
# if album:
|
|
||||||
# return album_to_dataclass(album)
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def get_albums_by_albumhashes(cls, hashes: Iterable[str], limit: int | None = None):
|
|
||||||
# with DbEngine.manager() as conn:
|
|
||||||
# result = conn.execute(
|
|
||||||
# select(AlbumTable).where(AlbumTable.albumhash.in_(hashes)).limit(limit)
|
|
||||||
# )
|
|
||||||
# albums = albums_to_dataclasses(result.fetchall())
|
|
||||||
|
|
||||||
# # order the albums in the same order as the hashes
|
|
||||||
# if type(hashes) == list:
|
|
||||||
# return sorted(albums, key=lambda x: hashes.index(x.albumhash))
|
|
||||||
|
|
||||||
# return albums
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def get_albums_by_artisthashes(cls, artisthashes: list[str]):
|
|
||||||
# with DbEngine.manager() as conn:
|
|
||||||
# albums: dict[str, list[AlbumModel]] = {}
|
|
||||||
|
|
||||||
# for artist in artisthashes:
|
|
||||||
# result = conn.execute(
|
|
||||||
# select(AlbumTable).where(AlbumTable.artisthashes.contains(artist))
|
|
||||||
# )
|
|
||||||
# albums[artist] = albums_to_dataclasses(result.fetchall())
|
|
||||||
|
|
||||||
# return albums
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def get_albums_by_base_title(cls, base_title: str):
|
|
||||||
# with DbEngine.manager() as conn:
|
|
||||||
# result = conn.execute(
|
|
||||||
# select(AlbumTable).where(AlbumTable.base_title == base_title)
|
|
||||||
# )
|
|
||||||
# return albums_to_dataclasses(result.fetchall())
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def get_albums_by_artisthash(cls, artisthash: str):
|
|
||||||
# with DbEngine.manager() as conn:
|
|
||||||
# result = conn.execute(
|
|
||||||
# select(AlbumTable).where(AlbumTable.artisthashes.contains(artisthash))
|
|
||||||
# )
|
|
||||||
# return albums_to_dataclasses(result.all())
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def increment_playcount(cls, albumhash: str, duration: int, timestamp: int):
|
|
||||||
# return cls.increment_scrobblecount(
|
|
||||||
# AlbumTable, AlbumTable.albumhash, albumhash, duration, timestamp
|
|
||||||
# )
|
|
||||||
|
|
||||||
|
|
||||||
# class ArtistTable(Base):
|
|
||||||
# __tablename__ = "artist"
|
|
||||||
|
|
||||||
# id: Mapped[int] = mapped_column(primary_key=True)
|
|
||||||
# albumcount: Mapped[int] = mapped_column(Integer())
|
|
||||||
# artisthash: Mapped[str] = mapped_column(String(), unique=True, index=True)
|
|
||||||
# created_date: Mapped[int] = mapped_column(Integer())
|
|
||||||
# date: Mapped[int] = mapped_column(Integer())
|
|
||||||
# duration: Mapped[int] = mapped_column(Integer())
|
|
||||||
# genrehashes: Mapped[list[str]] = mapped_column(JSON(), nullable=True, index=True)
|
|
||||||
# genres: Mapped[str] = mapped_column(JSON())
|
|
||||||
# name: Mapped[str] = mapped_column(String(), index=True)
|
|
||||||
# trackcount: Mapped[int] = mapped_column(Integer())
|
|
||||||
# lastplayed: Mapped[int] = mapped_column(Integer(), default=0)
|
|
||||||
# playcount: Mapped[int] = mapped_column(Integer(), default=0)
|
|
||||||
# playduration: Mapped[int] = mapped_column(Integer(), default=0)
|
|
||||||
# extra: Mapped[Optional[dict[str, Any]]] = mapped_column(
|
|
||||||
# JSON(), default_factory=dict
|
|
||||||
# )
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def get_all(cls):
|
|
||||||
# with DbEngine.manager() as conn:
|
|
||||||
# result = conn.execute(select(cls))
|
|
||||||
# all = result.fetchall()
|
|
||||||
# return artists_to_dataclasses(all)
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def get_artist_by_hash(cls, artisthash: str):
|
|
||||||
# with DbEngine.manager() as conn:
|
|
||||||
# result = conn.execute(
|
|
||||||
# select(ArtistTable).where(ArtistTable.artisthash == artisthash)
|
|
||||||
# )
|
|
||||||
# return artist_to_dataclass(result.fetchone())
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def get_artisthashes_not_in(cls, artisthashes: list[str]):
|
|
||||||
# with DbEngine.manager() as conn:
|
|
||||||
# result = conn.execute(
|
|
||||||
# select(ArtistTable.artisthash, ArtistTable.name).where(
|
|
||||||
# ~ArtistTable.artisthash.in_(artisthashes)
|
|
||||||
# )
|
|
||||||
# )
|
|
||||||
# return [{"artisthash": row[0], "name": row[1]} for row in result.fetchall()]
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def get_artists_by_artisthashes(
|
|
||||||
# cls, hashes: Iterable[str], limit: int | None = None
|
|
||||||
# ):
|
|
||||||
# with DbEngine.manager() as conn:
|
|
||||||
# result = conn.execute(
|
|
||||||
# select(ArtistTable)
|
|
||||||
# .where(ArtistTable.artisthash.in_(hashes))
|
|
||||||
# .limit(limit)
|
|
||||||
# )
|
|
||||||
# return artists_to_dataclasses(result.fetchall())
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# def increment_playcount(
|
|
||||||
# cls, artisthashes: list[str], duration: int, timestamp: int
|
|
||||||
# ):
|
|
||||||
# cls.execute(
|
|
||||||
# update(cls)
|
|
||||||
# .where(ArtistTable.artisthash.in_(artisthashes))
|
|
||||||
# .values(
|
|
||||||
# playcount=ArtistTable.playcount + 1,
|
|
||||||
# playduration=ArtistTable.playduration + duration,
|
|
||||||
# lastplayed=timestamp,
|
|
||||||
# ),
|
|
||||||
# commit=True,
|
|
||||||
# )
|
|
||||||
|
|||||||
+141
-10
@@ -1,3 +1,4 @@
|
|||||||
|
from dataclasses import asdict
|
||||||
import datetime
|
import datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
@@ -25,12 +26,14 @@ from app.db.utils import (
|
|||||||
plugin_to_dataclasses,
|
plugin_to_dataclasses,
|
||||||
similar_artist_to_dataclass,
|
similar_artist_to_dataclass,
|
||||||
similar_artists_to_dataclass,
|
similar_artists_to_dataclass,
|
||||||
|
tracklog_to_dataclass,
|
||||||
tracklog_to_dataclasses,
|
tracklog_to_dataclasses,
|
||||||
user_to_dataclass,
|
user_to_dataclass,
|
||||||
user_to_dataclasses,
|
user_to_dataclasses,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
|
from app.models.mix import Mix
|
||||||
from app.utils.auth import get_current_userid, hash_password
|
from app.utils.auth import get_current_userid, hash_password
|
||||||
|
|
||||||
|
|
||||||
@@ -223,9 +226,7 @@ class FavoritesTable(Base):
|
|||||||
# .select_from(join(table, cls, field == cls.hash))
|
# .select_from(join(table, cls, field == cls.hash))
|
||||||
.where(and_(cls.type == type, cls.userid == get_current_userid()))
|
.where(and_(cls.type == type, cls.userid == get_current_userid()))
|
||||||
.order_by(cls.timestamp.desc())
|
.order_by(cls.timestamp.desc())
|
||||||
.offset(
|
.offset(start)
|
||||||
start
|
|
||||||
)
|
|
||||||
# INFO: If start is 0, fetch all so we can get the total count
|
# INFO: If start is 0, fetch all so we can get the total count
|
||||||
.limit(limit if start != 0 else None)
|
.limit(limit if start != 0 else None)
|
||||||
)
|
)
|
||||||
@@ -293,10 +294,10 @@ class ScrobbleTable(Base):
|
|||||||
return cls.insert_one(item)
|
return cls.insert_one(item)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_all(cls, start: int, limit: int | None = None):
|
def get_all(cls, start: int, limit: int | None = None, userid: int | None = None):
|
||||||
result = cls.execute(
|
result = cls.execute(
|
||||||
select(cls)
|
select(cls)
|
||||||
.where(cls.userid == get_current_userid())
|
.where(cls.userid == (userid if userid else get_current_userid()))
|
||||||
.order_by(cls.timestamp.desc())
|
.order_by(cls.timestamp.desc())
|
||||||
.offset(start)
|
.offset(start)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -305,15 +306,27 @@ class ScrobbleTable(Base):
|
|||||||
return tracklog_to_dataclasses(result.fetchall())
|
return tracklog_to_dataclasses(result.fetchall())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_all_in_period(cls, start_time: int, end_time: int):
|
def get_all_in_period(cls, start_time: int, end_time: int, userid: int | None):
|
||||||
|
# UserId will be None if function is called from the API
|
||||||
|
# In that case, we use the request userid
|
||||||
|
if userid is None:
|
||||||
|
userid = get_current_userid()
|
||||||
|
|
||||||
result = cls.execute(
|
result = cls.execute(
|
||||||
select(cls)
|
select(cls)
|
||||||
.where(cls.userid == get_current_userid())
|
.where(cls.userid == userid)
|
||||||
.where(and_(cls.timestamp >= start_time, cls.timestamp <= end_time))
|
.where(and_(cls.timestamp >= start_time, cls.timestamp <= end_time))
|
||||||
.order_by(cls.timestamp.desc())
|
.order_by(cls.timestamp.desc())
|
||||||
)
|
)
|
||||||
return tracklog_to_dataclasses(result.fetchall())
|
return tracklog_to_dataclasses(result.fetchall())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_last_entry(cls, userid: int):
|
||||||
|
result = cls.execute(
|
||||||
|
select(cls).where(cls.userid == userid).order_by(cls.timestamp.desc())
|
||||||
|
)
|
||||||
|
return tracklog_to_dataclass(result.fetchone())
|
||||||
|
|
||||||
|
|
||||||
class PlaylistTable(Base):
|
class PlaylistTable(Base):
|
||||||
__tablename__ = "playlist"
|
__tablename__ = "playlist"
|
||||||
@@ -332,8 +345,12 @@ class PlaylistTable(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_all(cls):
|
def get_all(cls, current_user: bool = True):
|
||||||
result = cls.all()
|
if current_user:
|
||||||
|
result = cls.execute(select(cls).where(cls.userid == get_current_userid()))
|
||||||
|
else:
|
||||||
|
result = cls.execute(select(cls))
|
||||||
|
|
||||||
return playlists_to_dataclasses(result)
|
return playlists_to_dataclasses(result)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -448,7 +465,7 @@ class LibDataTable(Base):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def find_one(cls, hash: str, type: Literal["album", "artist"]):
|
def find_one(cls, hash: str, type: Literal["album", "artist"]):
|
||||||
result = cls.execute(
|
result = cls.execute(
|
||||||
select(cls).where((cls.itemhash == hash) & (cls.itemtype == type))
|
select(cls).where((cls.itemhash == type + hash) & (cls.itemtype == type))
|
||||||
)
|
)
|
||||||
return result.fetchone()
|
return result.fetchone()
|
||||||
|
|
||||||
@@ -458,3 +475,117 @@ class LibDataTable(Base):
|
|||||||
select(cls.itemhash, cls.color).where(cls.itemtype == type)
|
select(cls.itemhash, cls.color).where(cls.itemtype == type)
|
||||||
)
|
)
|
||||||
return [{"itemhash": r[0], "color": r[1]} for r in result.fetchall()]
|
return [{"itemhash": r[0], "color": r[1]} for r in result.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
class MixTable(Base):
|
||||||
|
__tablename__ = "mix"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
mixid: Mapped[str] = mapped_column(String(), index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String())
|
||||||
|
description: Mapped[str] = mapped_column(String())
|
||||||
|
timestamp: Mapped[int] = mapped_column(Integer())
|
||||||
|
sourcehash: Mapped[str] = mapped_column(String(), unique=True, index=True)
|
||||||
|
userid: Mapped[int] = mapped_column(
|
||||||
|
Integer(), ForeignKey("user.id", ondelete="cascade"), index=True
|
||||||
|
)
|
||||||
|
saved: Mapped[bool] = mapped_column(Boolean(), default=False)
|
||||||
|
tracks: Mapped[list[str]] = mapped_column(JSON(), default_factory=list)
|
||||||
|
extra: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSON(), nullable=True, default_factory=dict
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_all(cls, with_userid: bool = False):
|
||||||
|
if with_userid:
|
||||||
|
result = cls.execute(
|
||||||
|
select(cls)
|
||||||
|
.where(cls.userid == get_current_userid())
|
||||||
|
.order_by(cls.timestamp.desc())
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = cls.execute(select(cls).order_by(cls.timestamp.desc()))
|
||||||
|
|
||||||
|
return Mix.mixes_to_dataclasses(result.fetchall())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_sourcehash(cls, sourcehash: str):
|
||||||
|
result = cls.execute(select(cls).where(cls.sourcehash == sourcehash))
|
||||||
|
|
||||||
|
res = result.fetchone()
|
||||||
|
if res:
|
||||||
|
return Mix.mix_to_dataclass(res)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_mixid(cls, mixid: str):
|
||||||
|
result = cls.execute(select(cls).where(cls.mixid == mixid))
|
||||||
|
res = result.fetchone()
|
||||||
|
if res:
|
||||||
|
return Mix.mix_to_dataclass(res)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def insert_one(cls, mix: Mix):
|
||||||
|
mixdict = asdict(mix)
|
||||||
|
mixdict["mixid"] = mix.id
|
||||||
|
del mixdict["id"]
|
||||||
|
|
||||||
|
return cls.execute(insert(cls).values(mixdict), commit=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_one(cls, mixid: str, mix: Mix):
|
||||||
|
mixdict = asdict(mix)
|
||||||
|
mixdict["mixid"] = mix.id
|
||||||
|
del mixdict["id"]
|
||||||
|
|
||||||
|
return cls.execute(
|
||||||
|
update(cls)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
cls.mixid == mixid,
|
||||||
|
cls.sourcehash == mix.sourcehash,
|
||||||
|
cls.userid == get_current_userid(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values(mixdict),
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def save_artist_mix(cls, sourcehash: str):
|
||||||
|
"""
|
||||||
|
Toggles the saved status of an artist mix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mix = cls.get_by_sourcehash(sourcehash)
|
||||||
|
|
||||||
|
if not mix:
|
||||||
|
return False
|
||||||
|
|
||||||
|
mix.saved = not mix.saved
|
||||||
|
cls.update_one(mix.id, mix)
|
||||||
|
|
||||||
|
return mix.saved
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_saved_track_mixes(cls):
|
||||||
|
"""
|
||||||
|
Return all mixes that have the extra.trackmix_saved set to True.
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = cls.execute(select(cls).where(cls.extra.c.trackmix_saved == True))
|
||||||
|
return Mix.mixes_to_dataclasses(result.fetchall())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def save_track_mix(cls, sourcehash: str):
|
||||||
|
"""
|
||||||
|
Toggles the property extra.trackmix_saved to True.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mix = cls.get_by_sourcehash(sourcehash)
|
||||||
|
if not mix:
|
||||||
|
return False
|
||||||
|
|
||||||
|
mix.extra["trackmix_saved"] = not mix.extra.get("trackmix_saved", False)
|
||||||
|
cls.update_one(mix.id, mix)
|
||||||
|
|
||||||
|
return mix.extra["trackmix_saved"]
|
||||||
|
|||||||
+8
-2
@@ -124,9 +124,15 @@ class ProcessArtistColors:
|
|||||||
artist.set_color(colors[0])
|
artist.set_color(colors[0])
|
||||||
|
|
||||||
# INFO: Write to the database.
|
# INFO: Write to the database.
|
||||||
|
print("RECORD")
|
||||||
|
print(record)
|
||||||
if record is None:
|
if record is None:
|
||||||
LibDataTable.insert_one(
|
LibDataTable.insert_one(
|
||||||
{"itemhash": artisthash, "color": colors[0], "itemtype": "artist"}
|
{
|
||||||
|
"itemhash": "artist" + artisthash,
|
||||||
|
"color": colors[0],
|
||||||
|
"itemtype": "artist",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
LibDataTable.update_one(artisthash, {"color": colors[0]})
|
LibDataTable.update_one("artist" + artisthash, {"color": colors[0]})
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from app.db.userdata import MixTable
|
||||||
|
from app.plugins.mixes import MixesPlugin
|
||||||
|
|
||||||
|
|
||||||
|
def find_mix(mixid: str, sourcehash: str):
|
||||||
|
"""
|
||||||
|
Find a mix in the homepage store or the db.
|
||||||
|
"""
|
||||||
|
from app.store.homepage import HomepageStore
|
||||||
|
|
||||||
|
mixtype = "custom_mixes" if mixid[0] == "t" else "artist_mixes"
|
||||||
|
|
||||||
|
# INFO: Try getting the mix from the homepage store
|
||||||
|
mix = HomepageStore.get_mix(mixtype, mixid)
|
||||||
|
if mix and mix["sourcehash"] == sourcehash:
|
||||||
|
return mix
|
||||||
|
|
||||||
|
# INFO: Get the mix from the db
|
||||||
|
mix = MixTable.get_by_sourcehash(sourcehash)
|
||||||
|
|
||||||
|
if not mix:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if mixtype == "custom_mixes":
|
||||||
|
mix = MixesPlugin.get_track_mix(mix)
|
||||||
|
|
||||||
|
if not mix:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return mix.to_dict()
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import os
|
||||||
|
from app.db.userdata import PlaylistTable
|
||||||
|
from app.lib.home import find_mix
|
||||||
|
from app.lib.home.recentlyadded import get_recently_added_playlist
|
||||||
|
from app.lib.home.recentlyplayed import get_recently_played_playlist
|
||||||
|
from app.models.logger import TrackLog
|
||||||
|
from app.store.albums import AlbumStore
|
||||||
|
from app.store.artists import ArtistStore
|
||||||
|
from app.store.tracks import TrackStore
|
||||||
|
|
||||||
|
|
||||||
|
def create_items(entries: list[TrackLog], limit: int):
|
||||||
|
custom_playlists = [
|
||||||
|
{"name": "recentlyadded", "handler": get_recently_added_playlist},
|
||||||
|
{"name": "recentlyplayed", "handler": get_recently_played_playlist},
|
||||||
|
]
|
||||||
|
|
||||||
|
items = []
|
||||||
|
added = set()
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
if entry.source in added:
|
||||||
|
continue
|
||||||
|
|
||||||
|
added.add(entry.source)
|
||||||
|
|
||||||
|
if entry.type == "mix":
|
||||||
|
if not entry.type_src:
|
||||||
|
continue
|
||||||
|
|
||||||
|
splits = entry.type_src.split(".")
|
||||||
|
|
||||||
|
try:
|
||||||
|
mixid = splits[0]
|
||||||
|
sourcehash = splits[1]
|
||||||
|
except IndexError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# INFO: Get mix from homepage store
|
||||||
|
mix = find_mix(mixid, sourcehash)
|
||||||
|
if not mix:
|
||||||
|
continue
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"type": "mix",
|
||||||
|
"hash": entry.type_src,
|
||||||
|
"timestamp": entry.timestamp,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if entry.type == "album":
|
||||||
|
album = AlbumStore.albummap.get(entry.type_src)
|
||||||
|
|
||||||
|
if album is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
item = {
|
||||||
|
"type": "album",
|
||||||
|
"hash": entry.type_src,
|
||||||
|
"timestamp": entry.timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
items.append(item)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if entry.type == "artist":
|
||||||
|
artist = ArtistStore.artistmap.get(entry.type_src)
|
||||||
|
|
||||||
|
if artist is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"type": "artist",
|
||||||
|
"hash": entry.type_src,
|
||||||
|
"timestamp": entry.timestamp,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
if entry.type == "folder":
|
||||||
|
folder = entry.type_src
|
||||||
|
|
||||||
|
if not folder:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not folder.endswith("/"):
|
||||||
|
folder += "/"
|
||||||
|
|
||||||
|
is_home_dir = entry.type_src == "$home"
|
||||||
|
|
||||||
|
if is_home_dir:
|
||||||
|
folder = os.path.expanduser("~")
|
||||||
|
|
||||||
|
item = {
|
||||||
|
"type": "folder",
|
||||||
|
"hash": entry.type_src,
|
||||||
|
"timestamp": entry.timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
items.append(item)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if entry.type == "playlist":
|
||||||
|
is_custom = entry.type_src in [i["name"] for i in custom_playlists]
|
||||||
|
|
||||||
|
if is_custom:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"type": "playlist",
|
||||||
|
"hash": entry.type_src,
|
||||||
|
"timestamp": entry.timestamp,
|
||||||
|
"is_custom": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
playlist = PlaylistTable.get_by_id(entry.type_src)
|
||||||
|
if playlist is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
item = {
|
||||||
|
"type": "playlist",
|
||||||
|
"hash": entry.type_src,
|
||||||
|
"timestamp": entry.timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
items.append(item)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if entry.type == "favorite":
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"type": "favorite",
|
||||||
|
"timestamp": entry.timestamp,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
t = TrackStore.trackhashmap.get(entry.trackhash)
|
||||||
|
|
||||||
|
if t is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
item = {
|
||||||
|
"type": "track",
|
||||||
|
"hash": entry.trackhash,
|
||||||
|
"timestamp": entry.timestamp,
|
||||||
|
}
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
return items
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from app.db.userdata import ScrobbleTable
|
||||||
|
from app.lib.home.create_items import create_items
|
||||||
|
from app.models.logger import TrackLog
|
||||||
|
|
||||||
|
|
||||||
|
def get_recently_played(
|
||||||
|
limit: int, userid: int | None = None, _entries: list[TrackLog] = []
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get the recently played items for the homepage.
|
||||||
|
|
||||||
|
Pass a list of track log entries to use a subset of the scrobble table.
|
||||||
|
"""
|
||||||
|
# TODO: Paginate this
|
||||||
|
items = []
|
||||||
|
|
||||||
|
BATCH_SIZE = 200
|
||||||
|
current_index = 0
|
||||||
|
|
||||||
|
if len(_entries):
|
||||||
|
entries = _entries
|
||||||
|
limit = 1
|
||||||
|
else:
|
||||||
|
entries = ScrobbleTable.get_all(0, BATCH_SIZE, userid=userid)
|
||||||
|
|
||||||
|
max_iterations = 20
|
||||||
|
iterations = 0
|
||||||
|
|
||||||
|
while len(items) < limit and iterations < max_iterations:
|
||||||
|
items.extend(create_items(entries, limit))
|
||||||
|
current_index += BATCH_SIZE
|
||||||
|
|
||||||
|
if len(items) < limit:
|
||||||
|
entries = ScrobbleTable.get_all(
|
||||||
|
start=current_index + 1, limit=BATCH_SIZE, userid=userid
|
||||||
|
)
|
||||||
|
if not entries:
|
||||||
|
break
|
||||||
|
|
||||||
|
iterations += 1
|
||||||
|
|
||||||
|
if iterations == max_iterations:
|
||||||
|
print(
|
||||||
|
f"Warning: Reached maximum iterations ({max_iterations}) while fetching recently played items"
|
||||||
|
)
|
||||||
|
|
||||||
|
return items
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from time import time
|
|
||||||
|
|
||||||
from app.lib.playlistlib import get_first_4_images
|
from app.lib.playlistlib import get_first_4_images
|
||||||
from app.models.playlist import Playlist
|
from app.models.playlist import Playlist
|
||||||
@@ -9,17 +8,12 @@ from app.store.tracks import TrackStore
|
|||||||
from app.store.albums import AlbumStore
|
from app.store.albums import AlbumStore
|
||||||
from app.store.artists import ArtistStore
|
from app.store.artists import ArtistStore
|
||||||
|
|
||||||
from app.serializers.track import serialize_track
|
|
||||||
from app.serializers.album import album_serializer
|
|
||||||
from app.serializers.artist import serialize_for_card
|
|
||||||
|
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
|
|
||||||
from app.utils import flatten
|
from app.utils import flatten
|
||||||
from app.utils.dates import (
|
from app.utils.dates import (
|
||||||
create_new_date,
|
create_new_date,
|
||||||
date_string_to_time_passed,
|
date_string_to_time_passed,
|
||||||
timestamp_to_time_passed,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
older_albums = set()
|
older_albums = set()
|
||||||
@@ -59,15 +53,13 @@ def create_track(t: Track):
|
|||||||
"""
|
"""
|
||||||
Creates a recently added track entry.
|
Creates a recently added track entry.
|
||||||
"""
|
"""
|
||||||
track = serialize_track(t, to_remove={"created_date"})
|
|
||||||
track["help_text"] = "NEW TRACK"
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"type": "track",
|
"type": "track",
|
||||||
"item": track,
|
"hash": t.trackhash,
|
||||||
|
"timestamp": t.last_mod,
|
||||||
|
"help_text": "NEW TRACK",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# INFO: Keys: folder, tracks, time (timestamp)
|
# INFO: Keys: folder, tracks, time (timestamp)
|
||||||
# group_type = dict[str, str | list[Track] | float]
|
# group_type = dict[str, str | list[Track] | float]
|
||||||
|
|
||||||
@@ -83,7 +75,7 @@ def check_folder_type(group_: dict):
|
|||||||
|
|
||||||
if len(tracks) == 1:
|
if len(tracks) == 1:
|
||||||
entry = create_track(tracks[0])
|
entry = create_track(tracks[0])
|
||||||
entry["item"]["time"] = timestamp_to_time_passed(time)
|
entry["timestamp"] = time
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
is_album, albumhash, _ = check_is_album_folder(tracks)
|
is_album, albumhash, _ = check_is_album_folder(tracks)
|
||||||
@@ -94,26 +86,13 @@ def check_folder_type(group_: dict):
|
|||||||
if entry is None:
|
if entry is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
album = album_serializer(
|
|
||||||
entry.album,
|
|
||||||
to_remove={
|
|
||||||
"genres",
|
|
||||||
"og_title",
|
|
||||||
"date",
|
|
||||||
"duration",
|
|
||||||
"count",
|
|
||||||
"albumartists_hashes",
|
|
||||||
"base_title",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
album["help_text"] = (
|
|
||||||
"NEW ALBUM" if albumhash in existing_album_hashes else "NEW TRACKS"
|
|
||||||
)
|
|
||||||
album["time"] = timestamp_to_time_passed(time)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"type": "album",
|
"type": "album",
|
||||||
"item": album,
|
"hash": albumhash,
|
||||||
|
"timestamp": time,
|
||||||
|
"help_text": (
|
||||||
|
"NEW ALBUM" if albumhash in existing_album_hashes else "NEW TRACKS"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
is_artist, artisthash, trackcount = check_is_artist_folder(tracks)
|
is_artist, artisthash, trackcount = check_is_artist_folder(tracks)
|
||||||
@@ -123,16 +102,13 @@ def check_folder_type(group_: dict):
|
|||||||
if entry is None:
|
if entry is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
artist = serialize_for_card(entry.artist)
|
|
||||||
artist["trackcount"] = trackcount
|
|
||||||
artist["help_text"] = (
|
|
||||||
"NEW ARTIST" if artisthash not in existing_artist_hashes else "NEW MUSIC"
|
|
||||||
)
|
|
||||||
artist["time"] = timestamp_to_time_passed(time)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"type": "artist",
|
"type": "artist",
|
||||||
"item": artist,
|
"hash": artisthash,
|
||||||
|
"timestamp": time,
|
||||||
|
"help_text": (
|
||||||
|
"NEW ARTIST" if artisthash not in existing_artist_hashes else "NEW MUSIC"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
is_track_folder = check_is_track_folder(tracks)
|
is_track_folder = check_is_track_folder(tracks)
|
||||||
@@ -142,12 +118,9 @@ def check_folder_type(group_: dict):
|
|||||||
if is_track_folder
|
if is_track_folder
|
||||||
else {
|
else {
|
||||||
"type": "folder",
|
"type": "folder",
|
||||||
"item": {
|
"hash": key,
|
||||||
"path": key,
|
"timestamp": time,
|
||||||
"count": len(tracks),
|
|
||||||
"help_text": "NEW MUSIC",
|
"help_text": "NEW MUSIC",
|
||||||
"time": timestamp_to_time_passed(time),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,229 +1,13 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import os
|
|
||||||
from app.db.userdata import FavoritesTable, PlaylistTable, ScrobbleTable
|
|
||||||
|
|
||||||
from app.models.logger import TrackLog
|
|
||||||
from app.models.playlist import Playlist
|
from app.models.playlist import Playlist
|
||||||
from app.serializers.track import serialize_track
|
|
||||||
from app.serializers.album import album_serializer
|
|
||||||
from app.lib.playlistlib import get_first_4_images
|
from app.lib.playlistlib import get_first_4_images
|
||||||
from app.store.folder import FolderStore
|
|
||||||
from app.utils.dates import (
|
from app.utils.dates import (
|
||||||
create_new_date,
|
create_new_date,
|
||||||
date_string_to_time_passed,
|
date_string_to_time_passed,
|
||||||
timestamp_to_time_passed,
|
|
||||||
)
|
)
|
||||||
from app.serializers.artist import serialize_for_card
|
|
||||||
from app.serializers.playlist import serialize_for_card as serialize_playlist
|
|
||||||
from app.lib.home.recentlyadded import get_recently_added_playlist
|
|
||||||
|
|
||||||
from app.store.albums import AlbumStore
|
|
||||||
from app.store.tracks import TrackStore
|
from app.store.tracks import TrackStore
|
||||||
from app.store.artists import ArtistStore
|
|
||||||
|
|
||||||
|
|
||||||
def get_recently_played(limit=7):
|
|
||||||
# TODO: Paginate this
|
|
||||||
items = []
|
|
||||||
added = set()
|
|
||||||
|
|
||||||
custom_playlists = [
|
|
||||||
{"name": "recentlyadded", "handler": get_recently_added_playlist},
|
|
||||||
{"name": "recentlyplayed", "handler": get_recently_played_playlist},
|
|
||||||
]
|
|
||||||
|
|
||||||
def create_items(entries: list[TrackLog]):
|
|
||||||
for entry in entries:
|
|
||||||
if len(items) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
if entry.source in added:
|
|
||||||
continue
|
|
||||||
|
|
||||||
added.add(entry.source)
|
|
||||||
|
|
||||||
if entry.type == "album":
|
|
||||||
album = AlbumStore.get_album_by_hash(entry.type_src)
|
|
||||||
|
|
||||||
if album is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
album = album_serializer(
|
|
||||||
album,
|
|
||||||
to_remove={
|
|
||||||
"genres",
|
|
||||||
"date",
|
|
||||||
"count",
|
|
||||||
"duration",
|
|
||||||
"albumartists_hashes",
|
|
||||||
"og_title",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
album["help_text"] = "album"
|
|
||||||
album["time"] = timestamp_to_time_passed(entry.timestamp)
|
|
||||||
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"type": "album",
|
|
||||||
"item": album,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if entry.type == "artist":
|
|
||||||
artist = ArtistStore.get_artist_by_hash(entry.type_src)
|
|
||||||
|
|
||||||
if artist is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
artist = serialize_for_card(artist)
|
|
||||||
artist["help_text"] = "artist"
|
|
||||||
artist["time"] = timestamp_to_time_passed(entry.timestamp)
|
|
||||||
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"type": "artist",
|
|
||||||
"item": artist,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
continue
|
|
||||||
|
|
||||||
if entry.type == "folder":
|
|
||||||
folder = entry.type_src
|
|
||||||
|
|
||||||
if not folder:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not folder.endswith("/"):
|
|
||||||
folder += "/"
|
|
||||||
|
|
||||||
is_home_dir = entry.type_src == "$home"
|
|
||||||
|
|
||||||
if is_home_dir:
|
|
||||||
folder = os.path.expanduser("~")
|
|
||||||
|
|
||||||
# print(folder)
|
|
||||||
# folder = os.path.join("/", folder, "")
|
|
||||||
# print(folder)
|
|
||||||
# count = len([t for t in TrackStore.tracks if t.folder == folder])
|
|
||||||
count = FolderStore.count_tracks_containing_paths([folder])
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"type": "folder",
|
|
||||||
"item": {
|
|
||||||
"path": folder,
|
|
||||||
"count": count[0]["trackcount"],
|
|
||||||
"help_text": "folder",
|
|
||||||
"time": timestamp_to_time_passed(entry.timestamp),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if entry.type == "playlist":
|
|
||||||
is_custom = entry.type_src in [i["name"] for i in custom_playlists]
|
|
||||||
# is_recently_added = entry.type_src == "recentlyadded"
|
|
||||||
|
|
||||||
if is_custom:
|
|
||||||
playlist, _ = next(
|
|
||||||
i["handler"]()
|
|
||||||
for i in custom_playlists
|
|
||||||
if i["name"] == entry.type_src
|
|
||||||
)
|
|
||||||
playlist.images = [i["image"] for i in playlist.images]
|
|
||||||
|
|
||||||
playlist = serialize_playlist(
|
|
||||||
playlist, to_remove={"settings", "duration"}
|
|
||||||
)
|
|
||||||
|
|
||||||
playlist["help_text"] = "playlist"
|
|
||||||
playlist["time"] = timestamp_to_time_passed(entry.timestamp)
|
|
||||||
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"type": "playlist",
|
|
||||||
"item": playlist,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
playlist = PlaylistTable.get_by_id(entry.type_src)
|
|
||||||
if playlist is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
tracks = TrackStore.get_tracks_by_trackhashes(playlist.trackhashes)
|
|
||||||
playlist.clear_lists()
|
|
||||||
|
|
||||||
if not playlist.has_image:
|
|
||||||
images = get_first_4_images(tracks)
|
|
||||||
images = [i["image"] for i in images]
|
|
||||||
playlist.images = images
|
|
||||||
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"type": "playlist",
|
|
||||||
"item": {
|
|
||||||
"help_text": "playlist",
|
|
||||||
"time": timestamp_to_time_passed(entry.timestamp),
|
|
||||||
**serialize_playlist(playlist),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if entry.type == "favorite":
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"type": "favorite_tracks",
|
|
||||||
"item": {
|
|
||||||
"help_text": "playlist",
|
|
||||||
"count": FavoritesTable.count(),
|
|
||||||
"time": timestamp_to_time_passed(entry.timestamp),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
t = TrackStore.trackhashmap.get(entry.trackhash)
|
|
||||||
|
|
||||||
if t is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
track = serialize_track(t.get_best())
|
|
||||||
track["help_text"] = "track"
|
|
||||||
track["time"] = timestamp_to_time_passed(entry.timestamp)
|
|
||||||
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"type": "track",
|
|
||||||
"item": track,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
BATCH_SIZE = 200
|
|
||||||
current_index = 0
|
|
||||||
|
|
||||||
entries = ScrobbleTable.get_all(0, BATCH_SIZE)
|
|
||||||
max_iterations = 20 # Safeguard against unexpected infinite loops
|
|
||||||
iterations = 0
|
|
||||||
|
|
||||||
while len(items) < limit and iterations < max_iterations:
|
|
||||||
create_items(entries)
|
|
||||||
current_index += BATCH_SIZE
|
|
||||||
|
|
||||||
if len(items) < limit:
|
|
||||||
entries = ScrobbleTable.get_all(current_index + 1, BATCH_SIZE)
|
|
||||||
if not entries:
|
|
||||||
break
|
|
||||||
|
|
||||||
iterations += 1
|
|
||||||
|
|
||||||
if iterations == max_iterations:
|
|
||||||
print(
|
|
||||||
f"Warning: Reached maximum iterations ({max_iterations}) while fetching recently played items"
|
|
||||||
)
|
|
||||||
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def get_recently_played_playlist(limit: int = 100):
|
def get_recently_played_playlist(limit: int = 100):
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
from app.db.userdata import FavoritesTable, MixTable, PlaylistTable
|
||||||
|
from app.lib.home import find_mix
|
||||||
|
from app.lib.home.recentlyadded import get_recently_added_playlist
|
||||||
|
from app.lib.home.recentlyplayed import get_recently_played_playlist
|
||||||
|
from app.lib.playlistlib import get_first_4_images
|
||||||
|
from app.serializers.album import album_serializer
|
||||||
|
from app.serializers.artist import serialize_for_card
|
||||||
|
from app.serializers.playlist import serialize_for_card as serialize_playlist
|
||||||
|
from app.serializers.track import serialize_track
|
||||||
|
from app.store.albums import AlbumStore
|
||||||
|
from app.store.artists import ArtistStore
|
||||||
|
from app.store.folder import FolderStore
|
||||||
|
from app.store.tracks import TrackStore
|
||||||
|
from app.utils.dates import timestamp_to_time_passed
|
||||||
|
|
||||||
|
|
||||||
|
def recover_items(items: list[dict]):
|
||||||
|
custom_playlists = [
|
||||||
|
{"name": "recentlyadded", "handler": get_recently_added_playlist},
|
||||||
|
{"name": "recentlyplayed", "handler": get_recently_played_playlist},
|
||||||
|
]
|
||||||
|
recovered = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
recovered_item = None
|
||||||
|
|
||||||
|
if item["type"] == "album":
|
||||||
|
album = AlbumStore.get_album_by_hash(item["hash"])
|
||||||
|
if album is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
album = album_serializer(
|
||||||
|
album,
|
||||||
|
to_remove={
|
||||||
|
"genres",
|
||||||
|
"date",
|
||||||
|
"count",
|
||||||
|
"duration",
|
||||||
|
"albumartists_hashes",
|
||||||
|
"og_title",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
recovered_item = {
|
||||||
|
"type": "album",
|
||||||
|
"item": album,
|
||||||
|
}
|
||||||
|
elif item["type"] == "artist":
|
||||||
|
artist = ArtistStore.get_artist_by_hash(item["hash"])
|
||||||
|
if artist is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
recovered_item = {
|
||||||
|
"type": "artist",
|
||||||
|
"item": serialize_for_card(artist),
|
||||||
|
}
|
||||||
|
elif item["type"] == "folder":
|
||||||
|
count = FolderStore.count_tracks_containing_paths([item["hash"]])
|
||||||
|
|
||||||
|
recovered_item = {
|
||||||
|
"type": "folder",
|
||||||
|
"item": {
|
||||||
|
"path": item["hash"],
|
||||||
|
"count": count[0]["trackcount"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
elif item["type"] == "playlist":
|
||||||
|
if item.get("is_custom"):
|
||||||
|
playlist, _ = next(
|
||||||
|
i["handler"]()
|
||||||
|
for i in custom_playlists
|
||||||
|
if i["name"] == item["hash"]
|
||||||
|
)
|
||||||
|
playlist.images = [i["image"] for i in playlist.images]
|
||||||
|
|
||||||
|
playlist = serialize_playlist(
|
||||||
|
playlist, to_remove={"settings", "duration"}
|
||||||
|
)
|
||||||
|
recovered_item = {
|
||||||
|
"type": "playlist",
|
||||||
|
"item": playlist,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
playlist = PlaylistTable.get_by_id(item["hash"])
|
||||||
|
if playlist is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tracks = TrackStore.get_tracks_by_trackhashes(playlist.trackhashes)
|
||||||
|
playlist.clear_lists()
|
||||||
|
|
||||||
|
if not playlist.has_image:
|
||||||
|
images = get_first_4_images(tracks)
|
||||||
|
images = [i["image"] for i in images]
|
||||||
|
playlist.images = images
|
||||||
|
|
||||||
|
recovered_item = {
|
||||||
|
"type": "playlist",
|
||||||
|
"item": serialize_playlist(playlist),
|
||||||
|
}
|
||||||
|
elif item["type"] == "favorite":
|
||||||
|
recovered_item = {
|
||||||
|
"type": "favorite",
|
||||||
|
"item": {
|
||||||
|
"count": FavoritesTable.count(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
elif item["type"] == "track":
|
||||||
|
track = TrackStore.trackhashmap.get(item["hash"])
|
||||||
|
if track is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
recovered_item = {
|
||||||
|
"type": "track",
|
||||||
|
"item": serialize_track(track.get_best()),
|
||||||
|
}
|
||||||
|
|
||||||
|
elif item["type"] == "mix":
|
||||||
|
try:
|
||||||
|
splits = item["hash"].split(".")
|
||||||
|
mixid = splits[0]
|
||||||
|
sourcehash = splits[1]
|
||||||
|
except IndexError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
mix = find_mix(mixid, sourcehash)
|
||||||
|
if mix is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
recovered_item = {
|
||||||
|
"type": "mix",
|
||||||
|
"item": mix,
|
||||||
|
}
|
||||||
|
|
||||||
|
if recovered_item is not None:
|
||||||
|
helptext = item.get("help_text") or item.get("type")
|
||||||
|
secondary_text = item.get("secondary_text")
|
||||||
|
|
||||||
|
if "secondary_text" in item:
|
||||||
|
secondary_text = item["secondary_text"]
|
||||||
|
elif "timestamp" in item:
|
||||||
|
secondary_text = timestamp_to_time_passed(item["timestamp"])
|
||||||
|
|
||||||
|
if helptext:
|
||||||
|
recovered_item["item"]["help_text"] = helptext
|
||||||
|
|
||||||
|
if secondary_text:
|
||||||
|
recovered_item["item"]["time"] = secondary_text
|
||||||
|
|
||||||
|
recovered.append(recovered_item)
|
||||||
|
|
||||||
|
return recovered
|
||||||
@@ -7,6 +7,7 @@ from app.lib.mapstuff import (
|
|||||||
map_scrobble_data,
|
map_scrobble_data,
|
||||||
)
|
)
|
||||||
from app.lib.populate import CordinateMedia
|
from app.lib.populate import CordinateMedia
|
||||||
|
from app.lib.recipes.recents import RecentlyAdded
|
||||||
from app.lib.tagger import IndexTracks
|
from app.lib.tagger import IndexTracks
|
||||||
from app.store.albums import AlbumStore
|
from app.store.albums import AlbumStore
|
||||||
from app.store.artists import ArtistStore
|
from app.store.artists import ArtistStore
|
||||||
@@ -25,6 +26,9 @@ class IndexEverything:
|
|||||||
ArtistStore.load_artists(key)
|
ArtistStore.load_artists(key)
|
||||||
FolderStore.load_filepaths()
|
FolderStore.load_filepaths()
|
||||||
|
|
||||||
|
# NOTE: Rebuild recently added items on the homepage store
|
||||||
|
RecentlyAdded()
|
||||||
|
|
||||||
# map colors
|
# map colors
|
||||||
map_album_colors()
|
map_album_colors()
|
||||||
map_artist_colors()
|
map_artist_colors()
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from typing import Any
|
|||||||
from PIL import Image, ImageSequence
|
from PIL import Image, ImageSequence
|
||||||
|
|
||||||
from app import settings
|
from app import settings
|
||||||
from app.db.libdata import TrackTable
|
|
||||||
from app.models.track import Track
|
from app.models.track import Track
|
||||||
from app.store.albums import AlbumStore
|
from app.store.albums import AlbumStore
|
||||||
from app.store.tracks import TrackStore
|
from app.store.tracks import TrackStore
|
||||||
|
|||||||
+1
-1
@@ -86,7 +86,7 @@ def get_image(_map: tuple[str, Album]):
|
|||||||
matching_tracks = AlbumStore.get_album_tracks(album.albumhash)
|
matching_tracks = AlbumStore.get_album_tracks(album.albumhash)
|
||||||
|
|
||||||
for track in matching_tracks:
|
for track in matching_tracks:
|
||||||
if extract_thumb(track.filepath, track.image):
|
if extract_thumb(track.filepath, track.albumhash + ".webp"):
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
Recipes are a way to create mixes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, List
|
||||||
|
|
||||||
|
class HomepageRoutine(ABC):
|
||||||
|
"""
|
||||||
|
A routine creates a row of homepage items.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def is_valid(self) -> bool: ...
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
if not self.is_valid:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.run()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def run(self) -> List[Any]:
|
||||||
|
"""
|
||||||
|
Creates the homepage items and saves them to the
|
||||||
|
homepage store if self.is_valid is true.
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from app.db.userdata import UserTable
|
||||||
|
from app.lib.recipes import HomepageRoutine
|
||||||
|
from app.plugins.mixes import MixesPlugin
|
||||||
|
from app.store.homepage import HomepageStore
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistMixes(HomepageRoutine):
|
||||||
|
store_key = "artist_mixes"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid(self):
|
||||||
|
return MixesPlugin().enabled
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
users = UserTable.get_all()
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
mix = MixesPlugin()
|
||||||
|
mixes = mix.create_artist_mixes(user.id)
|
||||||
|
|
||||||
|
if not mixes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
HomepageStore.set_mixes(mixes, entrykey=self.store_key, userid=user.id)
|
||||||
|
|
||||||
|
custom_mixes = []
|
||||||
|
for _mix in mixes:
|
||||||
|
custom_mix = MixesPlugin.get_track_mix(_mix)
|
||||||
|
|
||||||
|
if custom_mix:
|
||||||
|
custom_mixes.append(custom_mix)
|
||||||
|
|
||||||
|
HomepageStore.set_mixes(
|
||||||
|
custom_mixes, entrykey="custom_mixes", userid=user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from pprint import pprint
|
||||||
|
from app.db.userdata import UserTable
|
||||||
|
from app.lib.recipes import HomepageRoutine
|
||||||
|
from app.lib.recipes.artistmixes import ArtistMixes
|
||||||
|
from app.models.mix import Mix
|
||||||
|
from app.plugins.mixes import MixesPlugin
|
||||||
|
from app.store.homepage import HomepageStore
|
||||||
|
|
||||||
|
|
||||||
|
class BecauseYouListened(HomepageRoutine):
|
||||||
|
store_keys = ["because_you_listened_to_artist", "artists_you_might_like"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid(self):
|
||||||
|
return MixesPlugin().enabled
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
users = UserTable.get_all()
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
entry: dict[str, Mix] = HomepageStore.entries.get(
|
||||||
|
ArtistMixes.store_key
|
||||||
|
).items.get(user.id) # type: ignore
|
||||||
|
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
|
||||||
|
because_you_listened_to_artist, artists_you_might_like = (
|
||||||
|
MixesPlugin().get_because_items(list(entry.values()))
|
||||||
|
)
|
||||||
|
|
||||||
|
HomepageStore.entries[self.store_keys[0]].items[
|
||||||
|
user.id
|
||||||
|
] = because_you_listened_to_artist
|
||||||
|
HomepageStore.entries[self.store_keys[1]].items[
|
||||||
|
user.id
|
||||||
|
] = artists_you_might_like
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import pprint
|
||||||
|
from app.db.userdata import ScrobbleTable, UserTable
|
||||||
|
from app.lib.home.recentlyadded import get_recently_added_items
|
||||||
|
from app.lib.home.get_recently_played import get_recently_played
|
||||||
|
from app.lib.recipes import HomepageRoutine
|
||||||
|
from app.store.homepage import HomepageStore
|
||||||
|
|
||||||
|
|
||||||
|
class RecentlyPlayed(HomepageRoutine):
|
||||||
|
ITEM_LIMIT = 15
|
||||||
|
store_key = "recently_played"
|
||||||
|
|
||||||
|
def __init__(self, userid: int | None = None) -> None:
|
||||||
|
"""
|
||||||
|
The userid is provided when we are running this routine
|
||||||
|
outside a cron job. ie. when a user records a new scrobble.
|
||||||
|
"""
|
||||||
|
self.userids = [userid] if userid else [user.id for user in UserTable.get_all()]
|
||||||
|
|
||||||
|
# NOTE: When the userid is provided
|
||||||
|
# we need to update the store for that userid only
|
||||||
|
# using the last scrobble entry.
|
||||||
|
self.update_only = userid is not None
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
if self.update_only:
|
||||||
|
last_entry = ScrobbleTable.get_last_entry(self.userids[0])
|
||||||
|
|
||||||
|
if last_entry:
|
||||||
|
items = get_recently_played(
|
||||||
|
limit=self.ITEM_LIMIT, userid=self.userids[0], _entries=[last_entry]
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
item = items[0]
|
||||||
|
store_entry = HomepageStore.entries[self.store_key].items[
|
||||||
|
self.userids[0]
|
||||||
|
][0]
|
||||||
|
except IndexError:
|
||||||
|
store_entry = None
|
||||||
|
item = None
|
||||||
|
|
||||||
|
if (
|
||||||
|
store_entry
|
||||||
|
and item
|
||||||
|
and store_entry["type"] + store_entry["hash"]
|
||||||
|
== item["type"] + item["hash"]
|
||||||
|
):
|
||||||
|
# If the item is the same as the one in the store
|
||||||
|
# only update the timestamp
|
||||||
|
HomepageStore.entries[self.store_key].items[self.userids[0]][0][
|
||||||
|
"timestamp"
|
||||||
|
] = item["timestamp"]
|
||||||
|
else:
|
||||||
|
# Otherwise, insert the new item
|
||||||
|
# and remove the oldest item if there are more than 15 items
|
||||||
|
HomepageStore.entries[self.store_key].items[self.userids[0]].insert(
|
||||||
|
0, item
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
len(
|
||||||
|
HomepageStore.entries[self.store_key].items[self.userids[0]]
|
||||||
|
)
|
||||||
|
> self.ITEM_LIMIT
|
||||||
|
):
|
||||||
|
HomepageStore.entries[self.store_key].items[
|
||||||
|
self.userids[0]
|
||||||
|
].pop()
|
||||||
|
|
||||||
|
for userid in self.userids:
|
||||||
|
items = get_recently_played(limit=self.ITEM_LIMIT, userid=userid)
|
||||||
|
HomepageStore.entries[self.store_key].items[userid] = items
|
||||||
|
|
||||||
|
|
||||||
|
class RecentlyAdded(HomepageRoutine):
|
||||||
|
ITEM_LIMIT = 15
|
||||||
|
store_key = "recently_added"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
items = get_recently_added_items(limit=self.ITEM_LIMIT)
|
||||||
|
|
||||||
|
# NOTE: Recently added is a global entry
|
||||||
|
# So we don't need a userid
|
||||||
|
HomepageStore.entries[self.store_key].items[0] = items
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from gettext import ngettext
|
||||||
|
import pendulum
|
||||||
|
|
||||||
|
from app.crons.cron import CronJob
|
||||||
|
from app.db.userdata import UserTable
|
||||||
|
from app.lib.recipes import HomepageRoutine
|
||||||
|
from app.store.homepage import HomepageStore
|
||||||
|
from app.utils.dates import get_date_range, seconds_to_time_string
|
||||||
|
from app.utils.stats import get_artists_in_period
|
||||||
|
|
||||||
|
|
||||||
|
class TopArtists(CronJob, HomepageRoutine):
|
||||||
|
"""
|
||||||
|
A routine to populate the top streamed artists/albums in the last week or month
|
||||||
|
"""
|
||||||
|
|
||||||
|
hours = 1
|
||||||
|
ITEM_LIMIT = 15
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid(self):
|
||||||
|
"""
|
||||||
|
Only valid if it's the middle or last 2 days of this month.
|
||||||
|
|
||||||
|
When the duration is "week", it's valid on the weekend.
|
||||||
|
"""
|
||||||
|
if self.duration == "month":
|
||||||
|
now = pendulum.now()
|
||||||
|
middle_day = now.days_in_month // 2
|
||||||
|
|
||||||
|
return (
|
||||||
|
now.day in range(middle_day, middle_day + 2)
|
||||||
|
or now.day > now.days_in_month - 2
|
||||||
|
)
|
||||||
|
if self.duration == "week":
|
||||||
|
return pendulum.now().isoweekday() in (5, 6, 7)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __init__(self, duration: str = "month") -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.duration = duration
|
||||||
|
|
||||||
|
if not self.is_valid:
|
||||||
|
return
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
if not self.is_valid:
|
||||||
|
self.destroy()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.userids = [user.id for user in UserTable.get_all()]
|
||||||
|
|
||||||
|
for userid in self.userids:
|
||||||
|
date_range = get_date_range(self.duration)
|
||||||
|
artists = get_artists_in_period(date_range[0], date_range[1], userid)[
|
||||||
|
: self.ITEM_LIMIT
|
||||||
|
]
|
||||||
|
|
||||||
|
artists = [
|
||||||
|
{
|
||||||
|
"type": "artist",
|
||||||
|
"hash": artist["artisthash"],
|
||||||
|
"help_text": seconds_to_time_string(artist["playduration"]),
|
||||||
|
"secondary_text": str(artist["playcount"])
|
||||||
|
+ " "
|
||||||
|
+ ngettext("play", "plays", artist["playcount"]),
|
||||||
|
}
|
||||||
|
for artist in artists
|
||||||
|
]
|
||||||
|
|
||||||
|
HomepageStore.entries[f"top_streamed_{self.duration}ly_artists"].items[
|
||||||
|
userid
|
||||||
|
] = artists
|
||||||
|
|
||||||
|
def destroy(self):
|
||||||
|
"""
|
||||||
|
Clear the top streamed entry from the homepage store.
|
||||||
|
"""
|
||||||
|
keys = [f"top_streamed_{self.duration}ly_artists"]
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
HomepageStore.entries[key].items = {}
|
||||||
+2
-1
@@ -69,7 +69,7 @@ class IndexTracks:
|
|||||||
for track in tracks:
|
for track in tracks:
|
||||||
try:
|
try:
|
||||||
extract_thumb(
|
extract_thumb(
|
||||||
track["filepath"], track["trackhash"] + ".webp", overwrite=True
|
track["filepath"], track["albumhash"] + ".webp", overwrite=True
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
continue
|
continue
|
||||||
@@ -179,6 +179,7 @@ def create_albums(_trackhashes: list[str] = []) -> list[tuple[Album, set[str]]]:
|
|||||||
"playduration": track.playduration,
|
"playduration": track.playduration,
|
||||||
"title": track.album,
|
"title": track.album,
|
||||||
"tracks": {track.trackhash},
|
"tracks": {track.trackhash},
|
||||||
|
"pathhash": track.pathhash,
|
||||||
"extra": {},
|
"extra": {},
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
|||||||
+75
-177
@@ -155,35 +155,59 @@ def get_tags(filepath: str, config: UserConfig):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tags: Any = TinyTag.get(filepath)
|
tags = TinyTag.get(filepath)
|
||||||
except: # noqa: E722
|
except Exception as e: # noqa: E722
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
other = tags.other
|
||||||
|
except AttributeError:
|
||||||
|
other = {}
|
||||||
|
|
||||||
|
metadata: dict[str, Any] = {
|
||||||
|
"album": tags.album,
|
||||||
|
"albumartists": tags.albumartist,
|
||||||
|
"artists": tags.artist,
|
||||||
|
"title": tags.title,
|
||||||
|
"last_mod": last_mod,
|
||||||
|
"filepath": win_replace_slash(filepath),
|
||||||
|
"folder": win_replace_slash(os.path.dirname(filepath)),
|
||||||
|
"bitrate": tags.bitrate,
|
||||||
|
"duration": tags.duration,
|
||||||
|
"track": tags.track,
|
||||||
|
"disc": tags.disc,
|
||||||
|
"genres": tags.genre,
|
||||||
|
"copyright": " ".join(other.get("copyright", [])),
|
||||||
|
"extra": {},
|
||||||
|
}
|
||||||
|
|
||||||
no_albumartist: bool = (tags.albumartist == "") or (tags.albumartist is None)
|
no_albumartist: bool = (tags.albumartist == "") or (tags.albumartist is None)
|
||||||
no_artist: bool = (tags.artist == "") or (tags.artist is None)
|
no_artist: bool = (tags.artist == "") or (tags.artist is None)
|
||||||
|
|
||||||
if no_albumartist and not no_artist:
|
if no_albumartist and not no_artist:
|
||||||
tags.albumartist = tags.artist
|
# INFO: If no albumartist, use the artist
|
||||||
|
metadata["albumartists"] = tags.artist
|
||||||
|
|
||||||
if no_artist and not no_albumartist:
|
if no_artist and not no_albumartist:
|
||||||
tags.artist = tags.albumartist
|
# INFO: If no artist, use the albumartist
|
||||||
|
metadata["artist"] = tags.albumartist
|
||||||
|
|
||||||
parse_data = None
|
parse_data = None
|
||||||
|
|
||||||
|
# INFO: If title or album is empty, extract the album and title from the filename
|
||||||
to_filename = ["title", "album"]
|
to_filename = ["title", "album"]
|
||||||
for tag in to_filename:
|
for tag in to_filename:
|
||||||
p = getattr(tags, tag)
|
p = metadata[tag]
|
||||||
if p == "" or p is None:
|
if p == "" or p is None:
|
||||||
parse_data = extract_artist_title(filename, config)
|
parse_data = extract_artist_title(filename, config)
|
||||||
title = parse_data.title.replace("_", " ")
|
title = parse_data.title.replace("_", " ")
|
||||||
setattr(tags, tag, title)
|
metadata[tag] = title
|
||||||
|
|
||||||
# tags.title = tags.title.replace("_", " ")
|
# INFO: If artist or albumartist is empty
|
||||||
# tags.album = tags.album.replace("_", " ")
|
# extract the artist and albumartist from the filename
|
||||||
|
parse = ["artists", "albumartists"]
|
||||||
parse = ["artist", "albumartist"]
|
|
||||||
for tag in parse:
|
for tag in parse:
|
||||||
p = getattr(tags, tag)
|
p = metadata[tag]
|
||||||
|
|
||||||
if p == "" or p is None:
|
if p == "" or p is None:
|
||||||
if not parse_data:
|
if not parse_data:
|
||||||
@@ -192,194 +216,68 @@ def get_tags(filepath: str, config: UserConfig):
|
|||||||
artist = parse_data.artist
|
artist = parse_data.artist
|
||||||
|
|
||||||
if artist:
|
if artist:
|
||||||
setattr(tags, tag, ", ".join(artist))
|
metadata[tag] = ", ".join(artist)
|
||||||
else:
|
else:
|
||||||
setattr(tags, tag, "Unknown")
|
metadata[tag] = "Unknown"
|
||||||
|
|
||||||
# TODO: Move parsing title, album and artist to startup. (Maybe!)
|
# INFO: If these are empty, set to "Unknown"
|
||||||
|
to_check = ["album", "albumartists"]
|
||||||
to_check = ["album", "year", "albumartist"]
|
|
||||||
for prop in to_check:
|
for prop in to_check:
|
||||||
p = getattr(tags, prop)
|
p = metadata[prop]
|
||||||
if (p is None) or (p == ""):
|
if (p is None) or (p == ""):
|
||||||
setattr(tags, prop, "Unknown")
|
metadata[prop] = "Unknown"
|
||||||
|
|
||||||
|
# INFO: Round the bitrate and duration
|
||||||
to_round = ["bitrate", "duration"]
|
to_round = ["bitrate", "duration"]
|
||||||
for prop in to_round:
|
for prop in to_round:
|
||||||
try:
|
try:
|
||||||
setattr(tags, prop, math.floor(getattr(tags, prop)))
|
metadata[prop] = math.floor(getattr(tags, prop))
|
||||||
except TypeError:
|
except TypeError:
|
||||||
setattr(tags, prop, 0)
|
metadata[prop] = 0
|
||||||
|
|
||||||
|
# INFO: Convert these to int
|
||||||
to_int = ["track", "disc"]
|
to_int = ["track", "disc"]
|
||||||
for prop in to_int:
|
for prop in to_int:
|
||||||
try:
|
try:
|
||||||
setattr(tags, prop, int(getattr(tags, prop)))
|
metadata[prop] = int(getattr(tags, prop))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
setattr(tags, prop, 1)
|
metadata[prop] = 1
|
||||||
|
|
||||||
try:
|
# INFO: Extract copyright from extra data
|
||||||
tags.copyright = tags.extra["copyright"]
|
metadata["date"] = parse_date(tags.year or "") or int(last_mod)
|
||||||
except KeyError:
|
|
||||||
tags.copyright = None
|
|
||||||
|
|
||||||
# tags.image = f"{tags.albumhash}.webp"
|
|
||||||
tags.folder = win_replace_slash(os.path.dirname(filepath))
|
|
||||||
|
|
||||||
tags.date = parse_date(tags.year) or int(last_mod)
|
|
||||||
tags.filepath = win_replace_slash(filepath)
|
|
||||||
tags.last_mod = last_mod
|
|
||||||
|
|
||||||
tags.artists = tags.artist
|
|
||||||
tags.albumartists = tags.albumartist
|
|
||||||
|
|
||||||
# split_artist = split_artists(tags.artist, separators=config.artistSeparators)
|
|
||||||
# split_albumartists = split_artists(tags.albumartist, separators=config.artistSeparators)
|
|
||||||
# new_title = tags.title
|
|
||||||
|
|
||||||
# TODO: Figure out which is the best spot to create these hashes
|
|
||||||
# create albumhash using og_album
|
# create albumhash using og_album
|
||||||
tags.albumhash = create_hash(tags.album or "", tags.albumartist)
|
metadata["albumhash"] = create_hash(
|
||||||
|
tags.album or "", metadata.get("albumartists", "")
|
||||||
|
)
|
||||||
|
|
||||||
# extract featured artists
|
metadata["trackhash"] = create_hash(
|
||||||
# if config.extractFeaturedArtists:
|
metadata.get("artist", ""), metadata.get("album", ""), metadata.get("title", "")
|
||||||
# feat, new_title = parse_feat_from_title(
|
)
|
||||||
# tags.title, separators=config.artistSeparators
|
|
||||||
# )
|
|
||||||
# original_lower = "-".join([create_hash(a) for a in split_artist])
|
|
||||||
# split_artist.extend(a for a in feat if create_hash(a) not in original_lower)
|
|
||||||
|
|
||||||
# if no albumartist, assign to the first artist
|
extra: dict[str, Any] = {
|
||||||
if not tags.albumartist:
|
k: v for k, v in tags.as_dict().items() if metadata.get(k, "meh") == "meh"
|
||||||
tags.albumartist = split_artists(tags.artist, config)[:1]
|
|
||||||
|
|
||||||
# create json objects for artists and albumartists
|
|
||||||
# tags.artists = [
|
|
||||||
# {
|
|
||||||
# "artisthash": create_hash(a, decode=True),
|
|
||||||
# "name": a,
|
|
||||||
# }
|
|
||||||
# for a in split_artist
|
|
||||||
# ]
|
|
||||||
|
|
||||||
# tags.albumartists = [
|
|
||||||
# {
|
|
||||||
# "artisthash": create_hash(a, decode=True),
|
|
||||||
# "name": a,
|
|
||||||
# }
|
|
||||||
# for a in split_albumartists
|
|
||||||
# ]
|
|
||||||
|
|
||||||
# tags.artisthashes = list(
|
|
||||||
# {a["artisthash"] for a in tags.artists}
|
|
||||||
# )
|
|
||||||
|
|
||||||
# remove prod by
|
|
||||||
# if config.removeProdBy:
|
|
||||||
# new_title = remove_prod(new_title)
|
|
||||||
|
|
||||||
# if track is a single, ie.
|
|
||||||
# if og_title == album, rename album to new_title
|
|
||||||
# if tags.title == tags.album:
|
|
||||||
# tags.album = new_title
|
|
||||||
|
|
||||||
# remove remaster from track title
|
|
||||||
# if config.removeRemasterInfo:
|
|
||||||
# new_title = clean_title(new_title)
|
|
||||||
|
|
||||||
# save final title
|
|
||||||
# tags.og_title = tags.title
|
|
||||||
# tags.title = new_title
|
|
||||||
# tags.og_album = tags.album
|
|
||||||
|
|
||||||
# clean album title
|
|
||||||
# if config.cleanAlbumTitle:
|
|
||||||
# tags.album, _ = get_base_title_and_versions(tags.album, get_versions=False)
|
|
||||||
|
|
||||||
# merge album versions
|
|
||||||
# if config.mergeAlbums:
|
|
||||||
# tags.albumhash = create_hash(
|
|
||||||
# tags.album, *(a["name"] for a in tags.albumartists)
|
|
||||||
# )
|
|
||||||
|
|
||||||
# process genres
|
|
||||||
# if tags.genre:
|
|
||||||
# src_genres: str = tags.genre
|
|
||||||
# src_genres = src_genres.lower()
|
|
||||||
# # separators = {"/", ";", "&"}
|
|
||||||
# separators = set(config.genreSeparators)
|
|
||||||
|
|
||||||
# contains_rnb = "r&b" in src_genres
|
|
||||||
# contains_rock = "rock & roll" in src_genres
|
|
||||||
|
|
||||||
# if contains_rnb:
|
|
||||||
# src_genres = src_genres.replace("r&b", "RnB")
|
|
||||||
|
|
||||||
# if contains_rock:
|
|
||||||
# src_genres = src_genres.replace("rock & roll", "rock")
|
|
||||||
|
|
||||||
# for s in separators:
|
|
||||||
# src_genres = src_genres.replace(s, ",")
|
|
||||||
|
|
||||||
# genres_list: list[str] = src_genres.split(",")
|
|
||||||
# tags.genres = [
|
|
||||||
# {"name": g.strip(), "genrehash": create_hash(g.strip())}
|
|
||||||
# for g in genres_list
|
|
||||||
# ]
|
|
||||||
# tags.genrehashes = [g["genrehash"] for g in tags.genres]
|
|
||||||
# else:
|
|
||||||
# tags.genres = []
|
|
||||||
# tags.genrehashes = []
|
|
||||||
|
|
||||||
tags.genres = tags.genre
|
|
||||||
|
|
||||||
# sub underscore with space
|
|
||||||
# tags.title = tags.title.replace("_", " ")
|
|
||||||
# tags.album = tags.album.replace("_", " ")
|
|
||||||
tags.trackhash = create_hash(tags.artists, tags.album, tags.title)
|
|
||||||
|
|
||||||
more_extra = {
|
|
||||||
"audio_offset": tags.audio_offset,
|
|
||||||
"bitdepth": tags.bitdepth,
|
|
||||||
"composer": tags.composer,
|
|
||||||
"channels": tags.channels,
|
|
||||||
"comment": tags.comment,
|
|
||||||
"disc_total": tags.disc_total,
|
|
||||||
"filesize": tags.filesize,
|
|
||||||
"samplerate": tags.samplerate,
|
|
||||||
"track_total": tags.track_total,
|
|
||||||
"hashinfo": {
|
|
||||||
"algo": "sha1",
|
|
||||||
"format": "[:5]+[-5:]", # first 5 + last 5 chars
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tags.extra = {**tags.extra, **more_extra}
|
extra["hashinfo"] = {
|
||||||
|
"algo": "sha1",
|
||||||
|
"format": "[:5]+[-5:]", # first 5 + last 5 chars
|
||||||
|
}
|
||||||
|
|
||||||
tags = tags.__dict__
|
to_pop = ["filename", "artist", "albumartist", "year"]
|
||||||
|
|
||||||
# delete all tag properties that start with _ (tinytag internals)
|
# REMOVE EMPTY VALUES
|
||||||
for tag in list(tags):
|
for key, value in extra.items():
|
||||||
if tag.startswith("_"):
|
if (
|
||||||
del tags[tag]
|
value is None
|
||||||
|
or value == ""
|
||||||
|
# INFO: If value is a list, check if it's empty or if the first element is empty
|
||||||
|
or (type(value) is list and "".join(value) == "")
|
||||||
|
):
|
||||||
|
to_pop.append(key)
|
||||||
|
|
||||||
to_delete = [
|
for key in to_pop:
|
||||||
"filesize",
|
extra.pop(key, None)
|
||||||
"audio_offset",
|
|
||||||
"channels",
|
|
||||||
"comment",
|
|
||||||
"composer",
|
|
||||||
"disc_total",
|
|
||||||
"samplerate",
|
|
||||||
"track_total",
|
|
||||||
"year",
|
|
||||||
"bitdepth",
|
|
||||||
"artist",
|
|
||||||
"albumartist",
|
|
||||||
"genre",
|
|
||||||
]
|
|
||||||
|
|
||||||
for tag in to_delete:
|
metadata["extra"] = extra
|
||||||
del tags[tag]
|
return metadata
|
||||||
|
|
||||||
return tags
|
|
||||||
|
|||||||
+4
-5
@@ -16,10 +16,9 @@ class CustomFormatter(logging.Formatter):
|
|||||||
red = "\033[41m"
|
red = "\033[41m"
|
||||||
bold_red = "\x1b[31;1m"
|
bold_red = "\x1b[31;1m"
|
||||||
reset = "\x1b[0m"
|
reset = "\x1b[0m"
|
||||||
# format = (
|
# format_ = "[%(asctime)s] %(name)s %(levelname)s %(message)s (%(filename)s:%(lineno)d)"
|
||||||
# "%(asctime)s - %(name)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)"
|
format_ = "[%(asctime)s] [%(levelname)s] %(message)s (%(filename)s:%(lineno)d)\n"
|
||||||
# )
|
# format_ = "%(message)s"
|
||||||
format_ = "%(message)s"
|
|
||||||
|
|
||||||
FORMATS = {
|
FORMATS = {
|
||||||
logging.DEBUG: grey + format_ + reset,
|
logging.DEBUG: grey + format_ + reset,
|
||||||
@@ -35,7 +34,7 @@ class CustomFormatter(logging.Formatter):
|
|||||||
return formatter.format(record)
|
return formatter.format(record)
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger("swing")
|
log = logging.getLogger("SWING MUSIC")
|
||||||
log.propagate = False
|
log.propagate = False
|
||||||
log.setLevel(logging.DEBUG)
|
log.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -30,12 +30,14 @@ class Album:
|
|||||||
playcount: int
|
playcount: int
|
||||||
playduration: int
|
playduration: int
|
||||||
extra: dict
|
extra: dict
|
||||||
|
pathhash: str = ""
|
||||||
|
|
||||||
id: int = -1
|
id: int = -1
|
||||||
type: str = "album"
|
type: str = "album"
|
||||||
image: str = ""
|
image: str = ""
|
||||||
versions: list[str] = dataclasses.field(default_factory=list)
|
versions: list[str] = dataclasses.field(default_factory=list)
|
||||||
fav_userids: list[int] = dataclasses.field(default_factory=list)
|
fav_userids: list[int] = dataclasses.field(default_factory=list)
|
||||||
|
weakhash: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_favorite(self):
|
def is_favorite(self):
|
||||||
@@ -52,8 +54,11 @@ class Album:
|
|||||||
self.fav_userids.append(userid)
|
self.fav_userids.append(userid)
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
self.image = self.albumhash + ".webp"
|
self.image = self.albumhash + ".webp" + "?pathhash=" + self.pathhash
|
||||||
self.populate_versions()
|
self.populate_versions()
|
||||||
|
self.weakhash = create_hash(
|
||||||
|
self.og_title, ",".join(a["name"] for a in self.albumartists)
|
||||||
|
)
|
||||||
|
|
||||||
def populate_versions(self):
|
def populate_versions(self):
|
||||||
_, self.versions = get_base_title_and_versions(self.og_title, get_versions=True)
|
_, self.versions = get_base_title_and_versions(self.og_title, get_versions=True)
|
||||||
@@ -169,7 +174,7 @@ class Album:
|
|||||||
|
|
||||||
# REVIEW: Reading from the config file in a for loop will be slow
|
# REVIEW: Reading from the config file in a for loop will be slow
|
||||||
# TODO: Find a
|
# TODO: Find a
|
||||||
if singleTrackAsSingle and len(tracks) == 1:
|
if singleTrackAsSingle and self.trackcount == 1:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -13,18 +13,25 @@ class TrackLog:
|
|||||||
duration: int
|
duration: int
|
||||||
timestamp: int
|
timestamp: int
|
||||||
source: str
|
source: str
|
||||||
|
"""
|
||||||
|
The full source string, eg. "al:123456"
|
||||||
|
"""
|
||||||
userid: int
|
userid: int
|
||||||
extra: dict[str, Any]
|
extra: dict[str, Any]
|
||||||
|
|
||||||
type = "track"
|
type = "track"
|
||||||
type_src = None
|
type_src = None
|
||||||
|
"""
|
||||||
|
The source identifier string, eg. albumhash, artisthash, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
prefix_map = {
|
prefix_map = {
|
||||||
|
"mix:": "mix",
|
||||||
"al:": "album",
|
"al:": "album",
|
||||||
"ar:": "artist",
|
"ar:": "artist",
|
||||||
"pl:": "playlist",
|
|
||||||
"fo:": "folder",
|
"fo:": "folder",
|
||||||
|
"pl:": "playlist",
|
||||||
"favorite": "favorite",
|
"favorite": "favorite",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import time
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.lib.playlistlib import get_first_4_images
|
||||||
|
from app.serializers.track import serialize_tracks
|
||||||
|
from app.store.tracks import TrackStore
|
||||||
|
from app.utils.dates import seconds_to_time_string, timestamp_to_time_passed
|
||||||
|
from app.utils.hashing import create_hash
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Mix:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
tracks: list[str]
|
||||||
|
sourcehash: str
|
||||||
|
userid: int
|
||||||
|
"""
|
||||||
|
A hash of the tracks used to generate the mix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
timestamp: int = field(default_factory=lambda: int(time.time()))
|
||||||
|
extra: dict = field(default_factory=dict)
|
||||||
|
saved: bool = False
|
||||||
|
|
||||||
|
def to_full_dict(self):
|
||||||
|
tracks = TrackStore.get_tracks_by_trackhashes(self.tracks)[:40]
|
||||||
|
serialized_tracks = serialize_tracks(tracks)
|
||||||
|
|
||||||
|
_dict = asdict(self)
|
||||||
|
_dict["tracks"] = serialized_tracks
|
||||||
|
|
||||||
|
# if not self.extra.get("image"):
|
||||||
|
# _dict["images"] = get_first_4_images(tracks)
|
||||||
|
|
||||||
|
_dict["duration"] = seconds_to_time_string(sum(t.duration for t in tracks))
|
||||||
|
_dict["trackcount"] = len(tracks)
|
||||||
|
|
||||||
|
del _dict["extra"]["albums"]
|
||||||
|
del _dict["extra"]["artists"]
|
||||||
|
|
||||||
|
return _dict
|
||||||
|
|
||||||
|
def to_dict(self, convert_timestamp: bool = False):
|
||||||
|
item = asdict(self)
|
||||||
|
item["trackshash"] = create_hash(*self.tracks[:40])
|
||||||
|
|
||||||
|
if convert_timestamp:
|
||||||
|
item["time"] = timestamp_to_time_passed(item["timestamp"])
|
||||||
|
|
||||||
|
del item["tracks"]
|
||||||
|
|
||||||
|
del item["extra"]["albums"]
|
||||||
|
del item["extra"]["artists"]
|
||||||
|
|
||||||
|
return item
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def mix_to_dataclass(cls, entry: Any):
|
||||||
|
entry_dict = entry._asdict()
|
||||||
|
entry_dict["id"] = entry_dict["mixid"]
|
||||||
|
del entry_dict["mixid"]
|
||||||
|
|
||||||
|
return Mix(**entry_dict)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def mixes_to_dataclasses(cls, entries: Any):
|
||||||
|
return [cls.mix_to_dataclass(entry) for entry in entries]
|
||||||
+10
-1
@@ -45,16 +45,22 @@ class Track:
|
|||||||
og_title: str = ""
|
og_title: str = ""
|
||||||
artisthashes: list[str] = field(default_factory=list)
|
artisthashes: list[str] = field(default_factory=list)
|
||||||
genrehashes: list[str] = field(default_factory=list)
|
genrehashes: list[str] = field(default_factory=list)
|
||||||
|
weakhash: str = ""
|
||||||
|
|
||||||
_pos: int = 0
|
_pos: int = 0
|
||||||
_ati: str = ""
|
_ati: str = ""
|
||||||
image: str = ""
|
image: str = ""
|
||||||
|
explicit: bool = False
|
||||||
fav_userids: list[int] = field(default_factory=list)
|
fav_userids: list[int] = field(default_factory=list)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_favorite(self):
|
def is_favorite(self):
|
||||||
return get_current_userid() in self.fav_userids
|
return get_current_userid() in self.fav_userids
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pathhash(self):
|
||||||
|
return create_hash(self.folder)
|
||||||
|
|
||||||
def toggle_favorite_user(self, userid: int):
|
def toggle_favorite_user(self, userid: int):
|
||||||
"""
|
"""
|
||||||
Toggles the favorite status of the track for a given user.
|
Toggles the favorite status of the track for a given user.
|
||||||
@@ -76,8 +82,11 @@ class Track:
|
|||||||
self.og_title = self.title
|
self.og_title = self.title
|
||||||
self.og_album = self.album
|
self.og_album = self.album
|
||||||
self.folder = self.folder + "/"
|
self.folder = self.folder + "/"
|
||||||
|
self.weakhash = create_hash(self.title, self.artists)
|
||||||
|
explicit_tag = self.extra.get("explicit", ["0"])
|
||||||
|
self.explicit = int(explicit_tag[0]) == 1
|
||||||
|
|
||||||
self.image = self.albumhash + ".webp"
|
self.image = self.albumhash + ".webp" + "?pathhash=" + self.pathhash
|
||||||
self.extra = {
|
self.extra = {
|
||||||
"disc_total": self.extra.get("disc_total", 0),
|
"disc_total": self.extra.get("disc_total", 0),
|
||||||
"track_total": self.extra.get("track_total", 0),
|
"track_total": self.extra.get("track_total", 0),
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from typing import Any
|
||||||
|
from hashlib import md5
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
|
from app.config import UserConfig
|
||||||
|
from app.models.track import Track
|
||||||
|
from app.settings import Paths
|
||||||
|
from app.utils.auth import get_current_userid
|
||||||
|
from app.utils.threading import background
|
||||||
|
from app.plugins import Plugin, plugin_method
|
||||||
|
|
||||||
|
from app.logger import log
|
||||||
|
|
||||||
|
|
||||||
|
class LastFmPlugin(Plugin):
|
||||||
|
"""
|
||||||
|
Last.fm scrobbler plugin.
|
||||||
|
"""
|
||||||
|
|
||||||
|
UPLOADING_DUMPS = False
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.config = UserConfig()
|
||||||
|
super().__init__("lastfm", "Last.fm scrobbler")
|
||||||
|
self.set_active(
|
||||||
|
bool(
|
||||||
|
self.config.lastfmApiKey
|
||||||
|
and self.config.lastfmApiSecret
|
||||||
|
and self.config.lastfmSessionKeys.get(str(get_current_userid()))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_api_signature(self, data: dict[str, Any]) -> str:
|
||||||
|
params = {k: v for k, v in data.items()}
|
||||||
|
|
||||||
|
signature = "".join(f"{k}{v}" for k, v in sorted(params.items()))
|
||||||
|
signature += self.config.lastfmApiSecret
|
||||||
|
|
||||||
|
return md5(signature.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def post(self, data: dict[str, Any], useSessionKey: bool = True):
|
||||||
|
url = "http://ws.audioscrobbler.com/2.0/?format=json"
|
||||||
|
data["api_key"] = self.config.lastfmApiKey
|
||||||
|
if useSessionKey:
|
||||||
|
data["sk"] = self.config.lastfmSessionKeys.get(str(get_current_userid()))
|
||||||
|
|
||||||
|
data["api_sig"] = self.get_api_signature(data)
|
||||||
|
|
||||||
|
final_url = (
|
||||||
|
url + "&" + "&".join(f"{k}={quote_plus(str(v))}" for k, v in data.items())
|
||||||
|
)
|
||||||
|
|
||||||
|
return requests.post(final_url)
|
||||||
|
|
||||||
|
def get_session_key(self, token: str):
|
||||||
|
data = {
|
||||||
|
"method": "auth.getSession",
|
||||||
|
"token": token,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = self.post(data, useSessionKey=False)
|
||||||
|
return res.json()["session"]["key"]
|
||||||
|
except Exception as e:
|
||||||
|
print("get_session_key error", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@plugin_method
|
||||||
|
@background
|
||||||
|
def scrobble(self, track: Track, timestamp: int):
|
||||||
|
log.info(f"Last.fm: logging track: {track.title} - {track.artists[0]['name']}")
|
||||||
|
data = {
|
||||||
|
"method": "track.scrobble",
|
||||||
|
"artist": track.artists[0]["name"],
|
||||||
|
"track": track.title,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"album": track.album,
|
||||||
|
"albumArtist": track.albumartists[0]["name"],
|
||||||
|
}
|
||||||
|
|
||||||
|
success = self.post_scrobble_data({**data})
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
self.dump_scrobble(data)
|
||||||
|
else:
|
||||||
|
self.upload_dumps()
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
def post_scrobble_data(self, data: dict[str, Any]):
|
||||||
|
"""
|
||||||
|
Uploads the scrobble data and handles the
|
||||||
|
response from the lastfm scrobble endpoint.
|
||||||
|
"""
|
||||||
|
log.info(f"scrobble data: {data}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = self.post(data)
|
||||||
|
except Exception as e:
|
||||||
|
log.warn("scrobble response error" + str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
log.info("scrobble response text: " + str(res.text))
|
||||||
|
log.info("scrobble response json: " + str(res.json()))
|
||||||
|
|
||||||
|
res_json: dict[str, Any] = res.json()
|
||||||
|
|
||||||
|
if res_json.get("error"):
|
||||||
|
log.error("LASTFM: scrobble error" + str(res_json))
|
||||||
|
|
||||||
|
if res_json["error"] == 9:
|
||||||
|
log.error("LAST.FM: Invalid session key")
|
||||||
|
# Invalid session key
|
||||||
|
self.config.lastfmSessionKeys.pop(str(get_current_userid()))
|
||||||
|
self.config.lastfmSessionKeys = self.config.lastfmSessionKeys
|
||||||
|
return False
|
||||||
|
|
||||||
|
if res_json.get("scrobbles", {}).get("@attr", {}).get("accepted") == 1:
|
||||||
|
log.info("scrobble accepted")
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
# SECTION: Persistence
|
||||||
|
def dump_scrobble(self, data: dict[str, Any]):
|
||||||
|
"""
|
||||||
|
Dumps the scrobble data to a file in the lastfm plugin directory.
|
||||||
|
"""
|
||||||
|
dump_dir = Path(Paths.get_plugins_path(), "lastfm")
|
||||||
|
if not dump_dir.exists():
|
||||||
|
dump_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
path = dump_dir / f"{int(time.time())}.json"
|
||||||
|
|
||||||
|
log.info(f"Dumping scrobble to {path}")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
|
||||||
|
def upload_dumps(self):
|
||||||
|
"""
|
||||||
|
Uploads the scrobble dumps to the lastfm api.
|
||||||
|
"""
|
||||||
|
if self.UPLOADING_DUMPS:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.UPLOADING_DUMPS = True
|
||||||
|
dump_dir = Path(Paths.get_plugins_path(), "lastfm")
|
||||||
|
|
||||||
|
if not dump_dir.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
for file in dump_dir.iterdir():
|
||||||
|
log.info(f"Uploading dump: {file}")
|
||||||
|
with open(file, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
success = self.post_scrobble_data(data)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
file.unlink()
|
||||||
|
finally:
|
||||||
|
self.UPLOADING_DUMPS = False
|
||||||
@@ -0,0 +1,606 @@
|
|||||||
|
from gettext import ngettext
|
||||||
|
from io import BytesIO
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from urllib.parse import quote
|
||||||
|
import requests
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app.db.userdata import MixTable
|
||||||
|
from app.models.artist import Artist
|
||||||
|
from app.models.mix import Mix
|
||||||
|
from app.models.track import Track
|
||||||
|
from app.plugins import Plugin, plugin_method
|
||||||
|
from app.settings import Paths
|
||||||
|
from app.store.albums import AlbumStore
|
||||||
|
from app.store.artists import ArtistStore
|
||||||
|
from app.store.tracks import TrackStore
|
||||||
|
from app.utils.dates import get_date_range, get_duration_ago
|
||||||
|
from app.utils.hashing import create_hash
|
||||||
|
from app.utils.mixes import balance_mix
|
||||||
|
from app.utils.stats import get_artists_in_period
|
||||||
|
|
||||||
|
|
||||||
|
class MixAlreadyExists(Exception):
|
||||||
|
"""
|
||||||
|
Raised when a mix with the same sourcehash already exists.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MixesPlugin(Plugin):
|
||||||
|
MAX_TRACKS_TO_FETCH = 5
|
||||||
|
MIN_TRACK_MIX_LENGTH = 15
|
||||||
|
MIX_TRACKS_LENGTH = 40
|
||||||
|
|
||||||
|
MIN_DAY_LISTEN_DURATION = 3 * 60 # 3 minutes
|
||||||
|
MIN_WEEK_LISTEN_DURATION = 10 * 60 # 10 minutes
|
||||||
|
MIN_MONTH_LISTEN_DURATION = 20 * 60 # 20 minutes
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("mixes", "Mixes")
|
||||||
|
self.server = "https://smcloud.mungaist.com"
|
||||||
|
# self.server = "http://localhost:1956"
|
||||||
|
|
||||||
|
# server_online = self.ping_server()
|
||||||
|
self.set_active(True)
|
||||||
|
|
||||||
|
def ping_server(self):
|
||||||
|
max_retries = 3
|
||||||
|
retry_delay = 2 # seconds
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
requests.get(self.server, timeout=10)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(
|
||||||
|
f"Failed to connect to the recommendation server (attempt {attempt + 1}/{max_retries})"
|
||||||
|
)
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
time.sleep(retry_delay)
|
||||||
|
continue
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
@plugin_method
|
||||||
|
def get_track_mix_data(self, tracks: list[Track], with_help: bool = False):
|
||||||
|
"""
|
||||||
|
Given a list of tracks, creates a mix by fetching data from the
|
||||||
|
Swing Music Cloud recommendation server.
|
||||||
|
|
||||||
|
The server returns a list of weak trackhashes. We use these to fetch
|
||||||
|
the matching track data from our library database. Found tracks are
|
||||||
|
then balanced and returned as the final mix tracklist.
|
||||||
|
|
||||||
|
:param with_help: Whether to include the help flag in the query.
|
||||||
|
The flag tells the server to find more data using other tracks from the same album.
|
||||||
|
"""
|
||||||
|
queries = [
|
||||||
|
{
|
||||||
|
"title": track.title,
|
||||||
|
"artists": [a["name"] for a in track.artists],
|
||||||
|
"album": track.og_album,
|
||||||
|
"with_help": with_help,
|
||||||
|
}
|
||||||
|
for track in tracks
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(f"{self.server}/radio", json=queries, timeout=30)
|
||||||
|
except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout):
|
||||||
|
print("Failed to connect to recommendation server")
|
||||||
|
return [], [], []
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = response.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print("Failed to decode JSON response from recommendation server")
|
||||||
|
return [], [], []
|
||||||
|
|
||||||
|
trackhashes: list[str] = results["tracks"]
|
||||||
|
|
||||||
|
trackmatches = TrackStore.get_flat_list()
|
||||||
|
trackmatches = [t for t in trackmatches if t.weakhash in trackhashes]
|
||||||
|
|
||||||
|
# filter out duplicates of the same weakhash
|
||||||
|
# group by weakhash and pick the one with the highest bitrate
|
||||||
|
grouped: dict[str, list[Track]] = {}
|
||||||
|
for track in trackmatches:
|
||||||
|
grouped.setdefault(track.weakhash, []).append(track)
|
||||||
|
|
||||||
|
trackmatches = [
|
||||||
|
max(group, key=lambda x: x.bitrate) for group in grouped.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
# sort by trackhash order
|
||||||
|
trackmatches = sorted(trackmatches, key=lambda x: trackhashes.index(x.weakhash))
|
||||||
|
|
||||||
|
# if the mix is short, try to fill it up with tracks
|
||||||
|
# from album and artist data from the cloud!
|
||||||
|
|
||||||
|
# Create as many filler tracks as possible
|
||||||
|
# Then the mix length will be controlled in the Mix model
|
||||||
|
# if len(trackmatches) < self.TRACK_MIX_LENGTH:
|
||||||
|
if True:
|
||||||
|
filler_tracks = self.fallback_create_artist_mix(
|
||||||
|
similar_artists=results["artists"],
|
||||||
|
similar_albums=results["albums"],
|
||||||
|
omit_trackhashes={t.weakhash for t in trackmatches},
|
||||||
|
# limit=self.TRACK_MIX_LENGTH - len(trackmatches),
|
||||||
|
)
|
||||||
|
trackmatches.extend(filler_tracks)
|
||||||
|
|
||||||
|
# try to balance the mix
|
||||||
|
trackmatches = balance_mix(trackmatches)
|
||||||
|
return trackmatches, results["albums"], results["artists"]
|
||||||
|
|
||||||
|
@plugin_method
|
||||||
|
# def get_artist_mix(self, artisthash: str):
|
||||||
|
# """
|
||||||
|
# Given an artisthash, creates an artist mix using the
|
||||||
|
# self.MAX_TRACKS_TO_FETCH most listened to tracks.
|
||||||
|
|
||||||
|
# Returns a tuple of the mix and the sourcehash.
|
||||||
|
# """
|
||||||
|
# artist = ArtistStore.artistmap[artisthash]
|
||||||
|
# tracks = TrackStore.get_tracks_by_trackhashes(artist.trackhashes)
|
||||||
|
|
||||||
|
# tracks = sorted(tracks, key=lambda x: x.playduration, reverse=True)
|
||||||
|
# sourcetracks = tracks[: self.MAX_TRACKS_TO_FETCH]
|
||||||
|
# sourcehash = create_hash(*[t.trackhash for t in sourcetracks])
|
||||||
|
|
||||||
|
# if MixTable.get_by_sourcehash(sourcehash):
|
||||||
|
# raise MixAlreadyExists()
|
||||||
|
|
||||||
|
# tracks, albums, artists = self.get_track_mix(tracks[: self.MAX_TRACKS_TO_FETCH])
|
||||||
|
# return (tracks, albums, artists, sourcehash)
|
||||||
|
|
||||||
|
@plugin_method
|
||||||
|
def create_artist_mixes(self, userid: int):
|
||||||
|
"""
|
||||||
|
Creates artist mixes for a given userid.
|
||||||
|
"""
|
||||||
|
mixes: list[Mix] = []
|
||||||
|
indexed = set()
|
||||||
|
|
||||||
|
today_start, today_end = get_date_range(duration="day")
|
||||||
|
last_2_days_start = get_duration_ago("day", 2)
|
||||||
|
last_7_days_start = get_duration_ago("week")
|
||||||
|
last_1_month_start = get_duration_ago("month")
|
||||||
|
|
||||||
|
artists = {
|
||||||
|
"today": {
|
||||||
|
"max": 4,
|
||||||
|
"artists": get_artists_in_period(today_start, today_end, userid),
|
||||||
|
"created": 0,
|
||||||
|
},
|
||||||
|
"last_2_days": {
|
||||||
|
"max": 3,
|
||||||
|
"artists": get_artists_in_period(
|
||||||
|
last_2_days_start, time.time(), userid
|
||||||
|
),
|
||||||
|
"created": 0,
|
||||||
|
},
|
||||||
|
"last_7_days": {
|
||||||
|
"max": 4,
|
||||||
|
"artists": get_artists_in_period(
|
||||||
|
last_7_days_start, time.time(), userid
|
||||||
|
),
|
||||||
|
"created": 0,
|
||||||
|
},
|
||||||
|
"last_1_month": {
|
||||||
|
"max": 4,
|
||||||
|
"artists": get_artists_in_period(
|
||||||
|
last_1_month_start, time.time(), userid
|
||||||
|
),
|
||||||
|
"created": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# FIXME: Make sure that different artists don't generate the same mix
|
||||||
|
|
||||||
|
for i, period in enumerate(artists.values()):
|
||||||
|
# if previous period has less than its max
|
||||||
|
# add the difference to this period's limit
|
||||||
|
limit = period["max"]
|
||||||
|
|
||||||
|
if i > 0:
|
||||||
|
previous_period = artists[list(artists.keys())[i - 1]]
|
||||||
|
if previous_period["created"] < previous_period["max"]:
|
||||||
|
limit += previous_period["max"] - previous_period["created"]
|
||||||
|
|
||||||
|
for artist in period["artists"]:
|
||||||
|
if period["created"] >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
if artist["artisthash"] in indexed:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# INFO: track['tracks'] is a dict of trackhashes and their counts
|
||||||
|
# get the trackhashes sorted by count
|
||||||
|
trackhashes = sorted(
|
||||||
|
artist["tracks"], key=lambda x: artist["tracks"][x], reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
mix = self.create_artist_mix(
|
||||||
|
artist, trackhashes[: self.MAX_TRACKS_TO_FETCH], userid=userid
|
||||||
|
)
|
||||||
|
|
||||||
|
if mix:
|
||||||
|
mixes.append(mix)
|
||||||
|
indexed.add(artist["artisthash"])
|
||||||
|
period["created"] += 1
|
||||||
|
|
||||||
|
print(f"⭐⭐⭐⭐ Created {len(mixes)} mixes")
|
||||||
|
print([m.title for m in mixes])
|
||||||
|
return mixes
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_mix_description(cls, tracks: list[Track], artishash: str):
|
||||||
|
"""
|
||||||
|
Constructs a description for a mix by putting together the first n=4
|
||||||
|
artists in the mix tracklist.
|
||||||
|
"""
|
||||||
|
first_4_artists = []
|
||||||
|
indexed = set()
|
||||||
|
|
||||||
|
for track in tracks:
|
||||||
|
if len(first_4_artists) < 4:
|
||||||
|
if (
|
||||||
|
track.artists[0]["artisthash"] != artishash
|
||||||
|
and track.artists[0]["artisthash"] not in indexed
|
||||||
|
):
|
||||||
|
first_4_artists.append(track.artists[0])
|
||||||
|
indexed.add(track.artists[0]["artisthash"])
|
||||||
|
|
||||||
|
if len(first_4_artists) == 4:
|
||||||
|
return f"Featuring {', '.join(a['name'] for a in first_4_artists)} and more"
|
||||||
|
|
||||||
|
if len(first_4_artists) > 0:
|
||||||
|
return f"Featuring {', '.join(a['name'] for a in first_4_artists)}"
|
||||||
|
|
||||||
|
return f"Featuring {tracks[0].artists[0]['name']}"
|
||||||
|
|
||||||
|
def create_artist_mix(
|
||||||
|
self, artist: dict[str, str], trackhashes: list[str], userid: int
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Given an artist dict, creates an artist mix.
|
||||||
|
"""
|
||||||
|
_artist = ArtistStore.artistmap.get(artist["artisthash"])
|
||||||
|
|
||||||
|
if not _artist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tracks = TrackStore.get_tracks_by_trackhashes(trackhashes)
|
||||||
|
# tracks = sorted(tracks, key=lambda x: x.playduration, reverse=True)
|
||||||
|
# sourcetracks = tracks[: self.MAX_TRACKS_TO_FETCH]
|
||||||
|
|
||||||
|
# INFO: Sort the trackhashes when creating the sourcehash
|
||||||
|
sourcehash = create_hash(
|
||||||
|
*sorted(trackhashes, key=lambda x: trackhashes.index(x))
|
||||||
|
)
|
||||||
|
|
||||||
|
db_mix = MixTable.get_by_sourcehash(sourcehash)
|
||||||
|
if db_mix:
|
||||||
|
print(f"🔍 Found existing mix for {_artist.artist.name}")
|
||||||
|
print(db_mix.title)
|
||||||
|
return db_mix
|
||||||
|
|
||||||
|
mix_tracks, albums, artists = self.get_track_mix_data(tracks)
|
||||||
|
|
||||||
|
if len(mix_tracks) < self.MIN_TRACK_MIX_LENGTH:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# try downloading artist image
|
||||||
|
mix_image = {"image": _artist.artist.image, "color": _artist.artist.color}
|
||||||
|
image = self.download_artist_image(_artist.artist)
|
||||||
|
|
||||||
|
if image:
|
||||||
|
mix_image["image"] = image
|
||||||
|
|
||||||
|
mix = Mix(
|
||||||
|
# the a prefix indicates that this is an artist mix
|
||||||
|
id=f"a{userid}{artist['artisthash']}",
|
||||||
|
title=artist["artist"] + " Radio",
|
||||||
|
description=self.get_mix_description(mix_tracks, artist["artisthash"]),
|
||||||
|
tracks=[t.trackhash for t in mix_tracks],
|
||||||
|
sourcehash=sourcehash,
|
||||||
|
userid=userid,
|
||||||
|
extra={
|
||||||
|
"type": "artist",
|
||||||
|
"artisthash": artist["artisthash"],
|
||||||
|
"sourcetracks": trackhashes,
|
||||||
|
"image": mix_image,
|
||||||
|
# NOTE: Save the similar albums and artists
|
||||||
|
# Related to the source tracks that were used to create the mix
|
||||||
|
# Will be useful when generating other homepage entries
|
||||||
|
"albums": albums,
|
||||||
|
"artists": artists,
|
||||||
|
},
|
||||||
|
timestamp=int(time.time()),
|
||||||
|
)
|
||||||
|
|
||||||
|
MixTable.insert_one(mix)
|
||||||
|
return mix
|
||||||
|
|
||||||
|
def download_artist_image(self, artist: Artist):
|
||||||
|
try:
|
||||||
|
res = requests.get(
|
||||||
|
f"{self.server}/mix/image?artist={quote(artist.name)}&type=Artist"
|
||||||
|
)
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if res.status_code == 200:
|
||||||
|
filename = f"{artist.artisthash}_{int(time.time())}.webp"
|
||||||
|
path = Paths.get_md_mixes_img_path() + "/" + filename
|
||||||
|
|
||||||
|
image = Image.open(BytesIO(res.content))
|
||||||
|
aspect_ratio = image.width / image.height
|
||||||
|
|
||||||
|
# resize to 512px
|
||||||
|
md_width = 512
|
||||||
|
md_height = int(md_width / aspect_ratio)
|
||||||
|
|
||||||
|
image = image.resize((md_width, md_height), Image.LANCZOS)
|
||||||
|
image.save(path, "webp")
|
||||||
|
|
||||||
|
# resize to 256px
|
||||||
|
sm_width = 256
|
||||||
|
sm_height = int(sm_width / aspect_ratio)
|
||||||
|
|
||||||
|
image = image.resize((sm_width, sm_height), Image.LANCZOS)
|
||||||
|
small_path = Paths.get_sm_mixes_img_path() + "/" + filename
|
||||||
|
image.save(small_path, "webp")
|
||||||
|
|
||||||
|
return filename
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fallback_create_artist_mix(
|
||||||
|
self,
|
||||||
|
# artist: dict[str, str],
|
||||||
|
similar_albums: list[str],
|
||||||
|
similar_artists: list[str],
|
||||||
|
omit_trackhashes: set[str],
|
||||||
|
limit: int = 99,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Creates an artist mix by selecting random tracks from similar albums and artists.
|
||||||
|
|
||||||
|
This is used when:
|
||||||
|
- The Swing Music recommendation server is down.
|
||||||
|
- The artist has less than self.MIN_TRACK_MIX_LENGTH tracks from the cloud mix.
|
||||||
|
- When we need to dilute the mix to balance the artist distribution.
|
||||||
|
|
||||||
|
:param similar_albums: A list of similar album weakhashes to select tracks from.
|
||||||
|
:param similar_artists: A list of similar artist hashes to select tracks from.
|
||||||
|
:param omit_trackhashes: A set of trackhashes to omit from the new tracklist.
|
||||||
|
:param limit: The maximum number of tracks to select.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mixtracks = []
|
||||||
|
albummatches = (
|
||||||
|
a
|
||||||
|
for a in AlbumStore.albummap.values()
|
||||||
|
if a.album.weakhash in similar_albums
|
||||||
|
)
|
||||||
|
|
||||||
|
for match in albummatches:
|
||||||
|
if len(mixtracks) >= limit:
|
||||||
|
return mixtracks
|
||||||
|
|
||||||
|
albumtracks = [
|
||||||
|
t
|
||||||
|
for t in TrackStore.get_tracks_by_trackhashes(match.trackhashes)
|
||||||
|
if t.weakhash not in omit_trackhashes
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(albumtracks) == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sample = random.sample(albumtracks, k=1)
|
||||||
|
mixtracks.extend(sample)
|
||||||
|
|
||||||
|
artistmatches = (
|
||||||
|
a
|
||||||
|
for a in ArtistStore.artistmap.values()
|
||||||
|
if a.artist.artisthash in similar_artists
|
||||||
|
)
|
||||||
|
|
||||||
|
for match in artistmatches:
|
||||||
|
if len(mixtracks) >= limit:
|
||||||
|
return mixtracks
|
||||||
|
|
||||||
|
artisttracks = [
|
||||||
|
t
|
||||||
|
for t in TrackStore.get_tracks_by_trackhashes(match.trackhashes)
|
||||||
|
if t.weakhash not in omit_trackhashes
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(artisttracks) == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sample = random.sample(artisttracks, k=1)
|
||||||
|
mixtracks.extend(sample)
|
||||||
|
|
||||||
|
return mixtracks
|
||||||
|
|
||||||
|
def get_mix_from_lastfm_data(self, artisthash: str, limit: int):
|
||||||
|
"""
|
||||||
|
Creates a mix from the locally available lastfm similar artists data.
|
||||||
|
|
||||||
|
The resulting mix is definitely expected to be of low quality.
|
||||||
|
|
||||||
|
TODO: Maybe implement this!
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_track_mix(cls, mix: Mix):
|
||||||
|
"""
|
||||||
|
Given a mix, returns the excess tracks as a custom mix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# INFO: If the mix can't have more than 20 tracks, return None
|
||||||
|
if len(mix.tracks) <= cls.MIX_TRACKS_LENGTH + 20:
|
||||||
|
return None
|
||||||
|
|
||||||
|
og_track = TrackStore.trackhashmap.get(mix.tracks[0])
|
||||||
|
|
||||||
|
if not og_track:
|
||||||
|
return None
|
||||||
|
|
||||||
|
og_track = og_track.get_best()
|
||||||
|
tracks = [og_track] + TrackStore.get_tracks_by_trackhashes(
|
||||||
|
mix.tracks[cls.MIX_TRACKS_LENGTH :]
|
||||||
|
)
|
||||||
|
|
||||||
|
trackmix = Mix(
|
||||||
|
id=f"t{mix.userid}{mix.extra['artisthash']}",
|
||||||
|
title=og_track.title,
|
||||||
|
description=cls.get_mix_description(tracks, mix.extra["artisthash"]),
|
||||||
|
tracks=[t.trackhash for t in tracks],
|
||||||
|
sourcehash=create_hash(*[t.trackhash for t in tracks]),
|
||||||
|
userid=mix.userid,
|
||||||
|
extra={
|
||||||
|
"type": "track",
|
||||||
|
"og_sourcehash": mix.sourcehash,
|
||||||
|
"images": cls.get_custom_mix_images(tracks),
|
||||||
|
"artists": None,
|
||||||
|
"albums": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
trackmix.timestamp = mix.timestamp
|
||||||
|
|
||||||
|
# INFO: Write track mix save state
|
||||||
|
if mix.extra.get("trackmix_saved"):
|
||||||
|
trackmix.saved = True
|
||||||
|
|
||||||
|
return trackmix
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_custom_mix_images(cls, tracks: list[Track]):
|
||||||
|
first_album = tracks[0].albumhash
|
||||||
|
first_img = {
|
||||||
|
"image": first_album + ".webp",
|
||||||
|
"type": "album",
|
||||||
|
"color": AlbumStore.albummap[first_album].album.color,
|
||||||
|
}
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
images = [first_img]
|
||||||
|
|
||||||
|
for track in tracks[1:]:
|
||||||
|
artisthash = track.artists[0]["artisthash"]
|
||||||
|
|
||||||
|
if artisthash in seen:
|
||||||
|
continue
|
||||||
|
|
||||||
|
artist = ArtistStore.artistmap.get(artisthash)
|
||||||
|
|
||||||
|
if not artist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen.add(artisthash)
|
||||||
|
|
||||||
|
image = {
|
||||||
|
"image": artisthash + ".webp",
|
||||||
|
"type": "artist",
|
||||||
|
"color": artist.artist.color,
|
||||||
|
}
|
||||||
|
|
||||||
|
images.append(image)
|
||||||
|
|
||||||
|
if len(images) == 3:
|
||||||
|
break
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_because_items(mixes: list[Mix]):
|
||||||
|
"""
|
||||||
|
Given a list of mixes, returns a list of artists that are similar to the
|
||||||
|
artists in the mixes.
|
||||||
|
"""
|
||||||
|
artists: dict[str, list[dict[str, str | int]]] = {}
|
||||||
|
albums: dict[str, list[dict[str, str | int]]] = {}
|
||||||
|
|
||||||
|
for mix in mixes:
|
||||||
|
mix_artisthash = mix.extra["artisthash"]
|
||||||
|
artists.setdefault(mix_artisthash, [])
|
||||||
|
albums.setdefault(mix_artisthash, [])
|
||||||
|
|
||||||
|
for artisthash in mix.extra["artists"]:
|
||||||
|
artist = ArtistStore.artistmap.get(artisthash)
|
||||||
|
|
||||||
|
if not artist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
artists[mix_artisthash].append(
|
||||||
|
{
|
||||||
|
"type": "artist",
|
||||||
|
"trackcount": artist.artist.trackcount,
|
||||||
|
"hash": artisthash,
|
||||||
|
"help_text": str(artist.artist.trackcount)
|
||||||
|
+ ngettext(" track", " tracks", artist.artist.trackcount),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for albumhash in mix.extra["albums"]:
|
||||||
|
album = AlbumStore.albummap.get(albumhash)
|
||||||
|
|
||||||
|
if not album:
|
||||||
|
continue
|
||||||
|
|
||||||
|
albums[mix_artisthash].append(
|
||||||
|
{
|
||||||
|
"type": "album",
|
||||||
|
"trackcount": album.album.trackcount,
|
||||||
|
"hash": albumhash,
|
||||||
|
"help_text": str(album.album.trackcount)
|
||||||
|
+ ngettext(" track", " tracks", album.album.trackcount),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# INFO: Sort artists by trackcount
|
||||||
|
artists[mix_artisthash] = sorted(
|
||||||
|
artists[mix_artisthash],
|
||||||
|
key=lambda x: x["trackcount"],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# INFO: Sort albums by trackcount
|
||||||
|
albums[mix_artisthash] = sorted(
|
||||||
|
albums[mix_artisthash],
|
||||||
|
key=lambda x: x["trackcount"],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
artisthash = mixes[0].extra["artisthash"]
|
||||||
|
because_you_listened_to_artist = {
|
||||||
|
"title": "Because you listened to "
|
||||||
|
+ ArtistStore.artistmap[artisthash].artist.name,
|
||||||
|
"items": albums[artisthash][:15],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Flatten list of artists and remove duplicates by artisthash
|
||||||
|
all_artists = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
# for artist_list in artists.values():
|
||||||
|
# for artist in artist_list:
|
||||||
|
# if artist["hash"] not in seen:
|
||||||
|
# all_artists.append(artist)
|
||||||
|
# seen.add(artist["hash"])
|
||||||
|
|
||||||
|
artists_you_might_like = {
|
||||||
|
"title": "Artists you might like",
|
||||||
|
"items": artists[artisthash][:15],
|
||||||
|
}
|
||||||
|
|
||||||
|
return because_you_listened_to_artist, artists_you_might_like
|
||||||
@@ -38,6 +38,7 @@ def serialize_for_card(album: Album):
|
|||||||
"extra",
|
"extra",
|
||||||
"id",
|
"id",
|
||||||
"lastplayed",
|
"lastplayed",
|
||||||
|
"weakhash",
|
||||||
}
|
}
|
||||||
|
|
||||||
return album_serializer(album, props_to_remove)
|
return album_serializer(album, props_to_remove)
|
||||||
|
|||||||
@@ -103,6 +103,30 @@ class Paths:
|
|||||||
def get_config_file_path(cls):
|
def get_config_file_path(cls):
|
||||||
return join(cls.get_app_dir(), "settings.json")
|
return join(cls.get_app_dir(), "settings.json")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_mixes_img_path(cls):
|
||||||
|
return join(cls.get_img_path(), "mixes")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_artist_mixes_img_path(cls):
|
||||||
|
return join(cls.get_mixes_img_path(), "artists")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_og_mixes_img_path(cls):
|
||||||
|
return join(cls.get_mixes_img_path(), "original")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_md_mixes_img_path(cls):
|
||||||
|
return join(cls.get_mixes_img_path(), "medium")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_sm_mixes_img_path(cls):
|
||||||
|
return join(cls.get_mixes_img_path(), "small")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_image_cache_path(cls):
|
||||||
|
return join(cls.get_img_path(), "cache")
|
||||||
|
|
||||||
|
|
||||||
# defaults
|
# defaults
|
||||||
class Defaults:
|
class Defaults:
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ def create_config_dir() -> None:
|
|||||||
|
|
||||||
playlist_img_path = os.path.join("images", "playlists")
|
playlist_img_path = os.path.join("images", "playlists")
|
||||||
|
|
||||||
|
|
||||||
|
mixes_img_path = settings.Paths.get_mixes_img_path()
|
||||||
|
og_mixes_img_path = settings.Paths.get_og_mixes_img_path()
|
||||||
|
md_mixes_img_path = settings.Paths.get_md_mixes_img_path()
|
||||||
|
sm_mixes_img_path = settings.Paths.get_sm_mixes_img_path()
|
||||||
|
|
||||||
dirs = [
|
dirs = [
|
||||||
"", # creates the config folder
|
"", # creates the config folder
|
||||||
sm_thumb_path,
|
sm_thumb_path,
|
||||||
@@ -73,6 +79,10 @@ def create_config_dir() -> None:
|
|||||||
md_artist_img_path,
|
md_artist_img_path,
|
||||||
small_artist_img_path,
|
small_artist_img_path,
|
||||||
large_artist_img_path,
|
large_artist_img_path,
|
||||||
|
mixes_img_path,
|
||||||
|
og_mixes_img_path,
|
||||||
|
md_mixes_img_path,
|
||||||
|
sm_mixes_img_path,
|
||||||
]
|
]
|
||||||
|
|
||||||
for _dir in dirs:
|
for _dir in dirs:
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
from itertools import groupby
|
|
||||||
import json
|
|
||||||
from pprint import pprint
|
|
||||||
import random
|
import random
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
from app.lib.tagger import create_albums
|
from app.lib.tagger import create_albums
|
||||||
from app.models import Album, Track
|
from app.models import Album, Track
|
||||||
from app.store.artists import ArtistStore
|
from app.store.artists import ArtistStore
|
||||||
from app.utils import flatten
|
|
||||||
from app.utils.auth import get_current_userid
|
from app.utils.auth import get_current_userid
|
||||||
from app.utils.customlist import CustomList
|
from app.utils.customlist import CustomList
|
||||||
from app.utils.remove_duplicates import remove_duplicates
|
|
||||||
|
|
||||||
from ..utils.hashing import create_hash
|
from ..utils.hashing import create_hash
|
||||||
from .tracks import TrackStore
|
from .tracks import TrackStore
|
||||||
from app.utils.progressbar import tqdm
|
|
||||||
|
|
||||||
ALBUM_LOAD_KEY = ""
|
ALBUM_LOAD_KEY = ""
|
||||||
|
|
||||||
@@ -44,20 +38,8 @@ class AlbumMapEntry:
|
|||||||
|
|
||||||
|
|
||||||
class AlbumStore:
|
class AlbumStore:
|
||||||
# albums: list[Album] = CustomList()
|
|
||||||
albummap: dict[str, AlbumMapEntry] = {}
|
albummap: dict[str, AlbumMapEntry] = {}
|
||||||
|
|
||||||
# @staticmethod
|
|
||||||
# def create_album(track: Track):
|
|
||||||
# """
|
|
||||||
# Creates album object from a track
|
|
||||||
# """
|
|
||||||
# return Album(
|
|
||||||
# albumhash=track.albumhash,
|
|
||||||
# albumartists=track.albumartists, # type: ignore
|
|
||||||
# title=track.og_album,
|
|
||||||
# )
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load_albums(cls, instance_key: str):
|
def load_albums(cls, instance_key: str):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -162,3 +162,16 @@ class ArtistStore:
|
|||||||
return TrackStore.get_tracks_by_trackhashes(entry.trackhashes)
|
return TrackStore.get_tracks_by_trackhashes(entry.trackhashes)
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def export(cls):
|
||||||
|
path = "artists.json"
|
||||||
|
|
||||||
|
with open(path, "w") as f:
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"name": a.name,
|
||||||
|
}
|
||||||
|
for a in cls.get_flat_list()
|
||||||
|
]
|
||||||
|
json.dump(data, f)
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.store.homepageentries import (
|
||||||
|
BecauseYouListenedToArtistHomepageEntry,
|
||||||
|
GenericRecoverableEntry,
|
||||||
|
HomepageEntry,
|
||||||
|
MixHomepageEntry,
|
||||||
|
RecentlyAddedHomepageEntry,
|
||||||
|
RecentlyPlayedHomepageEntry,
|
||||||
|
)
|
||||||
|
from app.utils.auth import get_current_userid
|
||||||
|
|
||||||
|
|
||||||
|
class HomepageStore:
|
||||||
|
"""
|
||||||
|
Stores the homepage items.
|
||||||
|
"""
|
||||||
|
|
||||||
|
entries: dict[str, HomepageEntry] = {
|
||||||
|
"recently_played": RecentlyPlayedHomepageEntry(
|
||||||
|
title="Recently played",
|
||||||
|
),
|
||||||
|
"artist_mixes": MixHomepageEntry(
|
||||||
|
title="Artist mixes for you",
|
||||||
|
description="Based on artists you have been listening to",
|
||||||
|
),
|
||||||
|
"custom_mixes": MixHomepageEntry(
|
||||||
|
title="Mixes for you",
|
||||||
|
description="Because artist mixes alone aren't enough",
|
||||||
|
),
|
||||||
|
"top_streamed_weekly_artists": GenericRecoverableEntry(
|
||||||
|
title="Top artists this week",
|
||||||
|
description="Your most played artists since Monday",
|
||||||
|
),
|
||||||
|
"top_streamed_monthly_artists": GenericRecoverableEntry(
|
||||||
|
title="Top artists this month",
|
||||||
|
description="Your most played artists since the start of the month",
|
||||||
|
),
|
||||||
|
"because_you_listened_to_artist": BecauseYouListenedToArtistHomepageEntry(
|
||||||
|
title="",
|
||||||
|
description="Artists similar to the artist you listened to",
|
||||||
|
),
|
||||||
|
"artists_you_might_like": BecauseYouListenedToArtistHomepageEntry(
|
||||||
|
title="Artists you might like",
|
||||||
|
description="Artists similar to the artists you have listened to",
|
||||||
|
),
|
||||||
|
"recently_added": RecentlyAddedHomepageEntry(
|
||||||
|
title="Recently added",
|
||||||
|
description="New music added to your library",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set_mixes(cls, items: list[Any], entrykey: str, userid: int | None = None):
|
||||||
|
idmap = {item.id: item for item in items}
|
||||||
|
cls.entries[entrykey].items[userid or get_current_userid()] = idmap
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_mix(cls, mixkey: str, mixid: str):
|
||||||
|
mix = cls.entries[mixkey].items.get(get_current_userid(), {}).get(mixid)
|
||||||
|
return mix.to_full_dict() if mix else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_homepage_items(cls, limit: int):
|
||||||
|
# return a dict of entry name to entry items
|
||||||
|
return [
|
||||||
|
{entry: cls.entries[entry].get_items(get_current_userid(), limit)}
|
||||||
|
for entry in cls.entries.keys()
|
||||||
|
if len(cls.entries[entry].items)
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_mix(cls, mixid: str):
|
||||||
|
mixentries = ["artist_mixes", "custom_mixes"]
|
||||||
|
|
||||||
|
for entry in mixentries:
|
||||||
|
mix = cls.entries[entry].items.get(get_current_userid(), {}).get(mixid)
|
||||||
|
if mix:
|
||||||
|
return mix
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
from abc import ABC
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.lib.home.recover_items import recover_items
|
||||||
|
from app.models.mix import Mix
|
||||||
|
|
||||||
|
class HomepageEntry(ABC):
|
||||||
|
"""
|
||||||
|
Base class for all homepage entries.
|
||||||
|
|
||||||
|
items is a dict of userid to a dict of stuff.
|
||||||
|
"""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
items: dict[int, Any]
|
||||||
|
|
||||||
|
def __init__(self, title: str, description: str):
|
||||||
|
self.title = title
|
||||||
|
self.description = description
|
||||||
|
|
||||||
|
def get_items(self, userid: int, limit: int | None = None):
|
||||||
|
"""
|
||||||
|
Return usable items for the homepage.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class MixHomepageEntry(HomepageEntry):
|
||||||
|
"""
|
||||||
|
A homepage entry for mixes.
|
||||||
|
self.items is a dict of userid to a dict of mixid to mix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items: dict[int, dict[str, Mix]]
|
||||||
|
|
||||||
|
def __init__(self, title: str, description: str):
|
||||||
|
super().__init__(title, description)
|
||||||
|
self.items = {}
|
||||||
|
|
||||||
|
def get_items(self, userid: int, limit: int | None = None):
|
||||||
|
items = []
|
||||||
|
|
||||||
|
for mix in self.items.get(userid, {}).values():
|
||||||
|
if limit and len(items) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"type": "mix",
|
||||||
|
"item": mix.to_dict(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": self.title,
|
||||||
|
"description": self.description,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RecentlyPlayedHomepageEntry(HomepageEntry):
|
||||||
|
"""
|
||||||
|
A homepage entry for recently played.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items: dict[int, list[dict[str, Any]]]
|
||||||
|
|
||||||
|
def __init__(self, title: str, description: str = ""):
|
||||||
|
super().__init__(title, description)
|
||||||
|
self.items = {}
|
||||||
|
|
||||||
|
def get_items(self, userid: int, limit: int | None = None):
|
||||||
|
items = self.items.get(userid, [])[:limit]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": self.title,
|
||||||
|
"description": self.description,
|
||||||
|
"items": recover_items(items),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RecentlyAddedHomepageEntry(RecentlyPlayedHomepageEntry):
|
||||||
|
"""
|
||||||
|
A homepage entry for recently added.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_items(self, userid: int, limit: int | None = None):
|
||||||
|
return super().get_items(0, limit)
|
||||||
|
|
||||||
|
|
||||||
|
class GenericRecoverableEntry(RecentlyPlayedHomepageEntry):
|
||||||
|
"""
|
||||||
|
A homepage entry for top streamed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# NOTE: This extends RecentlyPlayedHomepageEntry because
|
||||||
|
# the shape of the data is the same.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BecauseYouListenedToArtistHomepageEntry(RecentlyPlayedHomepageEntry):
|
||||||
|
"""
|
||||||
|
A homepage entry for because you listened to artist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# SHAPE: {userid: {title: str, items: list[RecoverableItem]}}
|
||||||
|
items: dict[int, dict[str, Any]]
|
||||||
|
|
||||||
|
def get_items(self, userid: int, limit: int | None = None):
|
||||||
|
title = self.items.get(userid, {}).get("title")
|
||||||
|
items = self.items.get(userid, {}).get("items", [])[:limit]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"items": recover_items(items),
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# from tqdm import tqdm
|
# from tqdm import tqdm
|
||||||
|
|
||||||
import itertools
|
import itertools
|
||||||
|
import json
|
||||||
from typing import Callable, Iterable
|
from typing import Callable, Iterable
|
||||||
from app.db.libdata import TrackTable
|
from app.db.libdata import TrackTable
|
||||||
|
|
||||||
@@ -313,3 +314,18 @@ class TrackStore:
|
|||||||
def get_recently_played(cls, limit: int):
|
def get_recently_played(cls, limit: int):
|
||||||
tracks = cls.get_flat_list()
|
tracks = cls.get_flat_list()
|
||||||
return sorted(tracks, key=lambda x: x.lastplayed, reverse=True)[:limit]
|
return sorted(tracks, key=lambda x: x.lastplayed, reverse=True)[:limit]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def export(cls):
|
||||||
|
path = "tracks.json"
|
||||||
|
|
||||||
|
with open(path, "w") as f:
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"title": t.title,
|
||||||
|
"album": t.album,
|
||||||
|
"artists": [a["name"] for a in t.artists],
|
||||||
|
}
|
||||||
|
for t in cls.get_flat_list()
|
||||||
|
]
|
||||||
|
json.dump(data, f)
|
||||||
|
|||||||
+51
-5
@@ -46,7 +46,7 @@ def date_string_to_time_passed(prev_date: str) -> str:
|
|||||||
return timestamp_to_time_passed(then)
|
return timestamp_to_time_passed(then)
|
||||||
|
|
||||||
|
|
||||||
def seconds_to_time_string(seconds):
|
def seconds_to_time_string(seconds: int):
|
||||||
"""
|
"""
|
||||||
Converts seconds to a time string. e.g. 1 hour 2 minutes, 1 hour 2 seconds, 1 hour, 1 minute 2 seconds, etc.
|
Converts seconds to a time string. e.g. 1 hour 2 minutes, 1 hour 2 seconds, 1 hour, 1 minute 2 seconds, etc.
|
||||||
"""
|
"""
|
||||||
@@ -66,17 +66,29 @@ def seconds_to_time_string(seconds):
|
|||||||
return f"{remaining_seconds} sec"
|
return f"{remaining_seconds} sec"
|
||||||
|
|
||||||
|
|
||||||
def get_date_range(duration: str):
|
def get_date_range(duration: str, units_ago: int = 0):
|
||||||
"""
|
"""
|
||||||
Returns a tuple of dates representing the start and end of a given duration.
|
Returns a tuple of dates representing the start and end of a given duration.
|
||||||
"""
|
"""
|
||||||
date_range = None
|
date_range = None
|
||||||
|
seconds_ago = 0
|
||||||
|
|
||||||
|
if duration != "alltime":
|
||||||
|
seconds_ago = (
|
||||||
|
pendulum.now() - pendulum.now().subtract().start_of(duration)
|
||||||
|
).total_seconds() * units_ago
|
||||||
|
print("seconds_ago", duration, str(seconds_ago))
|
||||||
|
|
||||||
match duration:
|
match duration:
|
||||||
case "week" | "month" | "year":
|
case "day" | "week" | "month" | "year":
|
||||||
date_range = (
|
date_range = (
|
||||||
pendulum.now().subtract().start_of(duration).timestamp(),
|
pendulum.now()
|
||||||
pendulum.now().end_of(duration).timestamp(),
|
.subtract(seconds=seconds_ago)
|
||||||
|
.start_of(duration)
|
||||||
|
.timestamp(),
|
||||||
|
pendulum.now()
|
||||||
|
# .end_of(duration)
|
||||||
|
.timestamp(),
|
||||||
)
|
)
|
||||||
case "alltime":
|
case "alltime":
|
||||||
date_range = (0, pendulum.now().timestamp())
|
date_range = (0, pendulum.now().timestamp())
|
||||||
@@ -86,6 +98,40 @@ def get_date_range(duration: str):
|
|||||||
return (int(date_range[0]), int(date_range[1]))
|
return (int(date_range[0]), int(date_range[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def get_duration_ago(duration: str, units_ago: int = 1) -> int:
|
||||||
|
"""
|
||||||
|
Returns the start of the last duration.
|
||||||
|
"""
|
||||||
|
seconds_in_day = 24 * 60 * 60
|
||||||
|
now = pendulum.now()
|
||||||
|
|
||||||
|
match duration:
|
||||||
|
case "day":
|
||||||
|
return int(
|
||||||
|
now.subtract(seconds=seconds_in_day * units_ago).timestamp()
|
||||||
|
)
|
||||||
|
case "week":
|
||||||
|
return int(
|
||||||
|
now
|
||||||
|
.subtract(seconds=seconds_in_day * 7 * units_ago)
|
||||||
|
.timestamp()
|
||||||
|
)
|
||||||
|
case "month":
|
||||||
|
return int(
|
||||||
|
now
|
||||||
|
.subtract(seconds=seconds_in_day * 30 * units_ago)
|
||||||
|
.timestamp()
|
||||||
|
)
|
||||||
|
case "year":
|
||||||
|
return int(
|
||||||
|
now
|
||||||
|
.subtract(seconds=seconds_in_day * 365 * units_ago)
|
||||||
|
.timestamp()
|
||||||
|
)
|
||||||
|
case _:
|
||||||
|
raise ValueError(f"Invalid duration: {duration}")
|
||||||
|
|
||||||
|
|
||||||
def get_duration_in_seconds(duration: str) -> int:
|
def get_duration_in_seconds(duration: str) -> int:
|
||||||
"""
|
"""
|
||||||
Returns the number of seconds in a given duration.
|
Returns the number of seconds in a given duration.
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
from app.models.track import Track
|
||||||
|
from typing import List, Dict, Tuple
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
|
||||||
|
def violates_gap_rule(
|
||||||
|
balanced_mix: Dict[int, Track], position: int, track: Track, gap: int = 3
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if placing the track at the given position violates the gap rule.
|
||||||
|
|
||||||
|
The gap rule is violated if the track has an artist in common with any
|
||||||
|
track within the gap range (default = 3).
|
||||||
|
"""
|
||||||
|
track_artists = set(artist["artisthash"] for artist in track.artists)
|
||||||
|
|
||||||
|
for i in range(max(0, position - gap), position):
|
||||||
|
if i in balanced_mix:
|
||||||
|
existing_artists = set(
|
||||||
|
artist["artisthash"] for artist in balanced_mix[i].artists
|
||||||
|
)
|
||||||
|
if track_artists.intersection(existing_artists):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def find_next_position(
|
||||||
|
balanced_mix: Dict[int, Track], start: int, track: Track, total_tracks: int
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Find the next available position for the track, starting from 'start' and wrapping around.
|
||||||
|
"""
|
||||||
|
for i in range(start, total_tracks):
|
||||||
|
if i not in balanced_mix and not violates_gap_rule(balanced_mix, i, track):
|
||||||
|
return i
|
||||||
|
for i in range(start):
|
||||||
|
if i not in balanced_mix and not violates_gap_rule(balanced_mix, i, track):
|
||||||
|
return i
|
||||||
|
return start # If no better position is found, return the original position
|
||||||
|
|
||||||
|
|
||||||
|
def is_tracklist_balanced(tracks: List[Track], gap: int = 3) -> Tuple[bool, bool]:
|
||||||
|
"""
|
||||||
|
Checks if a tracklist is balanced or can be balanced.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- tracks: List of Track objects
|
||||||
|
- gap: Minimum number of tracks between songs by the same artist (default 3)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- A tuple (can_be_balanced, is_currently_balanced)
|
||||||
|
"""
|
||||||
|
total_tracks = len(tracks)
|
||||||
|
|
||||||
|
# Count tracks per artist (considering only the first artist)
|
||||||
|
artist_counts = Counter(track.artists[0]["artisthash"] for track in tracks)
|
||||||
|
|
||||||
|
# Calculate the maximum number of tracks an artist can have in a balanced list
|
||||||
|
max_tracks_per_artist = (total_tracks + gap) // (gap + 1)
|
||||||
|
|
||||||
|
# Check if it's mathematically possible to balance the tracklist
|
||||||
|
can_be_balanced = all(
|
||||||
|
count <= max_tracks_per_artist for count in artist_counts.values()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not can_be_balanced:
|
||||||
|
return False, False
|
||||||
|
|
||||||
|
# Check if the current arrangement is balanced
|
||||||
|
is_currently_balanced = True
|
||||||
|
artist_last_positions = {}
|
||||||
|
|
||||||
|
for i, track in enumerate(tracks):
|
||||||
|
artist = track.artists[0]["artisthash"]
|
||||||
|
if artist in artist_last_positions:
|
||||||
|
if i - artist_last_positions[artist] <= gap:
|
||||||
|
is_currently_balanced = False
|
||||||
|
break
|
||||||
|
artist_last_positions[artist] = i
|
||||||
|
|
||||||
|
return can_be_balanced, is_currently_balanced
|
||||||
|
|
||||||
|
|
||||||
|
def balance_mix(tracks: List[Track]) -> List[Track]:
|
||||||
|
"""
|
||||||
|
Balances the mix by ensuring that the tracks in a mix are distributed evenly.
|
||||||
|
Preserves the overall rating order of tracks while minimizing disruption.
|
||||||
|
|
||||||
|
Tracks that need to be moved are moved down the tracklist until they no longer
|
||||||
|
violate the gap rule.
|
||||||
|
"""
|
||||||
|
can_be_balanced, is_balanced = is_tracklist_balanced(tracks)
|
||||||
|
|
||||||
|
if not can_be_balanced:
|
||||||
|
print("Warning: This tracklist cannot be perfectly balanced.")
|
||||||
|
# Proceed with best-effort balancing
|
||||||
|
|
||||||
|
if is_balanced:
|
||||||
|
return tracks # Already balanced, no need to modify
|
||||||
|
|
||||||
|
balanced_mix: Dict[int, Track] = {}
|
||||||
|
total_tracks = len(tracks)
|
||||||
|
|
||||||
|
for i, track in enumerate(tracks):
|
||||||
|
if i in balanced_mix or not violates_gap_rule(balanced_mix, i, track):
|
||||||
|
balanced_mix[i] = track
|
||||||
|
else:
|
||||||
|
new_position = find_next_position(balanced_mix, i, track, total_tracks)
|
||||||
|
balanced_mix[new_position] = track
|
||||||
|
|
||||||
|
# Convert the dictionary back to a list, preserving the new order
|
||||||
|
return [balanced_mix[i] for i in sorted(balanced_mix.keys())]
|
||||||
+27
-14
@@ -11,28 +11,40 @@ from app.store.tracks import TrackStore
|
|||||||
from app.utils.dates import seconds_to_time_string
|
from app.utils.dates import seconds_to_time_string
|
||||||
|
|
||||||
|
|
||||||
def get_artists_in_period(start_time: int, end_time: int):
|
def get_artists_in_period(
|
||||||
scrobbles = ScrobbleTable.get_all_in_period(start_time, end_time)
|
start_time: int | float, end_time: int | float, userid: int | None = None
|
||||||
artists = defaultdict(lambda: {"playcount": 0, "playduration": 0})
|
):
|
||||||
|
scrobbles = ScrobbleTable.get_all_in_period(start_time, end_time, userid)
|
||||||
|
artists: Any = defaultdict(
|
||||||
|
lambda: {"playcount": 0, "playduration": 0, "tracks": {}}
|
||||||
|
)
|
||||||
|
|
||||||
for scrobble in scrobbles:
|
for scrobble in scrobbles:
|
||||||
track = TrackStore.get_tracks_by_trackhashes([scrobble.trackhash])
|
track = TrackStore.get_tracks_by_trackhashes([scrobble.trackhash])
|
||||||
if not track:
|
if not track:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
track = track[0]
|
track = track[0]
|
||||||
|
|
||||||
for artist in track.artists:
|
for artist in track.artists:
|
||||||
artisthash = artist["artisthash"]
|
artisthash = artist["artisthash"]
|
||||||
|
|
||||||
|
artists[artisthash]["artist"] = artist["name"]
|
||||||
artists[artisthash]["artisthash"] = artist["artisthash"]
|
artists[artisthash]["artisthash"] = artist["artisthash"]
|
||||||
artists[artisthash]["playcount"] += 1
|
artists[artisthash]["playcount"] += 1
|
||||||
artists[artisthash]["playduration"] += scrobble.duration
|
artists[artisthash]["playduration"] += scrobble.duration
|
||||||
|
|
||||||
return list(artists.values())
|
# index the track counts too
|
||||||
|
artists[artisthash]["tracks"][track.trackhash] = (
|
||||||
|
artists[artisthash]["tracks"].get(track.trackhash, 0) + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
artists = list(artists.values())
|
||||||
|
return sorted(artists, key=lambda x: x["playduration"], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
def get_albums_in_period(start_time: int, end_time: int):
|
def get_albums_in_period(start_time: int, end_time: int, userid: int | None = None):
|
||||||
scrobbles = ScrobbleTable.get_all_in_period(start_time, end_time)
|
scrobbles = ScrobbleTable.get_all_in_period(start_time, end_time, userid)
|
||||||
albums: dict[str, Album] = {}
|
albums: dict[str, Album] = {}
|
||||||
|
|
||||||
for scrobble in scrobbles:
|
for scrobble in scrobbles:
|
||||||
@@ -58,8 +70,8 @@ def get_albums_in_period(start_time: int, end_time: int):
|
|||||||
return list(albums.values())
|
return list(albums.values())
|
||||||
|
|
||||||
|
|
||||||
def get_tracks_in_period(start_time: int, end_time: int):
|
def get_tracks_in_period(start_time: int, end_time: int, userid: int | None = None):
|
||||||
scrobbles = ScrobbleTable.get_all_in_period(start_time, end_time)
|
scrobbles = ScrobbleTable.get_all_in_period(start_time, end_time, userid)
|
||||||
tracks: dict[str, Track] = {}
|
tracks: dict[str, Track] = {}
|
||||||
duration = 0
|
duration = 0
|
||||||
|
|
||||||
@@ -158,12 +170,14 @@ def calculate_scrobble_trend(current_scrobbles: int, previous_scrobbles: int) ->
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def calculate_new_artists(current_artists: List[dict[str, Any]], timestamp: int):
|
def calculate_new_artists(
|
||||||
|
current_artists: List[dict[str, Any]], timestamp: int, userid: int | None = None
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Calculate the number of new artists based on the current and all previous scrobbles.
|
Calculate the number of new artists based on the current and all previous scrobbles.
|
||||||
"""
|
"""
|
||||||
current_artists_set = set(artist["artisthash"] for artist in current_artists)
|
current_artists_set = set(artist["artisthash"] for artist in current_artists)
|
||||||
all_records = ScrobbleTable.get_all_in_period(0, timestamp)
|
all_records = ScrobbleTable.get_all_in_period(0, timestamp, userid)
|
||||||
trackhashes = set(record.trackhash for record in all_records)
|
trackhashes = set(record.trackhash for record in all_records)
|
||||||
|
|
||||||
previous_artists_set = set()
|
previous_artists_set = set()
|
||||||
@@ -219,7 +233,7 @@ def get_track_group_stats(tracks: list[Track], is_album: bool = False):
|
|||||||
"toptrack",
|
"toptrack",
|
||||||
f"top track ({seconds_to_time_string(top_track.playduration)} listened)",
|
f"top track ({seconds_to_time_string(top_track.playduration)} listened)",
|
||||||
f"{top_track.title}",
|
f"{top_track.title}",
|
||||||
top_track.image,
|
top_track.image if top_track else None,
|
||||||
)
|
)
|
||||||
if top_track
|
if top_track
|
||||||
else StatItem(
|
else StatItem(
|
||||||
@@ -237,7 +251,7 @@ def get_track_group_stats(tracks: list[Track], is_album: bool = False):
|
|||||||
"playcount": 0,
|
"playcount": 0,
|
||||||
"playduration": 0,
|
"playduration": 0,
|
||||||
"title": track.album,
|
"title": track.album,
|
||||||
"image": track.image,
|
"image": track.image if track.image else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
albums_map[track.albumhash]["playcount"] += 1
|
albums_map[track.albumhash]["playcount"] += 1
|
||||||
@@ -254,8 +268,7 @@ def get_track_group_stats(tracks: list[Track], is_album: bool = False):
|
|||||||
"topalbum",
|
"topalbum",
|
||||||
f"top album ({seconds_to_time_string(top_album['playduration'])} listened)",
|
f"top album ({seconds_to_time_string(top_album['playduration'])} listened)",
|
||||||
f"{top_album['title']}",
|
f"{top_album['title']}",
|
||||||
top_album["image"],
|
top_album["image"])
|
||||||
)
|
|
||||||
if top_album
|
if top_album
|
||||||
else StatItem(
|
else StatItem(
|
||||||
"topalbum",
|
"topalbum",
|
||||||
|
|||||||
@@ -21,11 +21,14 @@ import setproctitle
|
|||||||
|
|
||||||
from app.api import create_api
|
from app.api import create_api
|
||||||
from app.arg_handler import ProcessArgs
|
from app.arg_handler import ProcessArgs
|
||||||
|
from app.crons import start_cron_jobs
|
||||||
from app.lib.index import IndexEverything
|
from app.lib.index import IndexEverything
|
||||||
from app.plugins.register import register_plugins
|
from app.plugins.register import register_plugins
|
||||||
from app.settings import FLASKVARS, TCOLOR, Info
|
from app.settings import FLASKVARS, TCOLOR, Info
|
||||||
from app.setup import load_into_mem, run_setup
|
from app.setup import load_into_mem, run_setup
|
||||||
from app.start_info_logger import log_startup_info
|
from app.start_info_logger import log_startup_info
|
||||||
|
from app.store.artists import ArtistStore
|
||||||
|
from app.store.tracks import TrackStore
|
||||||
from app.utils.filesystem import get_home_res_path
|
from app.utils.filesystem import get_home_res_path
|
||||||
from app.utils.paths import getClientFilesExtensions
|
from app.utils.paths import getClientFilesExtensions
|
||||||
from app.utils.threading import background
|
from app.utils.threading import background
|
||||||
@@ -59,7 +62,7 @@ mimetypes.add_type("application/manifest+json", ".webmanifest")
|
|||||||
|
|
||||||
|
|
||||||
# Background tasks
|
# Background tasks
|
||||||
@background
|
# @background
|
||||||
def bg_run_setup():
|
def bg_run_setup():
|
||||||
IndexEverything()
|
IndexEverything()
|
||||||
|
|
||||||
@@ -72,20 +75,19 @@ def bg_run_setup():
|
|||||||
@background
|
@background
|
||||||
def run_swingmusic():
|
def run_swingmusic():
|
||||||
log_startup_info()
|
log_startup_info()
|
||||||
bg_run_setup()
|
|
||||||
register_plugins()
|
register_plugins()
|
||||||
|
|
||||||
# start_watchdog()
|
# start_watchdog()
|
||||||
|
|
||||||
setproctitle.setproctitle(f"swingmusic ::{FLASKVARS.get_flask_port()}")
|
setproctitle.setproctitle(f"swingmusic ::{FLASKVARS.get_flask_port()}")
|
||||||
|
# bg_run_setup()
|
||||||
|
start_cron_jobs()
|
||||||
|
|
||||||
|
|
||||||
# Setup function calls
|
# Setup function calls
|
||||||
Info.load()
|
Info.load()
|
||||||
ProcessArgs()
|
ProcessArgs()
|
||||||
run_setup()
|
run_setup()
|
||||||
load_into_mem()
|
|
||||||
run_swingmusic()
|
|
||||||
|
|
||||||
|
|
||||||
# Create the Flask app
|
# Create the Flask app
|
||||||
@@ -102,7 +104,7 @@ whitelisted_routes = {
|
|||||||
"/auth/refresh",
|
"/auth/refresh",
|
||||||
"/docs",
|
"/docs",
|
||||||
}
|
}
|
||||||
blacklist_extensions = {".webp"}.union(getClientFilesExtensions())
|
blacklist_extensions = {".webp", ".jpg"}.union(getClientFilesExtensions())
|
||||||
|
|
||||||
|
|
||||||
def skipAuthAction():
|
def skipAuthAction():
|
||||||
@@ -224,6 +226,12 @@ def print_memory_usage(response: Response):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
||||||
|
load_into_mem()
|
||||||
|
run_swingmusic()
|
||||||
|
TrackStore.export()
|
||||||
|
ArtistStore.export()
|
||||||
|
|
||||||
host = FLASKVARS.get_flask_host()
|
host = FLASKVARS.get_flask_host()
|
||||||
port = FLASKVARS.get_flask_port()
|
port = FLASKVARS.get_flask_port()
|
||||||
|
|
||||||
@@ -231,7 +239,7 @@ if __name__ == "__main__":
|
|||||||
app,
|
app,
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
threads=10,
|
threads=100,
|
||||||
ipv6=True,
|
ipv6=True,
|
||||||
ipv4=True,
|
ipv4=True,
|
||||||
)
|
)
|
||||||
|
|||||||
Generated
+21
-7
@@ -2052,6 +2052,20 @@ files = [
|
|||||||
{file = "roundrobin-0.0.4.tar.gz", hash = "sha256:7e9d19a5bd6123d99993fb935fa86d25c88bb2096e493885f61737ed0f5e9abd"},
|
{file = "roundrobin-0.0.4.tar.gz", hash = "sha256:7e9d19a5bd6123d99993fb935fa86d25c88bb2096e493885f61737ed0f5e9abd"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schedule"
|
||||||
|
version = "1.2.2"
|
||||||
|
description = "Job scheduling for humans."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.7"
|
||||||
|
files = [
|
||||||
|
{file = "schedule-1.2.2-py3-none-any.whl", hash = "sha256:5bef4a2a0183abf44046ae0d164cadcac21b1db011bdd8102e4a0c1e91e06a7d"},
|
||||||
|
{file = "schedule-1.2.2.tar.gz", hash = "sha256:15fe9c75fe5fd9b9627f3f19cc0ef1420508f9f9a46f45cd0769ef75ede5f0b7"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
timezone = ["pytz"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "setproctitle"
|
name = "setproctitle"
|
||||||
version = "1.3.3"
|
version = "1.3.3"
|
||||||
@@ -2309,17 +2323,17 @@ widechars = ["wcwidth"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinytag"
|
name = "tinytag"
|
||||||
version = "1.10.1"
|
version = "2.0.0"
|
||||||
description = "Read music meta data and length of MP3, OGG, OPUS, MP4, M4A, FLAC, WMA and Wave files"
|
description = "Read audio file metadata"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=2.7"
|
python-versions = ">=3.7"
|
||||||
files = [
|
files = [
|
||||||
{file = "tinytag-1.10.1-py3-none-any.whl", hash = "sha256:e437654d04c966fbbbdbf807af61eb9759f1d80e4173a7d26202506b37cfdaf0"},
|
{file = "tinytag-2.0.0-py3-none-any.whl", hash = "sha256:971b9dceae2d1de73b5e8300639ea0b41454633b899426e702aed15f0e72a9b4"},
|
||||||
{file = "tinytag-1.10.1.tar.gz", hash = "sha256:122a63b836f85094aacca43fc807aaee3290be3de17d134f5f4a08b509ae268f"},
|
{file = "tinytag-2.0.0.tar.gz", hash = "sha256:d041f53d15553bb148549bfbc7feab445caf7105ba95fa2ecb9827bb06b62275"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
tests = ["flake8", "pytest", "pytest-cov"]
|
tests = ["coverage", "mypy", "pycodestyle", "pylint", "pytest"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tomli"
|
name = "tomli"
|
||||||
@@ -2761,4 +2775,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = ">=3.10,<3.12"
|
python-versions = ">=3.10,<3.12"
|
||||||
content-hash = "43972b6ffadd14e5047f067a0258f2428ebe351df8bd032dc0bf05df379678a6"
|
content-hash = "733ca957831c695560fe292a6dfdad13c3fc905695f473cd48cf13bfba8defdc"
|
||||||
|
|||||||
+2
-1
@@ -14,7 +14,7 @@ Pillow = "^9.0.1"
|
|||||||
"colorgram.py" = "^1.2.0"
|
"colorgram.py" = "^1.2.0"
|
||||||
tqdm = "^4.65.0"
|
tqdm = "^4.65.0"
|
||||||
rapidfuzz = "^2.13.7"
|
rapidfuzz = "^2.13.7"
|
||||||
tinytag = "^1.10.1"
|
tinytag = ">=2.0.0"
|
||||||
Unidecode = "^1.3.6"
|
Unidecode = "^1.3.6"
|
||||||
psutil = "^5.9.4"
|
psutil = "^5.9.4"
|
||||||
show-in-file-manager = "^1.1.4"
|
show-in-file-manager = "^1.1.4"
|
||||||
@@ -32,6 +32,7 @@ memory-profiler = "^0.61.0"
|
|||||||
sortedcontainers = "^2.4.0"
|
sortedcontainers = "^2.4.0"
|
||||||
xxhash = "^3.4.1"
|
xxhash = "^3.4.1"
|
||||||
ffmpeg-python = "^0.2.0"
|
ffmpeg-python = "^0.2.0"
|
||||||
|
schedule = "^1.2.2"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pylint = "^2.15.5"
|
pylint = "^2.15.5"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ astroid==2.15.8
|
|||||||
attrs==23.1.0
|
attrs==23.1.0
|
||||||
black==22.12.0
|
black==22.12.0
|
||||||
blinker==1.6.3
|
blinker==1.6.3
|
||||||
|
boto3==1.35.71
|
||||||
|
botocore==1.35.71
|
||||||
Brotli==1.1.0
|
Brotli==1.1.0
|
||||||
certifi==2023.7.22
|
certifi==2023.7.22
|
||||||
charset-normalizer==3.3.0
|
charset-normalizer==3.3.0
|
||||||
@@ -29,6 +31,7 @@ iniconfig==2.0.0
|
|||||||
isort==5.12.0
|
isort==5.12.0
|
||||||
itsdangerous==2.1.2
|
itsdangerous==2.1.2
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
|
jmespath==1.0.1
|
||||||
lazy-object-proxy==1.9.0
|
lazy-object-proxy==1.9.0
|
||||||
locust==2.20.1
|
locust==2.20.1
|
||||||
MarkupSafe==2.1.3
|
MarkupSafe==2.1.3
|
||||||
@@ -55,7 +58,10 @@ pyxdg==0.28
|
|||||||
pyzmq==25.1.2
|
pyzmq==25.1.2
|
||||||
rapidfuzz==2.15.2
|
rapidfuzz==2.15.2
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
|
requests-ip-rotator==1.0.14
|
||||||
roundrobin==0.0.4
|
roundrobin==0.0.4
|
||||||
|
s3transfer==0.10.4
|
||||||
|
schedule==1.2.2
|
||||||
setproctitle==1.3.3
|
setproctitle==1.3.3
|
||||||
show-in-file-manager==1.1.4
|
show-in-file-manager==1.1.4
|
||||||
six==1.16.0
|
six==1.16.0
|
||||||
|
|||||||
Reference in New Issue
Block a user