Compare commits

...

5 Commits

Author SHA1 Message Date
dependabot[bot]
3385c99d80
Bump drf-spectacular from 0.26.5 to 0.27.0
Bumps [drf-spectacular](https://github.com/tfranzel/drf-spectacular) from 0.26.5 to 0.27.0.
- [Release notes](https://github.com/tfranzel/drf-spectacular/releases)
- [Changelog](https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/tfranzel/drf-spectacular/compare/0.26.5...0.27.0)

---
updated-dependencies:
- dependency-name: drf-spectacular
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-14 14:31:27 +00:00
dba8bc9ba4 added last fm auth and scrobling 2024-01-14 17:26:43 +03:00
cb7bbbf49f updated music image handling in serializers 2024-01-12 13:18:04 +03:00
fb2b611dd8 fixed author and album images 2024-01-12 00:52:28 +03:00
017efcb1cc fixed music handling 2024-01-11 23:23:29 +03:00
21 changed files with 544 additions and 72 deletions

View File

@ -9,3 +9,9 @@ SENTRY_DSN=
EMAIL_PASSWORD=
EMAIL_USER=
EMAIL_USE_SSL=false
LAST_FM_API_KET=
LAST_FM_SECRET=
SPOTIFY_ID=
SPOTIFY_SECRET=
YANDEX_TOKEN=

96
about_page/about.html Normal file
View File

@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Neofetch in HTML</title>
<link href="https://fonts.googleapis.com/css?family=Fira+Mono:500" rel="stylesheet">
<style>
body {
background-color: #1e1e1e;
color: #c7c7c7;
font-family: 'Fira Mono', monospace;
margin: 0;
padding:
20px;
display: grid;
grid-template-columns: auto 1fr;
gap: 20px;
}
.neofetch-container {
align-items: start;
grid-column: 1 / -1;
display: grid;
grid-template-columns: minmax(auto, 1fr) 3fr;
}
.neofetch-logo img {
width: 100%;
height: auto;
grid-row: 1;
}
.neofetch-info {
white-space: pre;
line-height: 1.1;
grid-row: 1;
}
.color-blocks {
grid-column: 1 / -1;
display: flex;
justify-content: start;
margin-top: 5px;
}
.color-block {
height: 20px;
width: 20px;
margin-right: 4px;
}
.red { background-color: #FF5555; }
.green { background-color: #50FA7B; }
.yellow { background-color: #F1FA8C; }
.blue { background-color: #BD93F9; }
.purple { background-color: #FF79C6; }
.cyan { background-color: #8BE9FD; }
.white { background-color: #BBBBBB; }
.black { background-color: #282A36; }
</style>
</head>
<body>
<divЮ class="neofetch-container">
<div class="neofetch-logo">
<img src="archlinux-icon.svg" alt="Arch Linux Logo">
</div>
<div class="neofetch-info">
<span class="key-name">sanspie@TanOS</span>
<br>
-------------<br>
<span class="key-name">OS:</span> ArchLinux x86_64<br>
<span class="key-name">Kernel:</span> 6.6.10-arch1-1<br>
<span class="key-name">Uptime:</span> 59 mins<br>
<span class="key-name">Packages:</span> 2097 (pacman)<br>
<span class="key-name">Shell:</span> zsh 5.9<br>
<span class="key-name">Resolution:</span> 1920x1080, 3440x1440<br>
<span class="key-name">DE:</span> Plasma 5.27.10<br>
<span class="key-name">WM:</span> KWin<br>
<span class="key-name">WM Theme:</span> Sweet-Dark<br>
<span class="key-name">Theme:</span> [Plasma], Breeze [GTK2/3]<br>
<span class="key-name">Icons:</span> candy-icons [Plasma], candy-icons [GTK2/3]<br>
<span class="key-name">Terminal:</span> alacritty<br>
<span class="key-name">CPU:</span> AMD Ryzen 7 3700X (16) @ 3.600GHz<br>
<span class="key-name">GPU:</span> NVIDIA GeForce RTX 3060 Lite Hash Rate<br>
<span class="key-name">Memory:</span> 12384MiB / 32022MiB<br>
<span class="key-name">Disk (/):</span> 351G / 466G (76%)<br>
</div>
<div class="color-blocks">
<div class="color-block black"></div> <!-- Add a black color block if needed -->
<div class="color-block red"></div>
<div class="color-block green"></div>
<div class="color-block yellow"></div>
<div class="color-block blue"></div>
<div class="color-block purple"></div>
<div class="color-block cyan"></div>
<div class="color-block white"></div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><path d="M31.994-.006c-2.85 6.985-4.568 11.554-7.74 18.332 1.945 2.062 4.332 4.462 8.2 7.174-4.168-1.715-7-3.437-9.136-5.224-4.06 8.47-10.42 20.538-23.327 43.73C10.145 58.15 18 54.54 25.338 53.16c-.315-1.354-.494-2.818-.48-4.345l.012-.325c.16-6.5 3.542-11.498 7.547-11.158s7.118 5.886 6.957 12.386a18.36 18.36 0 0 1-.409 3.491c7.25 1.418 15.03 5.02 25.037 10.797l-5.42-10.026c-2.65-2.053-5.413-4.726-11.05-7.62 3.875 1.007 6.65 2.168 8.8 3.467-17.1-31.84-18.486-36.07-24.35-49.833z" fill="#1793d1" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 589 B

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2.8 on 2024-01-14 12:06
import colorfield.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("blog", "0009_alter_comment_parent"),
]
operations = [
migrations.AlterField(
model_name="tag",
name="color",
field=colorfield.fields.ColorField(
blank=True,
default="#FF0000",
image_field=None,
max_length=25,
samples=None,
),
),
]

View File

@ -13,6 +13,7 @@
SongUserRating,
TempFileUpload,
UserListenHistory,
UserMusicProfile,
)
@ -147,3 +148,5 @@ class UserListenHistoryAdmin(admin.ModelAdmin):
admin.site.register(UserListenHistory, UserListenHistoryAdmin)
admin.site.register(UserMusicProfile)

View File

@ -1,3 +1,4 @@
from django.db.models import Q
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
@ -65,6 +66,7 @@ class ListSongSerializer(SetUserModelSerializer):
album = serializers.SerializerMethodField(method_name="get_album")
authors = serializers.SerializerMethodField(method_name="get_authors")
liked = serializers.SerializerMethodField(method_name="get_liked")
image_cropped = serializers.SerializerMethodField(method_name="get_image")
@extend_schema_field(serializers.BooleanField)
def get_liked(self, obj):
@ -88,6 +90,25 @@ def get_authors(self, obj):
).data
return None
@extend_schema_field(serializers.ImageField)
def get_image(self, obj):
img = None
if obj.image_cropped:
img = obj.image_cropped
else:
album = Album.objects.cache().get(id=obj.album_id)
if album.image_cropped:
img = album.image_cropped
else:
authors = Author.objects.cache().filter(
Q(songs__id=obj.id) & ~Q(image="")
)
if authors:
img = authors.first().image_cropped
if img:
return self.context["request"].build_absolute_uri(img.url)
return None
class Meta:
model = Song
fields = [

View File

@ -0,0 +1,46 @@
# Generated by Django 4.2.8 on 2024-01-14 12:06
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("music", "0014_alter_album_authors"),
]
operations = [
migrations.CreateModel(
name="UserMusicProfile",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"lastfm_username",
models.CharField(blank=True, max_length=50, null=True),
),
(
"lastfm_token",
models.CharField(blank=True, max_length=50, null=True),
),
("preferences", models.JSONField(null=True)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="music_profile",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@ -161,3 +161,15 @@ class UserListenHistory(models.Model):
class Meta:
ordering = ["-created"]
class UserMusicProfile(models.Model):
user = models.OneToOneField(
"users.User", related_name="music_profile", on_delete=models.CASCADE
)
lastfm_username = models.CharField(max_length=50, blank=True, null=True)
lastfm_token = models.CharField(max_length=50, blank=True, null=True)
preferences = models.JSONField(null=True)
def __str__(self):
return f"{self.user} music profile"

View File

@ -166,28 +166,34 @@ def get_yandex_artist_info(artist_name: str, client: Client):
def download_image(url, save_path):
image_path = os.path.join(save_path, f"tmp_{randint(10000, 99999)}.png")
if type(url) is Cover:
url = url["uri"]
if not str(url).startswith("http"):
url = "https://" + str(url)
response = requests.get(url)
if response.status_code == 200:
image_path = os.path.join(save_path, f"tmp_{randint(10000, 99999)}.png")
with open(image_path, "wb") as f:
f.write(response.content)
return image_path
url.download(image_path)
else:
if not url.startswith("http"):
url = "https://" + url
response = requests.get(url)
if response.status_code == 200:
with open(image_path, "wb") as f:
f.write(response.content)
return image_path
return ""
def update_album_info(album: AlbumModel, track_name: str) -> None:
def update_album_info(album: AlbumModel, author_name: str = None) -> None:
client = yandex_login()
spotify_session = create_spotify_session()
# Retrieve info from both services
yandex_album_info = get_yandex_album_info(album.name + " - " + track_name, client)
spotify_album_info = get_spotify_album_info(
album.name + " - " + track_name, spotify_session
)
if author_name:
yandex_album_info = get_yandex_album_info(
album.name + " - " + author_name, client
)
spotify_album_info = get_spotify_album_info(
album.name + " - " + author_name, spotify_session
)
else:
yandex_album_info = get_yandex_album_info(album.name, client)
spotify_album_info = get_spotify_album_info(album.name, spotify_session)
# Combine and prioritize Spotify data
album_data = {}
@ -246,6 +252,7 @@ def update_album_info(album: AlbumModel, track_name: str) -> None:
save=True,
)
os.remove(image_path)
album.save()
# Update Album Authors from Spotify data if available
if spotify_album_info and "artists" in spotify_album_info:
@ -271,17 +278,13 @@ def update_album_info(album: AlbumModel, track_name: str) -> None:
album.save()
def update_author_info(author: Author, track_name: str) -> None:
def update_author_info(author: Author) -> None:
client = yandex_login()
spotify_session = create_spotify_session()
# Retrieve info from both services
yandex_artist_info = get_yandex_artist_info(
author.name + " - " + track_name, client
)
spotify_artist_info = get_spotify_artist_info(
author.name + " - " + track_name, spotify_session
)
yandex_artist_info = get_yandex_artist_info(author.name, client)
spotify_artist_info = get_spotify_artist_info(author.name, spotify_session)
# Combine and prioritize Spotify data
author_data = {}
@ -332,6 +335,7 @@ def update_author_info(author: Author, track_name: str) -> None:
save=True,
)
os.remove(image_path)
author.save()
if generated_name and not Author.objects.filter(slug=generated_name).exists():
if len(generated_name) > 20:

View File

@ -101,6 +101,46 @@ def download_from_youtube_link(link: str, user_id: int) -> Song:
chapter_path = path.split(".")[0] + chapters[i][2] + ".mp3"
info = search_all_platforms(chapters[i][2])
audio.export(chapter_path, format="mp3")
if not info["album_image"].startswith("/"):
r = requests.get(info["album_image"])
img_pth = str(
settings.MEDIA_ROOT
+ f"/{info['album_image'].split('/')[-1]}_{str(randint(100, 999))}"
)
with open(img_pth, "wb") as f:
f.write(r.content)
im = Image.open(img_pth)
im.save(str(f"{img_pth}.png"))
img_pth = f"{img_pth}.png"
else:
img_pth = info["album_image"]
if "genre" in info:
song = load_track(
chapter_path,
img_pth,
user_id,
info["artists"],
info["album_name"],
chapters[i][2],
genre=info["genre"],
)
else:
song = load_track(
chapter_path,
img_pth,
user_id,
info["artists"],
info["album_name"],
chapters[i][2],
)
os.remove(chapter_path)
else:
print(f"[processing] loading {title}")
info = search_all_platforms(title)
if not info["album_image"].startswith("/"):
r = requests.get(info["album_image"])
img_pth = str(
settings.MEDIA_ROOT
@ -113,46 +153,13 @@ def download_from_youtube_link(link: str, user_id: int) -> Song:
im.save(str(f"{img_pth}.png"))
os.remove(img_pth)
if "genre" in info:
song = load_track(
chapter_path,
f"{img_pth}.png",
user_id,
info["artists"],
info["album_name"],
chapters[i][2],
genre=info["genre"],
)
else:
song = load_track(
chapter_path,
f"{img_pth}.png",
user_id,
info["artists"],
info["album_name"],
chapters[i][2],
)
os.remove(chapter_path)
else:
print(f"[processing] loading {title}")
info = search_all_platforms(title)
r = requests.get(info["album_image"])
img_pth = str(
settings.MEDIA_ROOT
+ f"/{info['album_image'].split('/')[-1]}_{str(randint(100, 999))}"
)
with open(img_pth, "wb") as f:
f.write(r.content)
im = Image.open(img_pth)
im.save(str(f"{img_pth}.png"))
os.remove(img_pth)
img_pth = f"{img_pth}.png"
else:
img_pth = info["album_image"]
if "genre" in info:
song = load_track(
path,
f"{img_pth}.png",
img_pth,
user_id,
info["artists"],
info["album_name"],
@ -162,7 +169,7 @@ def download_from_youtube_link(link: str, user_id: int) -> Song:
else:
song = load_track(
path,
f"{img_pth}.png",
img_pth,
user_id,
info["artists"],
info["album_name"],

View File

@ -17,15 +17,14 @@ def auto_delete_file_on_delete(sender, instance, **kwargs):
@receiver(post_save, sender=Author)
def author_create(sender, instance, created, **kwargs):
if created:
songs = Song.objects.filter(authors=instance)
update_author_info(instance, songs.first().name if songs.exists() else "")
update_author_info(instance)
@receiver(post_save, sender=Album)
def album_create(sender, instance, created, **kwargs):
if created:
songs = Song.objects.filter(album=instance)
update_album_info(instance, songs.first().name if songs.exists() else "")
authors = instance.authors.all()
update_album_info(instance, authors.first().name if authors.exists() else None)
@receiver(post_save)

View File

@ -1,15 +1,21 @@
import pylast
import structlog
from asgiref.sync import async_to_sync
from celery import shared_task
from channels.layers import get_channel_layer
from django.conf import settings
from django.utils import timezone
from django.utils.timezone import now
from pytube import Channel, Playlist
from akarpov.music.api.serializers import SongSerializer
from akarpov.music.models import RadioSong, Song, UserListenHistory
from akarpov.music.models import RadioSong, Song, UserListenHistory, UserMusicProfile
from akarpov.music.services import yandex, youtube
from akarpov.music.services.file import load_dir, load_file
from akarpov.utils.celery import get_scheduled_tasks_name
logger = structlog.get_logger(__name__)
@shared_task
def list_tracks(url, user_id):
@ -105,11 +111,31 @@ def listen_to_song(song_id, user_id=None):
if (
last_listen
and last_listen.song_id == song_id
or last_listen.created + s.length > now()
or last_listen
and last_listen.created + s.length > now()
):
return
UserListenHistory.objects.create(
user_id=user_id,
song_id=song_id,
)
try:
user_profile = UserMusicProfile.objects.get(user_id=user_id)
lastfm_token = user_profile.lastfm_token
# Initialize Last.fm network with the user's session key
network = pylast.LastFMNetwork(
api_key=settings.LAST_FM_API_KEY,
api_secret=settings.LAST_FM_SECRET,
session_key=lastfm_token,
)
song = Song.objects.get(id=song_id)
artist_name = song.artists_names
track_name = song.name
timestamp = int(timezone.now().timestamp())
network.scrobble(artist=artist_name, title=track_name, timestamp=timestamp)
except UserMusicProfile.DoesNotExist:
pass
except Exception as e:
logger.error(f"Last.fm scrobble error: {e}")
return song_id

View File

@ -5,6 +5,7 @@
app_name = "music"
urlpatterns = [
path("", views.music_landing, name="landing"),
path("upload", views.load_track_view, name="load"),
path("upload_file", views.load_track_file_view, name="upload"),
path("<str:slug>", views.song_view, name="song"),
@ -13,4 +14,6 @@
path("playlist/<str:slug>", views.playlist_view, name="playlist"),
path("radio/", views.radio_main_view, name="radio"),
path("player/", views.music_player_view, name="player"),
path("lastfm/callback", views.lastfm_callback, name="lastfm_callback"),
path("lastfm/connect", views.lastfm_auth, name="lastfm_connect"),
]

View File

@ -1,8 +1,22 @@
import pylast
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.sites.shortcuts import get_current_site
from django.shortcuts import redirect
from django.urls import reverse
from django.views import generic
from akarpov.common.views import SuperUserRequiredMixin
from akarpov.music.forms import FileUploadForm, TracksLoadForm
from akarpov.music.models import Album, Author, Playlist, Song, TempFileUpload
from akarpov.music.models import (
Album,
Author,
Playlist,
Song,
TempFileUpload,
UserMusicProfile,
)
from akarpov.music.services.base import load_track_file, load_tracks
@ -97,3 +111,68 @@ def get_queryset(self):
music_player_view = MusicPlayerView.as_view()
@login_required
def lastfm_auth(request):
API_KEY = settings.LAST_FM_API_KEY
if not API_KEY:
raise Exception("LAST_FM_API_KEY not set in settings")
callback_url = (
f"https://{get_current_site(request).domain}{reverse('music:lastfm_callback')}"
)
auth_url = f"http://www.last.fm/api/auth/?api_key={API_KEY}&cb={callback_url}"
return redirect(auth_url)
@login_required
def lastfm_callback(request):
API_KEY = settings.LAST_FM_API_KEY
API_SECRET = settings.LAST_FM_SECRET
token = request.GET.get("token")
if not token:
messages.error(request, "No token provided by Last.fm")
return redirect(reverse("music:landing"))
network = pylast.LastFMNetwork(api_key=API_KEY, api_secret=API_SECRET)
skg = pylast.SessionKeyGenerator(network)
try:
session_key = skg.get_web_auth_session_key(url="", token=token)
user = request.user
UserMusicProfile.objects.update_or_create(
user=user,
defaults={
"lastfm_token": session_key,
"lastfm_username": user.username,
},
)
messages.success(request, "Last.fm account is successfully connected")
except Exception as e:
messages.error(
request, f"There was an error connecting your Last.fm account: {e}"
)
return redirect(reverse("music:landing"))
class MusicLanding(generic.TemplateView):
template_name = "music/landing.html"
def get_context_data(self, **kwargs):
if self.request.user.is_authenticated:
try:
kwargs["last_fm_account"] = UserMusicProfile.objects.get(
user=self.request.user
).lastfm_username
except UserMusicProfile.DoesNotExist:
kwargs["last_fm_account"] = None
return super().get_context_data(**kwargs)
music_landing = MusicLanding.as_view()

View File

@ -62,6 +62,10 @@
<a href="{% url 'about' %}" class="{% active_link 'about' %} text-muted nav-link px-sm-0 px-2">
<i class="fs-5 bi-person"></i><span class="ms-1 d-none d-sm-inline">About me</span></a>
</li>
<li>
<a href="{% url 'music:landing' %}" class="{% active_link 'music:landing' %} text-muted nav-link px-sm-0 px-2">
<i class="fs-5 bi-music-note-list"></i><span class="ms-1 d-none d-sm-inline">Music</span></a>
</li>
{% if request.user.is_authenticated %}
<li>
<a href="{% url 'files:main' %}" class="{% active_link 'files:main' %} text-muted nav-link px-sm-0 px-2">

View File

@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% block content %}
<h1>Welcome to music app</h1>
<p>This is mainly the backend of music, you should consider using side clients like: <a href="https://next.akarpov.ru/msuic">otomir23's client</a></p>
{% if request.user.is_authenticated %}
{% if last_fm_account %}
<p>Last.fm connected to {{ last_fm_account }}, <a href="{% url 'music:lastfm_connect' %}">reconnect</a></p>
{% else %}
<p>Last.fm is not connected, <a href="{% url 'music:lastfm_connect' %}">connect</a></p>
{% endif %}
{% else %}
<p>Login to connect last.fm account</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,19 @@
# Generated by Django 4.2.8 on 2024-01-14 12:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0013_user_agree_data_to_be_sold"),
]
operations = [
migrations.AlterField(
model_name="user",
name="agree_data_to_be_sold",
field=models.BooleanField(
default=False, verbose_name="Agree my data to be sold to vendors"
),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.2.8 on 2024-01-14 12:06
import colorfield.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("themes", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="theme",
name="color",
field=colorfield.fields.ColorField(
default="#FFFFFF", image_field=None, max_length=25, samples=None
),
),
]

View File

@ -599,6 +599,10 @@
# YANDEX_MUSIC
MUSIC_YANDEX_TOKEN = env("YANDEX_TOKEN", default="")
# LAST.FM
LAST_FM_API_KEY = env("LAST_FM_API_KET", default="")
LAST_FM_SECRET = env("LAST_FM_SECRET", default="")
# ROBOTS
# ------------------------------------------------------------------------------
ROBOTS_USE_SITEMAP = True

90
poetry.lock generated
View File

@ -2035,13 +2035,13 @@ files = [
[[package]]
name = "drf-spectacular"
version = "0.26.5"
version = "0.27.0"
description = "Sane and flexible OpenAPI 3 schema generation for Django REST framework"
optional = false
python-versions = ">=3.6"
files = [
{file = "drf-spectacular-0.26.5.tar.gz", hash = "sha256:aee55330a774ba8a9cbdb125714d1c9ee05a8aafd3ce3be8bfd26527649aeb44"},
{file = "drf_spectacular-0.26.5-py3-none-any.whl", hash = "sha256:c0002a820b11771fdbf37853deb371947caf0159d1afeeffe7598e964bc1db94"},
{file = "drf-spectacular-0.27.0.tar.gz", hash = "sha256:18d7ae74b2b5d533fd31f1c591ebaa5cce1447e0976ced927401e3163040dea9"},
{file = "drf_spectacular-0.27.0-py3-none-any.whl", hash = "sha256:6ab2d20674244e8c940c2883f744b43c34fc68c70ea3aefa802f574108c9699b"},
]
[package.dependencies]
@ -2728,6 +2728,51 @@ files = [
{file = "hiredis-2.3.2.tar.gz", hash = "sha256:733e2456b68f3f126ddaf2cd500a33b25146c3676b97ea843665717bda0c5d43"},
]
[[package]]
name = "httpcore"
version = "1.0.2"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
files = [
{file = "httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7"},
{file = "httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.13,<0.15"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<0.23.0)"]
[[package]]
name = "httpx"
version = "0.26.0"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
files = [
{file = "httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd"},
{file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"},
]
[package.dependencies]
anyio = "*"
certifi = "*"
httpcore = "==1.*"
idna = "*"
sniffio = "*"
[package.extras]
brotli = ["brotli", "brotlicffi"]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
[[package]]
name = "humanize"
version = "4.9.0"
@ -3521,6 +3566,16 @@ files = [
{file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"},
{file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"},
{file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"},
{file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"},
{file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"},
{file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"},
{file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"},
{file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"},
{file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"},
{file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"},
{file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"},
{file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"},
{file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"},
{file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"},
{file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"},
{file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"},
@ -4748,6 +4803,23 @@ check = ["check-manifest", "docutils", "flake8", "flake8-black", "flake8-depreca
docs = ["sphinx (>=1.8)", "sphinx-intl", "sphinx-py3doc-enhanced-theme", "sphinx-rtd-theme"]
test = ["coverage[toml] (>=5.2)", "py-cpuinfo", "pytest", "pytest-benchmark", "pytest-cov", "pytest-pep8", "pytest-profiling"]
[[package]]
name = "pylast"
version = "5.2.0"
description = "A Python interface to Last.fm and Libre.fm"
optional = false
python-versions = ">=3.8"
files = [
{file = "pylast-5.2.0-py3-none-any.whl", hash = "sha256:89c7c01ea9f08c83865999d8907835157a8096e77dd9dc23420246eb66cfcff5"},
{file = "pylast-5.2.0.tar.gz", hash = "sha256:bb046804ef56a0c18072c750d61a282d47ac102a3b0b9c44a023eaf5b0934b0a"},
]
[package.dependencies]
httpx = "*"
[package.extras]
tests = ["flaky", "pytest", "pytest-cov", "pytest-random-order", "pyyaml"]
[[package]]
name = "pylint"
version = "3.0.3"
@ -5222,6 +5294,7 @@ files = [
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
{file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
{file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
{file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
{file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
@ -5229,8 +5302,15 @@ files = [
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
{file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
{file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
{file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
{file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
@ -5247,6 +5327,7 @@ files = [
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
{file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
{file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
{file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
{file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
@ -5254,6 +5335,7 @@ files = [
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
{file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
{file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
{file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
@ -7327,4 +7409,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.11,<3.13"
content-hash = "eb20087fe00cc1a298f95eaa4d6ca08ba69ab0ff8babb39182d8fbc21592cb29"
content-hash = "386773a631caacada37c77c53dcce0dedf6b357bd0ec8482966cdebee587b391"

View File

@ -32,7 +32,7 @@ django-colorfield = "^0.11.0"
djangorestframework = "^3.14.0"
django-rest-auth = "^0.9.5"
django-cors-headers = "^4.0.0"
drf-spectacular = "^0.26.2"
drf-spectacular = "^0.27.0"
werkzeug = {extras = ["watchdog"], version = "^2.3.4"}
ipdb = "^0.13.13"
watchfiles = "^0.18.1"
@ -119,6 +119,7 @@ qrcode = {extras = ["pil"], version = "^7.4.2"}
spotdl = "^4.2.4"
fuzzywuzzy = "^0.18.0"
python-levenshtein = "^0.23.0"
pylast = "^5.2.0"
[build-system]