mirror of
https://github.com/Alexander-D-Karpov/akarpov
synced 2024-11-24 11:13:44 +03:00
added last fm auth and scrobling
This commit is contained in:
parent
cb7bbbf49f
commit
dba8bc9ba4
|
@ -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
96
about_page/about.html
Normal 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>
|
1
about_page/archlinux-icon.svg
Normal file
1
about_page/archlinux-icon.svg
Normal 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 |
24
akarpov/blog/migrations/0010_alter_tag_color.py
Normal file
24
akarpov/blog/migrations/0010_alter_tag_color.py
Normal 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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
46
akarpov/music/migrations/0015_usermusicprofile.py
Normal file
46
akarpov/music/migrations/0015_usermusicprofile.py
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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">
|
||||
|
|
15
akarpov/templates/music/landing.html
Normal file
15
akarpov/templates/music/landing.html
Normal 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 %}
|
|
@ -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"
|
||||
),
|
||||
),
|
||||
]
|
20
akarpov/users/themes/migrations/0002_alter_theme_color.py
Normal file
20
akarpov/users/themes/migrations/0002_alter_theme_color.py
Normal 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
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
64
poetry.lock
generated
64
poetry.lock
generated
|
@ -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"
|
||||
|
@ -4748,6 +4793,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"
|
||||
|
@ -7327,4 +7389,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 = "c42bc63ed7bf613a8430fe48f0ed6a2ae218fc995618bea29584db33ce8cc46c"
|
||||
|
|
|
@ -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]
|
||||
|
|
Loading…
Reference in New Issue
Block a user