diff --git a/.env.example b/.env.example index dd17f0f..cfc4629 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/about_page/about.html b/about_page/about.html new file mode 100644 index 0000000..c6ce5d5 --- /dev/null +++ b/about_page/about.html @@ -0,0 +1,96 @@ + + + + + + Neofetch in HTML + + + + + + +
+ sanspie@TanOS +
+ -------------
+ OS: ArchLinux x86_64
+ Kernel: 6.6.10-arch1-1
+ Uptime: 59 mins
+ Packages: 2097 (pacman)
+ Shell: zsh 5.9
+ Resolution: 1920x1080, 3440x1440
+ DE: Plasma 5.27.10
+ WM: KWin
+ WM Theme: Sweet-Dark
+ Theme: [Plasma], Breeze [GTK2/3]
+ Icons: candy-icons [Plasma], candy-icons [GTK2/3]
+ Terminal: alacritty
+ CPU: AMD Ryzen 7 3700X (16) @ 3.600GHz
+ GPU: NVIDIA GeForce RTX 3060 Lite Hash Rate
+ Memory: 12384MiB / 32022MiB
+ Disk (/): 351G / 466G (76%)
+
+
+
+
+
+
+
+
+
+
+
+ + + diff --git a/about_page/archlinux-icon.svg b/about_page/archlinux-icon.svg new file mode 100644 index 0000000..b6673ef --- /dev/null +++ b/about_page/archlinux-icon.svg @@ -0,0 +1 @@ + diff --git a/akarpov/blog/migrations/0010_alter_tag_color.py b/akarpov/blog/migrations/0010_alter_tag_color.py new file mode 100644 index 0000000..b70d884 --- /dev/null +++ b/akarpov/blog/migrations/0010_alter_tag_color.py @@ -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, + ), + ), + ] diff --git a/akarpov/music/admin.py b/akarpov/music/admin.py index b2f1585..0d41a27 100644 --- a/akarpov/music/admin.py +++ b/akarpov/music/admin.py @@ -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) diff --git a/akarpov/music/migrations/0015_usermusicprofile.py b/akarpov/music/migrations/0015_usermusicprofile.py new file mode 100644 index 0000000..728df15 --- /dev/null +++ b/akarpov/music/migrations/0015_usermusicprofile.py @@ -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, + ), + ), + ], + ), + ] diff --git a/akarpov/music/models.py b/akarpov/music/models.py index d5b5495..5cbc61b 100644 --- a/akarpov/music/models.py +++ b/akarpov/music/models.py @@ -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" diff --git a/akarpov/music/tasks.py b/akarpov/music/tasks.py index 95c9520..1d26655 100644 --- a/akarpov/music/tasks.py +++ b/akarpov/music/tasks.py @@ -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 diff --git a/akarpov/music/urls.py b/akarpov/music/urls.py index 6696296..5bc31fe 100644 --- a/akarpov/music/urls.py +++ b/akarpov/music/urls.py @@ -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("", views.song_view, name="song"), @@ -13,4 +14,6 @@ path("playlist/", 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"), ] diff --git a/akarpov/music/views.py b/akarpov/music/views.py index e785c04..448789e 100644 --- a/akarpov/music/views.py +++ b/akarpov/music/views.py @@ -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() diff --git a/akarpov/templates/base.html b/akarpov/templates/base.html index 33a4ef7..be67455 100644 --- a/akarpov/templates/base.html +++ b/akarpov/templates/base.html @@ -62,6 +62,10 @@ About me +
  • + + Music +
  • {% if request.user.is_authenticated %}
  • diff --git a/akarpov/templates/music/landing.html b/akarpov/templates/music/landing.html new file mode 100644 index 0000000..489331c --- /dev/null +++ b/akarpov/templates/music/landing.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block content %} +

    Welcome to music app

    +

    This is mainly the backend of music, you should consider using side clients like: otomir23's client

    +{% if request.user.is_authenticated %} + {% if last_fm_account %} +

    Last.fm connected to {{ last_fm_account }}, reconnect

    + {% else %} +

    Last.fm is not connected, connect

    + {% endif %} +{% else %} +

    Login to connect last.fm account

    +{% endif %} +{% endblock %} diff --git a/akarpov/users/migrations/0014_alter_user_agree_data_to_be_sold.py b/akarpov/users/migrations/0014_alter_user_agree_data_to_be_sold.py new file mode 100644 index 0000000..7a6ce60 --- /dev/null +++ b/akarpov/users/migrations/0014_alter_user_agree_data_to_be_sold.py @@ -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" + ), + ), + ] diff --git a/akarpov/users/themes/migrations/0002_alter_theme_color.py b/akarpov/users/themes/migrations/0002_alter_theme_color.py new file mode 100644 index 0000000..21a9efa --- /dev/null +++ b/akarpov/users/themes/migrations/0002_alter_theme_color.py @@ -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 + ), + ), + ] diff --git a/config/settings/base.py b/config/settings/base.py index ea0e5b0..959f1c2 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -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 diff --git a/poetry.lock b/poetry.lock index 945898e..9e0a206 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index a65fa6f..4a39b88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]