diff --git a/.dockerignore b/.dockerignore index 2c5dec9..ede6716 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,4 +9,5 @@ .travis.yml venv .git -/web/ \ No newline at end of file +/web/ +/media/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index ea3a9ee..14f968f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ yarn-error.log* *.sln local_settings.py db.sqlite3 +/media/ diff --git a/applications/music/admin.py b/applications/music/admin.py index 8c38f3f..e69de29 100644 --- a/applications/music/admin.py +++ b/applications/music/admin.py @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/applications/music/apps.py b/applications/music/apps.py index d909c7f..acac107 100644 --- a/applications/music/apps.py +++ b/applications/music/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class MusicConfig(AppConfig): - name = 'music' + name = 'applications.music' diff --git a/applications/music/migrations/0001_initial.py b/applications/music/migrations/0001_initial.py index ac4cb34..53a1ec1 100644 --- a/applications/music/migrations/0001_initial.py +++ b/applications/music/migrations/0001_initial.py @@ -1,6 +1,12 @@ -# Generated by Django 2.2.6 on 2023-04-03 10:26 +# Generated by Django 2.2.6 on 2023-04-26 14:51 +import applications.music.utils +import applications.music.validators +import datetime +from django.conf import settings from django.db import migrations, models +import django.db.models.deletion +import django_mysql.models class Migration(migrations.Migration): @@ -8,26 +14,171 @@ class Migration(migrations.Migration): initial = True dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Music', + name='Album', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=255)), - ('artist', models.CharField(default='', max_length=255)), - ('album', models.CharField(default='', max_length=255)), - ('genre', models.CharField(default='', max_length=255)), - ('year', models.CharField(default='', max_length=255)), - ('lyrics', models.TextField(default='')), - ('comment', models.TextField(default='')), - ('fs_id', models.CharField(default='', max_length=255)), + ('name', models.CharField(default='', max_length=255, verbose_name='专辑名称')), + ('all_artist_ids', django_mysql.models.ListTextField(models.IntegerField(), default=list, size=None)), + ('max_year', models.IntegerField(default=0)), + ('song_count', models.IntegerField(default=-1, verbose_name='歌曲统计')), + ('plays_count', models.IntegerField(default=0, verbose_name='播放次数')), + ('duration', models.FloatField(default=0, verbose_name='歌曲时长s')), + ('created_at', models.DateTimeField(null=True)), + ('updated_at', models.DateTimeField(auto_now=True, null=True)), + ('accessed_date', models.DateTimeField(null=True, verbose_name='访问时间')), + ('full_text', models.CharField(blank=True, default='', max_length=255, null=True)), + ('size', models.IntegerField(default=0, verbose_name='文件大小')), + ('comment', models.CharField(max_length=255, null=True)), + ('paths', models.CharField(max_length=255, null=True)), + ('description', models.CharField(default='', max_length=255, null=True)), + ('mbz_album_id', models.CharField(max_length=255, null=True)), + ('mbz_album_artist_id', models.CharField(max_length=255, null=True)), + ('mbz_album_type', models.CharField(max_length=255, null=True)), + ('mbz_album_comment', models.CharField(max_length=255, null=True)), + ('external_url', models.CharField(default='', max_length=255, null=True)), + ('external_info_updated_at', models.DateTimeField(null=True)), + ], + options={ + 'verbose_name': '专辑', + 'verbose_name_plural': '专辑', + }, + ), + migrations.CreateModel( + name='Artist', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='', max_length=255)), + ('album_count', models.IntegerField(default=0)), + ('full_text', models.CharField(blank=True, default='', max_length=255, null=True)), + ('order_artist_name', models.CharField(blank=True, max_length=255, null=True)), + ('sort_artist_name', models.CharField(blank=True, max_length=255, null=True)), + ('song_count', models.IntegerField(blank=True, default=0, null=True)), + ('size', models.IntegerField(blank=True, default=0, null=True)), + ('mbz_artist_id', models.CharField(blank=True, max_length=255, null=True)), + ('small_image_url', models.CharField(blank=True, default='', max_length=255, null=True)), + ('medium_image_url', models.CharField(blank=True, default='', max_length=255, null=True)), + ('large_image_url', models.CharField(blank=True, default='', max_length=255, null=True)), + ('similar_artists', models.CharField(blank=True, default='', max_length=255, null=True)), + ('external_url', models.CharField(blank=True, default='', max_length=255, null=True)), + ('external_info_updated_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'verbose_name': '艺术家', + 'verbose_name_plural': '艺术家', + }, + ), + migrations.CreateModel( + name='Attachment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('url', models.URLField(blank=True, max_length=500, null=True)), + ('creation_date', models.DateTimeField(default=datetime.datetime.now)), + ('last_fetch_date', models.DateTimeField(blank=True, null=True)), + ('size', models.IntegerField(blank=True, null=True)), + ('mimetype', models.CharField(blank=True, max_length=200, null=True)), + ('file', models.ImageField(max_length=255, upload_to=applications.music.utils.get_file_path, validators=[applications.music.validators.ImageDimensionsValidator(min_height=50, min_width=50), applications.music.validators.FileValidator(allowed_extensions=['png', 'jpg', 'jpeg'], max_size=5242880)])), + ], + options={ + 'verbose_name': '附件', + 'verbose_name_plural': '附件', + }, + ), + migrations.CreateModel( + name='Genre', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), + ], + options={ + 'verbose_name': '风格', + 'verbose_name_plural': '风格', + }, + ), + migrations.CreateModel( + name='Track', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='', max_length=255)), ('path', models.CharField(default='', max_length=255)), - ('parent_path', models.CharField(default='', max_length=255)), - ('size', models.CharField(default='', max_length=255)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), + ('has_cover_art', models.BooleanField(default=False)), + ('track_number', models.IntegerField(default=0)), + ('disc_number', models.IntegerField(default=0)), + ('plays_count', models.IntegerField(default=0, null=True, verbose_name='播放量')), + ('year', models.IntegerField(default=0, null=True)), + ('size', models.IntegerField(default=0, verbose_name='文件大小')), + ('suffix', models.CharField(default='', max_length=255, null=True, verbose_name='后缀')), + ('mimetype', models.CharField(default='', max_length=255, null=True)), + ('duration', models.FloatField(default=0, verbose_name='歌曲时长s')), + ('bit_rate', models.IntegerField(default=0, null=True)), + ('created_at', models.DateTimeField(null=True)), + ('updated_at', models.DateTimeField(auto_now=True, null=True)), + ('accessed_date', models.DateTimeField(null=True, verbose_name='访问时间')), + ('full_text', models.CharField(blank=True, default='', max_length=255, null=True)), + ('comment', models.TextField(null=True)), + ('lyrics', models.TextField(null=True)), + ('mbz_track_id', models.CharField(default='', max_length=255)), + ('mbz_album_id', models.CharField(default='', max_length=255)), + ('mbz_artist_id', models.CharField(default='', max_length=255)), + ('mbz_album_artist_id', models.CharField(default='', max_length=255)), + ('mbz_album_type', models.CharField(default='', max_length=255)), + ('mbz_album_comment', models.CharField(default='', max_length=255)), + ('mbz_release_track_id', models.CharField(default='', max_length=255)), + ('album', models.ForeignKey(db_constraint=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tracks', to='music.Album')), + ('artist', models.ForeignKey(db_constraint=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tracks', to='music.Artist')), + ('genre', models.ForeignKey(db_constraint=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tracks', to='music.Genre')), + ], + options={ + 'verbose_name': '歌曲', + 'verbose_name_plural': '歌曲', + }, + ), + migrations.CreateModel( + name='Playlist', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('creation_date', models.DateTimeField(default=datetime.datetime.now)), + ('modification_date', models.DateTimeField(auto_now=True)), + ('privacy_level', models.CharField(choices=[('me', 'Only me'), ('followers', 'Me and my followers'), ('instance', 'Everyone on my instance, and my followers'), ('everyone', 'Everyone, including people on other instances')], default='instance', max_length=30)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='playlists', to=settings.AUTH_USER_MODEL)), ], ), + migrations.AddField( + model_name='artist', + name='attachment_cover', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='artist_cover', to='music.Attachment'), + ), + migrations.AddField( + model_name='album', + name='artist', + field=models.ForeignKey(db_constraint=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='albums', to='music.Artist'), + ), + migrations.AddField( + model_name='album', + name='attachment_cover', + field=models.ForeignKey(db_constraint=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='album_cover', to='music.Attachment'), + ), + migrations.AddField( + model_name='album', + name='genre', + field=models.ForeignKey(db_constraint=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='albums', to='music.Genre'), + ), + migrations.CreateModel( + name='TrackFavorite', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('creation_date', models.DateTimeField(default=datetime.datetime.now)), + ('track', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='track_favorites', to='music.Track')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='track_favorites', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-creation_date',), + 'unique_together': {('track', 'user')}, + }, + ), ] diff --git a/applications/music/migrations/0002_auto_20230403_1042.py b/applications/music/migrations/0002_auto_20230403_1042.py deleted file mode 100644 index e23840d..0000000 --- a/applications/music/migrations/0002_auto_20230403_1042.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2.6 on 2023-04-03 10:42 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('music', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='music', - name='path', - field=models.TextField(default=''), - ), - ] diff --git a/applications/music/migrations/0002_folder.py b/applications/music/migrations/0002_folder.py new file mode 100644 index 0000000..3822020 --- /dev/null +++ b/applications/music/migrations/0002_folder.py @@ -0,0 +1,27 @@ +# Generated by Django 2.2.6 on 2023-04-26 16:50 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('music', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Folder', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=256)), + ('path', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_scan_time', models.DateTimeField(auto_now=True)), + ('file_type', models.CharField(default='folder', max_length=32)), + ('uid', models.UUIDField(default=uuid.uuid4, editable=False)), + ('parent_id', models.UUIDField(default=uuid.uuid4, editable=False)), + ], + ), + ] diff --git a/applications/music/migrations/0004_auto_20230426_1732.py b/applications/music/migrations/0004_auto_20230426_1732.py new file mode 100644 index 0000000..16aa31b --- /dev/null +++ b/applications/music/migrations/0004_auto_20230426_1732.py @@ -0,0 +1,48 @@ +# Generated by Django 2.2.6 on 2023-04-26 17:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('music', '0003_auto_20230426_1659'), + ] + + operations = [ + migrations.AlterField( + model_name='track', + name='mbz_album_artist_id', + field=models.CharField(blank=True, default='', max_length=255, null=True), + ), + migrations.AlterField( + model_name='track', + name='mbz_album_comment', + field=models.CharField(blank=True, default='', max_length=255, null=True), + ), + migrations.AlterField( + model_name='track', + name='mbz_album_id', + field=models.CharField(blank=True, default='', max_length=255, null=True), + ), + migrations.AlterField( + model_name='track', + name='mbz_album_type', + field=models.CharField(blank=True, default='', max_length=255, null=True), + ), + migrations.AlterField( + model_name='track', + name='mbz_artist_id', + field=models.CharField(blank=True, default='', max_length=255, null=True), + ), + migrations.AlterField( + model_name='track', + name='mbz_release_track_id', + field=models.CharField(blank=True, default='', max_length=255, null=True), + ), + migrations.AlterField( + model_name='track', + name='mbz_track_id', + field=models.CharField(blank=True, default='', max_length=255, null=True), + ), + ] diff --git a/applications/music/models.py b/applications/music/models.py index 34e57e9..5e6bd5f 100644 --- a/applications/music/models.py +++ b/applications/music/models.py @@ -1,21 +1,210 @@ +import uuid +from datetime import datetime + +from django.contrib.auth.models import User from django.db import models +from django_mysql.models import ListTextField + +from applications.music import validators +from applications.music.utils import get_file_path -class Music(models.Model): - title = models.CharField(max_length=255) - artist = models.CharField(max_length=255, default="") - album = models.CharField(max_length=255, default="") - genre = models.CharField(max_length=255, default="") - year = models.CharField(max_length=255, default="") - lyrics = models.TextField(default="") - comment = models.TextField(default="") - fs_id = models.CharField(max_length=255, default="") - path = models.TextField(default="") - parent_path = models.CharField(max_length=255, default="") - size = models.CharField(max_length=255, default="") +class Album(models.Model): + name = models.CharField("专辑名称", max_length=255, default='', null=False) + artist = models.ForeignKey('Artist', on_delete=models.SET_NULL, null=True, related_name='albums', + db_constraint=False) + all_artist_ids = ListTextField(base_field=models.IntegerField(), default=list) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) + max_year = models.IntegerField(default=0, null=False) + song_count = models.IntegerField("歌曲统计", default=-1, null=False) + plays_count = models.IntegerField("播放次数", default=0, null=False) + duration = models.FloatField("歌曲时长s", default=0, null=False) + genre = models.ForeignKey('Genre', on_delete=models.SET_NULL, null=True, related_name='albums', db_constraint=False) + created_at = models.DateTimeField(null=True) + updated_at = models.DateTimeField(null=True, auto_now=datetime.now) + accessed_date = models.DateTimeField("访问时间", null=True) + + full_text = models.CharField(max_length=255, default='', null=True, blank=True) + size = models.IntegerField("文件大小", default=0, null=False) + comment = models.CharField(max_length=255, null=True) + paths = models.CharField(max_length=255, null=True) + description = models.CharField(max_length=255, default='', null=True) + attachment_cover = models.ForeignKey('Attachment', on_delete=models.SET_NULL, null=True, related_name='album_cover', + db_constraint=False) + + # musicbrainz fields + mbz_album_id = models.CharField(max_length=255, null=True) + mbz_album_artist_id = models.CharField(max_length=255, null=True) + mbz_album_type = models.CharField(max_length=255, null=True) + mbz_album_comment = models.CharField(max_length=255, null=True) + + external_url = models.CharField(max_length=255, default='', null=True) + external_info_updated_at = models.DateTimeField(null=True) + + class Meta: + verbose_name = "专辑" + verbose_name_plural = "专辑" def __str__(self): - return self.title + return self.name + + +class Track(models.Model): + name = models.CharField(default='', max_length=255) + + path = models.CharField(default='', max_length=255) + album = models.ForeignKey('Album', on_delete=models.SET_NULL, null=True, related_name='tracks', db_constraint=False) + artist = models.ForeignKey('Artist', on_delete=models.SET_NULL, null=True, related_name='tracks', + db_constraint=False) + has_cover_art = models.BooleanField(default=False) + track_number = models.IntegerField(default=0) + disc_number = models.IntegerField(default=0) + plays_count = models.IntegerField("播放量", default=0, null=True) + year = models.IntegerField(default=0, null=True) + size = models.IntegerField("文件大小", default=0, null=False) + suffix = models.CharField("后缀", default='', max_length=255, null=True) + mimetype = models.CharField(default='', max_length=255, null=True) + duration = models.FloatField("歌曲时长s", default=0, null=False) + bit_rate = models.IntegerField(default=0, null=True) + genre = models.ForeignKey('Genre', on_delete=models.SET_NULL, null=True, related_name='tracks', db_constraint=False) + created_at = models.DateTimeField(null=True) + updated_at = models.DateTimeField(null=True, auto_now=datetime.now) + accessed_date = models.DateTimeField("访问时间", null=True) + full_text = models.CharField(default='', max_length=255, null=True, blank=True) + comment = models.TextField(null=True) + lyrics = models.TextField(null=True) + # musicbrainz fields + mbz_track_id = models.CharField(default='', max_length=255, null=True, blank=True) + mbz_album_id = models.CharField(default='', max_length=255, null=True, blank=True) + mbz_artist_id = models.CharField(default='', max_length=255, null=True, blank=True) + mbz_album_artist_id = models.CharField(default='', max_length=255, null=True, blank=True) + mbz_album_type = models.CharField(default='', max_length=255, null=True, blank=True) + mbz_album_comment = models.CharField(default='', max_length=255, null=True, blank=True) + mbz_release_track_id = models.CharField(default='', max_length=255, null=True, blank=True) + + class Meta: + verbose_name = "歌曲" + verbose_name_plural = "歌曲" + + def __str__(self): + return self.name + + +class Artist(models.Model): + name = models.CharField(max_length=255, default='', blank=False) + album_count = models.IntegerField(default=0) + full_text = models.CharField(max_length=255, default='', null=True, blank=True) + order_artist_name = models.CharField(max_length=255, null=True, blank=True) + sort_artist_name = models.CharField(max_length=255, null=True, blank=True) + song_count = models.IntegerField(default=0, null=True, blank=True) + size = models.IntegerField(default=0, null=True, blank=True) + mbz_artist_id = models.CharField(max_length=255, null=True, blank=True) + attachment_cover = models.ForeignKey('Attachment', null=True, blank=True, on_delete=models.SET_NULL, + related_name='artist_cover') + small_image_url = models.CharField(max_length=255, default='', null=True, blank=True) + medium_image_url = models.CharField(max_length=255, default='', null=True, blank=True) + large_image_url = models.CharField(max_length=255, default='', null=True, blank=True) + similar_artists = models.CharField(max_length=255, default='', null=True, blank=True) + external_url = models.CharField(max_length=255, default='', null=True, blank=True) + external_info_updated_at = models.DateTimeField(null=True, blank=True) + + class Meta: + verbose_name = "艺术家" + verbose_name_plural = "艺术家" + + def __str__(self): + return self.name + + +class Genre(models.Model): + name = models.CharField(max_length=255, unique=True) + + class Meta: + verbose_name = "风格" + verbose_name_plural = "风格" + + +class Attachment(models.Model): + # Remote URL where the attachment can be fetched + url = models.URLField(max_length=500, null=True, blank=True) + # Actor associated with the attachment + creation_date = models.DateTimeField(default=datetime.now) + last_fetch_date = models.DateTimeField(null=True, blank=True) + # File size + size = models.IntegerField(null=True, blank=True) + mimetype = models.CharField(null=True, blank=True, max_length=200) + + file = models.ImageField( + upload_to=get_file_path, + max_length=255, + validators=[ + validators.ImageDimensionsValidator(min_width=50, min_height=50), + validators.FileValidator( + allowed_extensions=["png", "jpg", "jpeg"], + max_size=1024 * 1024 * 5, + ), + ], + ) + + def save(self, **kwargs): + if self.file and not self.size: + self.size = self.file.size + + if self.file and not self.mimetype: + self.mimetype = "" + + return super().save() + + def __str__(self): + return self.file.name + + class Meta: + verbose_name = "附件" + verbose_name_plural = "附件" + + +class Playlist(models.Model): + PRIVACY_LEVEL_CHOICES = [ + ("me", "Only me"), + ("followers", "Me and my followers"), + ("instance", "Everyone on my instance, and my followers"), + ("everyone", "Everyone, including people on other instances"), + ] + name = models.CharField(max_length=50) + user = models.ForeignKey(User, related_name="playlists", on_delete=models.CASCADE) + creation_date = models.DateTimeField(default=datetime.now) + modification_date = models.DateTimeField(auto_now=True) + privacy_level = models.CharField(max_length=30, choices=PRIVACY_LEVEL_CHOICES, default="instance") + + def __str__(self): + return self.name + + +class TrackFavorite(models.Model): + creation_date = models.DateTimeField(default=datetime.now) + user = models.ForeignKey( + User, related_name="track_favorites", on_delete=models.CASCADE + ) + track = models.ForeignKey( + Track, related_name="track_favorites", on_delete=models.CASCADE + ) + + class Meta: + unique_together = ("track", "user") + ordering = ("-creation_date",) + + @classmethod + def add(cls, track, user): + favorite, created = cls.objects.get_or_create(user=user, track=track) + return favorite + + +class Folder(models.Model): + name = models.CharField(max_length=256) + path = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + last_scan_time = models.DateTimeField(auto_now=True) + # 文件格式,例如:folder, music, image + file_type = models.CharField(max_length=32, default='folder') + uid = models.UUIDField(default=uuid.uuid4, editable=False) + parent_id = models.UUIDField(default=uuid.uuid4, editable=False, null=True, blank=True) diff --git a/applications/music/utils.py b/applications/music/utils.py index 485fa1f..530e6ae 100644 --- a/applications/music/utils.py +++ b/applications/music/utils.py @@ -1,21 +1,87 @@ -from django.db import transaction +import os +import uuid +import urllib.parse -from applications.music.models import Music +from django.conf import settings +from django.utils.deconstruct import deconstructible -def create_music(filename): - bulk_list = [] - with transaction.atomic(): - with open(f"/Users/macbookair/coding/demo/music/{filename}.txt", "r") as f: - song_list = f.readlines() - for song in song_list: - category, fs_id, isdir, local_ctime, local_mtime, path, server_ctime, server_mtime, server_filename, size = song.split( - "||") - parent_path = path.split("/")[-2] - bulk_list.append(Music(**{ - "title": server_filename, - "fs_id": fs_id, - "path": path, - "parent_path": parent_path, - "size": size - })) - Music.objects.bulk_create(bulk_list) + +def get_file_path(instance, filename): + return ChunkedPath("attachments")(instance, filename) + + +@deconstructible +class ChunkedPath: + def sanitize_filename(self, filename): + return filename.replace("/", "-") + + def __init__(self, root, preserve_file_name=True): + self.root = root + self.preserve_file_name = preserve_file_name + + def __call__(self, instance, filename): + self.sanitize_filename(filename) + uid = str(uuid.uuid4()) + chunk_size = 2 + chunks = [uid[i: i + chunk_size] for i in range(0, len(uid), chunk_size)] + if self.preserve_file_name: + parts = chunks[:3] + [filename] + else: + ext = os.path.splitext(filename)[1][1:].lower() + new_filename = "".join(chunks[3:]) + f".{ext}" + parts = chunks[:3] + [new_filename] + return os.path.join(self.root, *parts) + + +def strip_absolute_media_url(path): + if ( + settings.MEDIA_URL.startswith("http://") + or settings.MEDIA_URL.startswith("https://") + and path.startswith(settings.MEDIA_URL) + ): + path = path.replace(settings.MEDIA_URL, "/media/", 1) + return path + + +def get_file_path_view(audio_file): + # serve_path = settings.MUSIC_DIRECTORY_SERVE_PATH + # prefix = settings.MUSIC_DIRECTORY_PATH + serve_path = "" + prefix = "" + t = settings.REVERSE_PROXY_TYPE + if t == "nginx": + # we have to use the internal locations + try: + path = audio_file.url + path = path.replace("/attachments", "", 1) + except AttributeError: + # a path was given + if not serve_path or not prefix: + raise ValueError( + "You need to specify MUSIC_DIRECTORY_SERVE_PATH and " + "MUSIC_DIRECTORY_PATH to serve in-place imported files" + ) + path = "/music" + audio_file.replace(prefix, "", 1) + path = strip_absolute_media_url(path) + if path.startswith("http://") or path.startswith("https://"): + protocol, remainder = path.split("://", 1) + hostname, r_path = remainder.split("/", 1) + r_path = urllib.parse.quote(r_path) + path = protocol + "://" + hostname + "/" + r_path + return (settings.PROTECT_FILES_PATH + "/media/" + path).encode("utf-8") + # needed to serve files with % or ? chars + path = urllib.parse.quote(path) + return path.encode("utf-8") + if t == "apache2": + try: + path = audio_file.path + except AttributeError: + # a path was given + if not serve_path or not prefix: + raise ValueError( + "You need to specify MUSIC_DIRECTORY_SERVE_PATH and " + "MUSIC_DIRECTORY_PATH to serve in-place imported files" + ) + path = audio_file.replace(prefix, serve_path, 1) + path = strip_absolute_media_url(path) + return path.encode("utf-8") diff --git a/applications/music/validators.py b/applications/music/validators.py new file mode 100644 index 0000000..ee69391 --- /dev/null +++ b/applications/music/validators.py @@ -0,0 +1,167 @@ +import mimetypes +from os.path import splitext + +from django.core import validators +from django.core.exceptions import ValidationError +from django.core.files.images import get_image_dimensions +from django.template.defaultfilters import filesizeformat +from django.utils.deconstruct import deconstructible +from django.utils.translation import ugettext_lazy as _ + + +@deconstructible +class ImageDimensionsValidator: + """ + ImageField dimensions validator. + + from https://gist.github.com/emilio-rst/4f81ea2718736a6aaf9bdb64d5f2ea6c + """ + + def __init__( + self, + width=None, + height=None, + min_width=None, + max_width=None, + min_height=None, + max_height=None, + ): + """ + Constructor + + Args: + width (int): exact width + height (int): exact height + min_width (int): minimum width + min_height (int): minimum height + max_width (int): maximum width + max_height (int): maximum height + """ + + self.width = width + self.height = height + self.min_width = min_width + self.max_width = max_width + self.min_height = min_height + self.max_height = max_height + + def __call__(self, image): + w, h = get_image_dimensions(image) + + if self.width is not None and w != self.width: + raise ValidationError(_("Width must be %dpx.") % (self.width,)) + + if self.height is not None and h != self.height: + raise ValidationError(_("Height must be %dpx.") % (self.height,)) + + if self.min_width is not None and w < self.min_width: + raise ValidationError(_("Minimum width must be %dpx.") % (self.min_width,)) + + if self.min_height is not None and h < self.min_height: + raise ValidationError( + _("Minimum height must be %dpx.") % (self.min_height,) + ) + + if self.max_width is not None and w > self.max_width: + raise ValidationError(_("Maximum width must be %dpx.") % (self.max_width,)) + + if self.max_height is not None and h > self.max_height: + raise ValidationError( + _("Maximum height must be %dpx.") % (self.max_height,) + ) + + +@deconstructible +class FileValidator: + """ + Taken from https://gist.github.com/jrosebr1/2140738 + Validator for files, checking the size, extension and mimetype. + Initialization parameters: + allowed_extensions: iterable with allowed file extensions + ie. ('txt', 'doc') + allowd_mimetypes: iterable with allowed mimetypes + ie. ('image/png', ) + min_size: minimum number of bytes allowed + ie. 100 + max_size: maximum number of bytes allowed + ie. 24*1024*1024 for 24 MB + Usage example:: + MyModel(models.Model): + myfile = FileField(validators=FileValidator(max_size=24*1024*1024), ...) + """ + + extension_message = _( + "Extension '%(extension)s' not allowed. Allowed extensions are: '%(allowed_extensions)s.'" + ) + mime_message = _( + "MIME type '%(mimetype)s' is not valid. Allowed types are: %(allowed_mimetypes)s." + ) + min_size_message = _( + "The current file %(size)s, which is too small. The minimum file size is %(allowed_size)s." + ) + max_size_message = _( + "The current file %(size)s, which is too large. The maximum file size is %(allowed_size)s." + ) + + def __init__(self, *args, **kwargs): + self.allowed_extensions = kwargs.pop("allowed_extensions", None) + self.allowed_mimetypes = kwargs.pop("allowed_mimetypes", None) + self.min_size = kwargs.pop("min_size", 0) + self.max_size = kwargs.pop("max_size", None) + + def __call__(self, value): + """ + Check the extension, content type and file size. + """ + + # Check the extension + ext = splitext(value.name)[1][1:].lower() + if self.allowed_extensions and ext not in self.allowed_extensions: + message = self.extension_message % { + "extension": ext, + "allowed_extensions": ", ".join(self.allowed_extensions), + } + + raise ValidationError(message) + + # Check the content type + mimetype = mimetypes.guess_type(value.name)[0] + if self.allowed_mimetypes and mimetype not in self.allowed_mimetypes: + message = self.mime_message % { + "mimetype": mimetype, + "allowed_mimetypes": ", ".join(self.allowed_mimetypes), + } + + raise ValidationError(message) + + # Check the file size + filesize = len(value) + if self.max_size and filesize > self.max_size: + message = self.max_size_message % { + "size": filesizeformat(filesize), + "allowed_size": filesizeformat(self.max_size), + } + + raise ValidationError(message) + + elif filesize < self.min_size: + message = self.min_size_message % { + "size": filesizeformat(filesize), + "allowed_size": filesizeformat(self.min_size), + } + + raise ValidationError(message) + + +class DomainValidator(validators.URLValidator): + message = "Enter a valid domain name." + + def __call__(self, value): + """ + This is a bit hackish but since we don't have any built-in domain validator, + we use the url one, and prepend http:// in front of it. + + If it fails, we know the domain is not valid. + """ + super().__call__(f"http://{value}") + return value diff --git a/applications/navidrome/admin.py b/applications/navidrome/admin.py deleted file mode 100644 index a9f3931..0000000 --- a/applications/navidrome/admin.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.contrib import admin - -from applications.navidrome.models import Album, MediaFile, Artist, Genre -from component.drf.adminx_expand import MultiDBModelAdmin - - -class AlbumAdmin(MultiDBModelAdmin): - list_display = ["id", "name", "artist", "paths"] - - -class MediaFileAdmin(MultiDBModelAdmin): - list_display = ["id", "album", "title", "artist", "path"] - - -class ArtistAdmin(MultiDBModelAdmin): - list_display = ["id", "name", "song_count"] - - -class GenreAdmin(MultiDBModelAdmin): - list_display = ["id", "name"] - - -admin.site.register(Album, AlbumAdmin) -admin.site.register(MediaFile, MediaFileAdmin) -admin.site.register(Artist, ArtistAdmin) -admin.site.register(Genre, GenreAdmin) diff --git a/applications/navidrome/apps.py b/applications/navidrome/apps.py deleted file mode 100644 index dfd0917..0000000 --- a/applications/navidrome/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class NavidromeConfig(AppConfig): - name = 'navidrome' diff --git a/applications/navidrome/models.py b/applications/navidrome/models.py deleted file mode 100644 index 217a70a..0000000 --- a/applications/navidrome/models.py +++ /dev/null @@ -1,139 +0,0 @@ -from django.db import models - - -class Album(models.Model): - id = models.CharField(max_length=255, primary_key=True) - name = models.CharField(max_length=255, default='', null=False) - artist_id = models.CharField(max_length=255, default='', null=False) - embed_art_path = models.CharField(max_length=255, default='', null=False) - artist = models.CharField(max_length=255, default='', null=False) - album_artist = models.CharField(max_length=255, default='', null=False) - min_year = models.IntegerField(default=0, null=False) - max_year = models.IntegerField(default=0, null=False) - compilation = models.BooleanField(default=False, null=False) - song_count = models.IntegerField(default=0, null=False) - duration = models.FloatField(default=0, null=False) - genre = models.CharField(max_length=255, default='', null=False) - created_at = models.DateTimeField(null=True) - updated_at = models.DateTimeField(null=True) - full_text = models.CharField(max_length=255, default='', null=False) - album_artist_id = models.CharField(max_length=255, default='', null=False) - order_album_name = models.CharField(max_length=255) - order_album_artist_name = models.CharField(max_length=255) - sort_album_name = models.CharField(max_length=255) - sort_artist_name = models.CharField(max_length=255) - sort_album_artist_name = models.CharField(max_length=255) - size = models.IntegerField(default=0, null=False) - mbz_album_id = models.CharField(max_length=255, null=True) - mbz_album_artist_id = models.CharField(max_length=255, null=True) - mbz_album_type = models.CharField(max_length=255, null=True) - mbz_album_comment = models.CharField(max_length=255, null=True) - catalog_num = models.CharField(max_length=255, null=True) - comment = models.CharField(max_length=255, null=True) - all_artist_ids = models.CharField(max_length=255, null=True) - image_files = models.CharField(max_length=255, null=True) - paths = models.CharField(max_length=255, null=True) - description = models.CharField(max_length=255, default='', null=False) - small_image_url = models.CharField(max_length=255, default='', null=False) - medium_image_url = models.CharField(max_length=255, default='', null=False) - large_image_url = models.CharField(max_length=255, default='', null=False) - external_url = models.CharField(max_length=255, default='', null=False) - external_info_updated_at = models.DateTimeField(null=True) - - class Meta: - db_table = "album" - managed = False - verbose_name = "专辑" - verbose_name_plural = "专辑" - - -class MediaFile(models.Model): - id = models.CharField(primary_key=True, max_length=255) - path = models.CharField(default='', max_length=255) - title = models.CharField(default='', max_length=255) - album = models.CharField(default='', max_length=255) - artist = models.CharField(default='', max_length=255) - artist_id = models.CharField(default='', max_length=255) - album_artist = models.CharField(default='', max_length=255) - album_id = models.CharField(default='', max_length=255) - has_cover_art = models.BooleanField(default=False) - track_number = models.IntegerField(default=0) - disc_number = models.IntegerField(default=0) - year = models.IntegerField(default=0) - size = models.IntegerField(default=0) - suffix = models.CharField(default='', max_length=255) - duration = models.FloatField(default=0) - bit_rate = models.IntegerField(default=0) - genre = models.CharField(default='', max_length=255) - compilation = models.BooleanField(default=False) - created_at = models.DateTimeField(null=True) - updated_at = models.DateTimeField(null=True) - full_text = models.CharField(default='', max_length=255) - album_artist_id = models.CharField(default='', max_length=255) - order_album_name = models.CharField(default='', max_length=255) - order_album_artist_name = models.CharField(default='', max_length=255) - order_artist_name = models.CharField(default='', max_length=255) - sort_album_name = models.CharField(default='', max_length=255) - sort_artist_name = models.CharField(default='', max_length=255) - sort_album_artist_name = models.CharField(default='', max_length=255) - sort_title = models.CharField(default='', max_length=255) - disc_subtitle = models.CharField(default='', max_length=255) - mbz_track_id = models.CharField(default='', max_length=255) - mbz_album_id = models.CharField(default='', max_length=255) - mbz_artist_id = models.CharField(default='', max_length=255) - mbz_album_artist_id = models.CharField(default='', max_length=255) - mbz_album_type = models.CharField(default='', max_length=255) - mbz_album_comment = models.CharField(default='', max_length=255) - catalog_num = models.CharField(default='', max_length=255) - comment = models.TextField(null=True) - lyrics = models.TextField(null=True) - bpm = models.IntegerField(null=True) - channels = models.IntegerField(null=True) - order_title = models.CharField(default='', max_length=255, null=True) - mbz_release_track_id = models.CharField(default='', max_length=255) - rg_album_gain = models.FloatField(null=True) - rg_album_peak = models.FloatField(null=True) - rg_track_gain = models.FloatField(null=True) - rg_track_peak = models.FloatField(null=True) - - class Meta: - db_table = "media_file" - managed = False - verbose_name = "歌曲" - verbose_name_plural = "歌曲" - - -class Artist(models.Model): - id = models.CharField(max_length=255, primary_key=True) - name = models.CharField(max_length=255, default='', blank=False) - album_count = models.IntegerField(default=0) - full_text = models.CharField(max_length=255, default='') - order_artist_name = models.CharField(max_length=255) - sort_artist_name = models.CharField(max_length=255) - song_count = models.IntegerField(default=0) - size = models.IntegerField(default=0) - mbz_artist_id = models.CharField(max_length=255) - biography = models.CharField(max_length=255, default='', blank=False) - small_image_url = models.CharField(max_length=255, default='', blank=False) - medium_image_url = models.CharField(max_length=255, default='', blank=False) - large_image_url = models.CharField(max_length=255, default='', blank=False) - similar_artists = models.CharField(max_length=255, default='', blank=False) - external_url = models.CharField(max_length=255, default='', blank=False) - external_info_updated_at = models.DateTimeField(null=True) - - class Meta: - db_table = "artist" - managed = False - verbose_name = "艺术家" - verbose_name_plural = "艺术家" - - -class Genre(models.Model): - id = models.CharField(primary_key=True, max_length=255) - name = models.CharField(max_length=255, unique=True) - - class Meta: - db_table = "genre" - managed = False - verbose_name = "风格" - verbose_name_plural = "风格" diff --git a/applications/navidrome/views.py b/applications/navidrome/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/applications/navidrome/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/applications/navidrome/__init__.py b/applications/subsonic/__init__.py similarity index 100% rename from applications/navidrome/__init__.py rename to applications/subsonic/__init__.py diff --git a/applications/subsonic/admin.py b/applications/subsonic/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/applications/subsonic/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/applications/subsonic/apps.py b/applications/subsonic/apps.py new file mode 100644 index 0000000..b12d59f --- /dev/null +++ b/applications/subsonic/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class SubsonicConfig(AppConfig): + name = 'subsonic' diff --git a/applications/subsonic/authentication.py b/applications/subsonic/authentication.py new file mode 100644 index 0000000..d059fdc --- /dev/null +++ b/applications/subsonic/authentication.py @@ -0,0 +1,63 @@ +import binascii +import hashlib + +from django.contrib.auth.models import User +from rest_framework import authentication, exceptions +from django.contrib.auth import authenticate as django_authenticate + +from applications.user.models import UserProfile + + +def get_token(salt, password): + to_hash = password + salt + h = hashlib.md5() + h.update(to_hash.encode("utf-8")) + return h.hexdigest() + + +def authenticate(username, password): + try: + if password.startswith("enc:"): + password = password.replace("enc:", "", 1) + password = binascii.unhexlify(password).decode("utf-8") + + user = django_authenticate(username=username, password=password) + + except (User.DoesNotExist, binascii.Error): + raise exceptions.AuthenticationFailed("Wrong username or password.") + + return user, None + + +def authenticate_salt(username, salt, token): + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.AuthenticationFailed("Wrong username or password.") + user_profile, _ = UserProfile.objects.get_or_create(user=user) + try: + expected = get_token(salt, user_profile.subsonic_api_token) + except Exception: + raise exceptions.AuthenticationFailed("请设置你的subsonic api token") + if expected != token: + raise exceptions.AuthenticationFailed("Wrong username or password.") + return user, None + + +class SubsonicAuthentication(authentication.BaseAuthentication): + def authenticate(self, request): + data = request.GET or request.POST + username = data.get("u") + if not username: + return None + + p = data.get("p") + s = data.get("s") + t = data.get("t") + if not p and (not s or not t): + raise exceptions.AuthenticationFailed("Missing credentials") + + if p: + return authenticate(username, p) + + return authenticate_salt(username, s, t) diff --git a/applications/subsonic/constants.py b/applications/subsonic/constants.py new file mode 100644 index 0000000..c56280d --- /dev/null +++ b/applications/subsonic/constants.py @@ -0,0 +1,20 @@ +AUDIO_EXTENSIONS_AND_MIMETYPE = [ + # keep the most correct mimetype for each extension at the bottom + ("mp3", "audio/mp3"), + ("mp3", "audio/mpeg3"), + ("mp3", "audio/x-mp3"), + ("mp3", "audio/mpeg"), + ("ogg", "video/ogg"), + ("ogg", "audio/ogg"), + ("opus", "audio/opus"), + ("aac", "audio/x-m4a"), + ("m4a", "audio/x-m4a"), + ("flac", "audio/x-flac"), + ("flac", "audio/flac"), + ("aif", "audio/aiff"), + ("aif", "audio/x-aiff"), + ("aiff", "audio/aiff"), + ("aiff", "audio/x-aiff"), +] +COVER_TYPE = {"jpg", "jpeg", "png"} +EXTENSION_TO_MIMETYPE = {ext: mt for ext, mt in AUDIO_EXTENSIONS_AND_MIMETYPE} diff --git a/applications/subsonic/filters.py b/applications/subsonic/filters.py new file mode 100644 index 0000000..9a123f7 --- /dev/null +++ b/applications/subsonic/filters.py @@ -0,0 +1,23 @@ +from django_filters import rest_framework as filters + +from applications.music.models import Album + + +class AlbumList2FilterSet(filters.FilterSet): + type = filters.CharFilter(field_name="_", method="filter_type") + + class Meta: + model = Album + fields = [] + + def filter_type(self, queryset, name, value): + ORDERING = { + "random": "?", + "newest": "-creation_date", + "alphabeticalByArtist": "artist__name", + "alphabeticalByName": "title", + } + if value not in ORDERING: + return queryset + + return queryset.order_by(ORDERING[value]) diff --git a/applications/navidrome/migrations/__init__.py b/applications/subsonic/migrations/__init__.py similarity index 100% rename from applications/navidrome/migrations/__init__.py rename to applications/subsonic/migrations/__init__.py diff --git a/applications/subsonic/models.py b/applications/subsonic/models.py new file mode 100644 index 0000000..e69de29 diff --git a/applications/subsonic/negotiation.py b/applications/subsonic/negotiation.py new file mode 100644 index 0000000..96b4158 --- /dev/null +++ b/applications/subsonic/negotiation.py @@ -0,0 +1,18 @@ +from rest_framework import exceptions, negotiation + +from . import renderers + +MAPPING = { + "json": (renderers.SubsonicJSONRenderer(), "application/json"), + "xml": (renderers.SubsonicXMLRenderer(), "text/xml"), +} + + +class SubsonicContentNegociation(negotiation.DefaultContentNegotiation): + def select_renderer(self, request, renderers, format_suffix=None): + data = request.GET or request.POST + requested_format = data.get("f", "xml") + try: + return MAPPING[requested_format] + except KeyError: + raise exceptions.NotAcceptable(available_renderers=renderers) diff --git a/applications/subsonic/renderers.py b/applications/subsonic/renderers.py new file mode 100644 index 0000000..29f38e1 --- /dev/null +++ b/applications/subsonic/renderers.py @@ -0,0 +1,90 @@ +import collections +import xml.etree.ElementTree as ET + +from rest_framework import renderers + + + +# from https://stackoverflow.com/a/8915039 +# because I want to avoid a lxml dependency just for outputting cdata properly +# in a RSS feed +def CDATA(text=None): + element = ET.Element("![CDATA[") + element.text = text + return element + + +ET._original_serialize_xml = ET._serialize_xml + + +def _serialize_xml(write, elem, qnames, namespaces, **kwargs): + if elem.tag == "![CDATA[": + write(f"<{elem.tag}{elem.text}]]>") + return + return ET._original_serialize_xml(write, elem, qnames, namespaces, **kwargs) + + +ET._serialize_xml = ET._serialize["xml"] = _serialize_xml +# end of tweaks + + +def structure_payload(data): + payload = { + "funkwhaleVersion": "1", + "status": "ok", + "type": "funkwhale", + "version": "1.16.0", + } + payload.update(data) + if "detail" in payload: + payload["error"] = {"code": 0, "message": payload.pop("detail")} + if "error" in payload: + payload["status"] = "failed" + return collections.OrderedDict(sorted(payload.items(), key=lambda v: v[0])) + + +class SubsonicJSONRenderer(renderers.JSONRenderer): + def render(self, data, accepted_media_type=None, renderer_context=None): + if not data: + # when stream view is called, we don't have any data + return super().render(data, accepted_media_type, renderer_context) + final = {"subsonic-response": structure_payload(data)} + return super().render(final, accepted_media_type, renderer_context) + + +class SubsonicXMLRenderer(renderers.JSONRenderer): + media_type = "text/xml" + + def render(self, data, accepted_media_type=None, renderer_context=None): + if not data: + # when stream view is called, we don't have any data + return super().render(data, accepted_media_type, renderer_context) + final = structure_payload(data) + final["xmlns"] = "http://subsonic.org/restapi" + tree = dict_to_xml_tree("subsonic-response", final) + return b'\n' + ET.tostring( + tree, encoding="utf-8" + ) + + +def dict_to_xml_tree(root_tag, d, parent=None): + root = ET.Element(root_tag) + for key, value in d.items(): + if isinstance(value, dict): + root.append(dict_to_xml_tree(key, value, parent=root)) + elif isinstance(value, list): + for obj in value: + if isinstance(obj, dict): + el = dict_to_xml_tree(key, obj, parent=root) + else: + el = ET.Element(key) + el.text = str(obj) + root.append(el) + else: + if key == "value": + root.text = str(value) + elif key == "cdata_value": + root.append(CDATA(value)) + else: + root.set(key, str(value)) + return root diff --git a/applications/subsonic/serializers.py b/applications/subsonic/serializers.py new file mode 100644 index 0000000..08c2a75 --- /dev/null +++ b/applications/subsonic/serializers.py @@ -0,0 +1,318 @@ +import collections + +from django.db.models import Count, functions, Sum +from rest_framework import serializers + +from applications.music.models import Track +from applications.subsonic.utils import get_type_from_ext + + +def to_subsonic_date(date): + """ + Subsonic expects this kind of date format: 2012-04-17T19:55:49.000Z + """ + + if not date: + return + + return date.strftime("%Y-%m-%dT%H:%M:%S.000Z") + + +def get_valid_filepart(s): + """ + Return a string suitable for use in a file path. Escape most non-ASCII + chars, and truncate the string to a suitable length too. + """ + max_length = 50 + keepcharacters = " ._()[]-+" + final = "".join( + c if c.isalnum() or c in keepcharacters else "_" for c in s + ).rstrip() + return final[:max_length] + + +def get_track_path(track, suffix): + parts = [] + parts.append(get_valid_filepart(track.artist.name)) + if track.album: + parts.append(get_valid_filepart(track.album.name)) + track_part = get_valid_filepart(track.name) + "." + suffix + if track.track_number: + track_part = f"{track.track_number} - {track_part}" + parts.append(track_part) + return "/".join(parts) + + +def get_artist_data(artist_values): + return { + "id": artist_values["id"], + "name": artist_values["name"], + "albumCount": artist_values["album_count"], + "coverArt": "ar-{}".format(artist_values["id"]), + } + + +class GetArtistsSerializer(serializers.Serializer): + def to_representation(self, queryset): + payload = {"ignoredArticles": "", "index": []} + queryset = queryset.order_by(functions.Lower("name")) + values = queryset.values("id", "album_count", "name") + + first_letter_mapping = collections.defaultdict(list) + for artist in values: + if artist["name"]: + first_letter_mapping[artist["name"][0].upper()].append(artist) + + for letter, artists in sorted(first_letter_mapping.items()): + letter_data = { + "name": letter, + "artist": [get_artist_data(v) for v in artists], + } + payload["index"].append(letter_data) + return payload + + +class GetArtistSerializer(serializers.Serializer): + def to_representation(self, artist): + albums = artist.albums.all() + payload = { + "id": artist.pk, + "name": artist.name, + "albumCount": albums.count(), + "album": [], + } + if artist.attachment_cover_id: + payload["coverArt"] = f"ar-{artist.id}" + for album in albums: + album_data = { + "id": album.id, + "artistId": artist.id, + "name": album.name, + "artist": artist.name, + "created": to_subsonic_date(album.created_at), + "songCount": album.tracks.count(), + "duration": album.tracks.aggregate(duration_count=Sum("duration")).get("duration_count", 0) + } + if album.attachment_cover_id: + album_data["coverArt"] = f"al-{album.id}" + if album.max_year: + album_data["year"] = album.max_year + payload["album"].append(album_data) + return payload + + +def get_track_data(track): + """ + subsonic expects this kind of data: + """ + album = track.album + artist = track.artist + data = { + "id": track.pk, + "isDir": "false", + "title": track.name, + "album": album.name if album else "", + "artist": artist.name, + "track": track.track_number or 1, + "discNumber": track.disc_number or 1, + "contentType": track.mimetype or get_type_from_ext(track.path), + "suffix": track.suffix or "", + "path": get_track_path(track, track.suffix or "mp3"), + "duration": track.duration or 0, + "created": to_subsonic_date(track.created_at), + "albumId": album.pk if album else "", + "artistId": album.artist.pk if album else track.artist.pk, + "type": "music", + } + if album and album.attachment_cover_id: + data["coverArt"] = f"al-{album.id}" + if track.bit_rate: + data["bitrate"] = int(track.bit_rate / 1000) + if track.size: + data["size"] = track.size + if album and album.max_year: + data["year"] = album.max_year + else: + data["year"] = track.created_at.year + return data + + +def get_album2_data(album): + """ + subsonic expects this kind of data: + """ + # todo 优化 外建关联prefetch + payload = { + "id": album.id, + "artistId": album.artist_id, + "name": album.name, + "artist": album.artist.name, + "created": to_subsonic_date(album.created_at), + "duration": album.tracks.aggregate(duration_count=Sum("duration")).get("duration_count", 0), + "playCount": 1, + } + if album.attachment_cover_id: + payload["coverArt"] = f"al-{album.id}" + if album.genre: + payload["genre"] = album.genre.name + if album.max_year: + payload["year"] = album.max_year + payload["songCount"] = album.tracks.count() + return payload + + +def get_song_list_data(tracks): + songs = [] + for track in tracks: + track_data = get_track_data(track) + songs.append(track_data) + return songs + + +class GetAlbumSerializer(serializers.Serializer): + def to_representation(self, album): + payload = get_album2_data(album) + + tracks = album.tracks.all() + payload["song"] = get_song_list_data(tracks) + return payload + + +class GetSongSerializer(serializers.Serializer): + def to_representation(self, track): + return get_track_data(track) + + +def get_starred_tracks_data(favorites): + by_track_id = {f.track_id: f for f in favorites} + tracks = ( + Track.objects.filter(pk__in=by_track_id.keys()) + .select_related("album__artist") + ) + tracks = tracks.order_by("-created_at") + data = [] + for t in tracks: + td = get_track_data(t) + td["starred"] = to_subsonic_date(by_track_id[t.pk].created_at) + data.append(td) + return data + + +def get_album_list2_data(albums): + return [get_album2_data(a) for a in albums] + + +def get_playlist_data(playlist): + return { + "id": playlist.pk, + "name": playlist.name, + "owner": playlist.user.username, + "public": "false", + "songCount": 0, + "duration": 0, + "created": to_subsonic_date(playlist.creation_date), + } + + +def get_playlist_detail_data(playlist): + data = get_playlist_data(playlist) + qs = ( + playlist.playlist_tracks.select_related("track__album__artist") + .prefetch_related("track__uploads") + .order_by("index") + ) + data["entry"] = [] + for plt in qs: + try: + uploads = [upload for upload in plt.track.uploads.all()][0] + except IndexError: + continue + td = get_track_data(plt.track.album, plt.track, uploads) + data["entry"].append(td) + return data + + +def get_folders(user): + return [ + # Dummy folder ID to match what is returned in the getMusicFolders endpoint + # cf https://dev.funkwhale.audio/funkwhale/funkwhale/issues/624 + {"id": 1, "name": "Music"} + ] + + +def get_user_detail_data(user): + return { + "username": user.username, + "email": user.email, + "scrobblingEnabled": "true", + "adminRole": "false", + "settingsRole": "false", + "commentRole": "false", + "podcastRole": "true", + "coverArtRole": "false", + "shareRole": "false", + "uploadRole": "true", + "downloadRole": "true", + "playlistRole": "true", + "streamRole": "true", + "jukeboxRole": "true", + "folder": [f["id"] for f in get_folders(user)], + } + + +def get_genre_data(tag): + return { + "songCount": getattr(tag, "_tracks_count", 0), + "albumCount": getattr(tag, "_albums_count", 0), + "value": tag.name, + } + + +def get_channel_data(channel, uploads): + data = { + "id": str(channel.uuid), + "url": channel.get_rss_url(), + "title": channel.artist.name, + "description": channel.artist.description.as_plain_text + if channel.artist.description + else "", + "coverArt": f"at-{channel.artist.attachment_cover.uuid}" + if channel.artist.attachment_cover + else "", + "originalImageUrl": channel.artist.attachment_cover.url + if channel.artist.attachment_cover + else "", + "status": "completed", + } + if uploads: + data["episode"] = [ + get_channel_episode_data(upload, channel.uuid) for upload in uploads + ] + + return data + + +def get_channel_episode_data(upload, channel_id): + return { + "id": str(upload.uuid), + "channelId": str(channel_id), + "streamId": upload.track.id, + "title": upload.track.name, + "description": upload.track.description.as_plain_text + if upload.track.description + else "", + "coverArt": f"at-{upload.track.attachment_cover.uuid}" + if upload.track.attachment_cover + else "", + "isDir": "false", + "year": upload.track.creation_date.year, + "publishDate": upload.track.creation_date.isoformat(), + "created": upload.track.creation_date.isoformat(), + "genre": "Podcast", + "size": upload.size if upload.size else "", + "duration": upload.duration if upload.duration else "", + "bitrate": upload.bitrate / 1000 if upload.bitrate else "", + "contentType": upload.mimetype or "audio/mpeg", + "suffix": upload.extension or "mp3", + "status": "completed", + } diff --git a/applications/navidrome/tests.py b/applications/subsonic/tests.py similarity index 100% rename from applications/navidrome/tests.py rename to applications/subsonic/tests.py diff --git a/applications/subsonic/urls.py b/applications/subsonic/urls.py new file mode 100644 index 0000000..bf751fe --- /dev/null +++ b/applications/subsonic/urls.py @@ -0,0 +1,7 @@ +from rest_framework import routers + +from . import views + +router = routers.DefaultRouter() +router.register(r"", views.SubsonicViewSet, base_name='subsonic') + diff --git a/applications/subsonic/utils.py b/applications/subsonic/utils.py new file mode 100644 index 0000000..d6ee1c4 --- /dev/null +++ b/applications/subsonic/utils.py @@ -0,0 +1,42 @@ +import urllib.parse +from datetime import datetime + +from django.conf import settings +from rest_framework.response import Response + +from applications.subsonic.constants import EXTENSION_TO_MIMETYPE + + +def get_type_from_ext(path): + extension = path.split(".")[-1] + return EXTENSION_TO_MIMETYPE.get(extension) + + +def get_content_disposition(filename): + filename = f"filename*=UTF-8''{urllib.parse.quote(filename)}" + return f"attachment; {filename}" + + +def handle_serve( + track, user, _format=None, max_bitrate=None, proxy_media=True, download=False): + # we update the accessed_date + now = datetime.now() + track.accessed_date = now + track.save(update_fields=["accessed_date"]) + file_path = track.path + mt = track.mimetype + + if mt: + response = Response(content_type=mt) + else: + response = Response() + mapping = {"nginx": "X-Accel-Redirect", "apache2": "X-Sendfile"} + file_header = mapping[settings.REVERSE_PROXY_TYPE] + response[file_header] = file_path + if download: + filename = track.name + response["Content-Disposition"] = get_content_disposition(filename) + if mt: + response["Content-Type"] = mt + + return response diff --git a/applications/subsonic/views.py b/applications/subsonic/views.py new file mode 100644 index 0000000..e638b42 --- /dev/null +++ b/applications/subsonic/views.py @@ -0,0 +1,472 @@ +""" +Documentation of Subsonic API can be found at http://www.subsonic.org/pages/api.jsp +""" +import datetime + +from django.conf import settings +from django.utils import timezone +from rest_framework import exceptions +from rest_framework import permissions as rest_permissions +from rest_framework import response, viewsets +from rest_framework.decorators import action + +from applications.music.models import Artist, Album, Attachment, Track, Playlist, TrackFavorite +from . import authentication, negotiation, serializers +from .filters import AlbumList2FilterSet +from .utils import handle_serve + + +class SubsonicViewSet(viewsets.GenericViewSet): + content_negotiation_class = negotiation.SubsonicContentNegociation + authentication_classes = [authentication.SubsonicAuthentication] + permission_classes = [rest_permissions.IsAuthenticated] + throttling_scopes = {"*": {"authenticated": "subsonic", "anonymous": "subsonic"}} + + def dispatch(self, request, *args, **kwargs): + return super().dispatch(request, *args, **kwargs) + + def handle_exception(self, exc): + # subsonic API sends 200 status code with custom error + # codes in the payload + mapping = { + exceptions.AuthenticationFailed: (40, "Wrong username or password."), + exceptions.NotAuthenticated: (10, "Required parameter is missing."), + } + payload = {"status": "failed"} + if exc.__class__ in mapping: + code, message = mapping[exc.__class__] + else: + return super().handle_exception(exc) + payload["error"] = {"code": code, "message": message} + + return response.Response(payload, status=200) + + @action(detail=False, methods=["get", "post"], permission_classes=[]) + def ping(self, request, *args, **kwargs): + data = {"status": "ok", "version": "1.16.0"} + return response.Response(data, status=200) + + @action( + detail=False, + methods=["get", "post"], + url_name="get_license", + permission_classes=[], + url_path="getLicense", + ) + def get_license(self, request, *args, **kwargs): + now = timezone.now() + data = { + "status": "ok", + "version": "1.16.0", + "type": "music-tag-web", + "musicTagVersion": "1.0.1", + "license": { + "valid": "true", + "email": "valid@valid.license", + "licenseExpires": now + datetime.timedelta(days=365), + }, + } + return response.Response(data, status=200) + + @action( + detail=False, + methods=["get", "post"], + url_name="get_artists", + url_path="getArtists", + ) + def get_artists(self, request, *args, **kwargs): + + artists = Artist.objects.all() + data = serializers.GetArtistsSerializer(artists).data + payload = {"artists": data} + + return response.Response(payload, status=200) + + @action( + detail=False, + methods=["get", "post"], + url_name="get_cover_art", + url_path="getCoverArt", + ) + def get_cover_art(self, request, *args, **kwargs): + """ 返回封面图片""" + data = request.GET or request.POST + the_id = data.get("id", "") + if not the_id: + return response.Response( + {"error": {"code": 10, "message": "cover art ID must be specified."}} + ) + + if the_id.startswith("al-"): + try: + album_id = int(the_id.replace("al-", "")) + album = Album.objects.get(pk=album_id) + except (TypeError, ValueError, Album.DoesNotExist): + return response.Response( + {"error": {"code": 70, "message": "cover art not found."}} + ) + attachment = album.attachment_cover + elif the_id.startswith("ar-"): + try: + artist_id = int(the_id.replace("ar-", "")) + artist = Artist.objects.get(pk=artist_id) + except (TypeError, ValueError, Album.DoesNotExist): + return response.Response( + {"error": {"code": 70, "message": "cover art not found."}} + ) + attachment = artist.attachment_cover + elif the_id.startswith("at-"): + try: + attachment_id = the_id.replace("at-", "") + attachment = Attachment.objects.get(id=attachment_id) + except (TypeError, ValueError, Album.DoesNotExist): + return response.Response( + {"error": {"code": 70, "message": "cover art not found."}} + ) + else: + return response.Response( + {"error": {"code": 70, "message": "cover art not found."}} + ) + if not attachment: + return response.Response( + {"error": {"code": 70, "message": "cover art not found."}} + ) + # if not attachment.file: + # common_tasks.fetch_remote_attachment(attachment) + cover = attachment.file + mapping = {"nginx": "X-Accel-Redirect", "apache2": "X-Sendfile"} + # path = get_file_path_view(cover) + file_header = mapping["nginx"] + # let the proxy set the content-type + r = response.Response({}, content_type='') + r[file_header] = cover.url + + return r + + @action( + detail=False, + methods=["get", "post"], + url_name="get_artist", + url_path="getArtist", + ) + def get_artist(self, request, *args, **kwargs): + data = request.GET or request.POST + + artist = Artist.objects.filter(pk=data["id"]).first() + if not artist: + return response.Response( + {"error": {"code": 70, "message": "Artist not found."}} + ) + data = serializers.GetArtistSerializer(artist).data + payload = {"artist": data} + + return response.Response(payload, status=200) + + @action( + detail=False, methods=["get", "post"], url_name="get_song", url_path="getSong" + ) + def get_song(self, request, *args, **kwargs): + data = request.GET or request.POST + + track = Track.objects.filter(pk=data["id"]).first() + if not track: + return response.Response( + {"error": {"code": 70, "message": "Track not found."}} + ) + data = serializers.GetSongSerializer(track).data + payload = {"song": data} + + return response.Response(payload, status=200) + + @action( + detail=False, + methods=["get", "post"], + url_name="get_artist_info2", + url_path="getArtistInfo2", + ) + def get_artist_info2(self, request, *args, **kwargs): + payload = {"artist-info2": {}} + + return response.Response(payload, status=200) + + @action( + detail=False, methods=["get", "post"], url_name="get_album", url_path="getAlbum" + ) + def get_album(self, request, *args, **kwargs): + req_data = request.GET or request.POST + + album = Album.objects.filter(pk=req_data["id"]).first() + if not album: + return response.Response( + {"error": {"code": 70, "message": "Album not found."}} + ) + data = serializers.GetAlbumSerializer(album).data + payload = {"album": data} + return response.Response(payload, status=200) + + @action(detail=False, methods=["get", "post"], url_name="stream", url_path="stream") + def stream(self, request, *args, **kwargs): + data = request.GET or request.POST + track = Track.objects.filter(pk=data["id"]).first() + max_bitrate = data.get("maxBitRate") + try: + max_bitrate = min(max(int(max_bitrate), 0), 320) or None + except (TypeError, ValueError): + max_bitrate = None + + if max_bitrate: + max_bitrate = max_bitrate * 1000 + + _format = data.get("format") or None + if max_bitrate and not _format: + _format = settings.SUBSONIC_DEFAULT_TRANSCODING_FORMAT + elif _format == "raw": + _format = None + + return handle_serve( + track=track, + user=request.user, + _format=_format, + max_bitrate=max_bitrate, + # Subsonic clients don't expect 302 redirection unfortunately, + # So we have to proxy media files + proxy_media=True, + ) + + @action( + detail=False, methods=["get", "post"], url_name="scrobble", url_path="scrobble" + ) + def scrobble(self, request, *args, **kwargs): + """ + 回传正在播放的音乐信息 ?id=1&albumId=1&submission=true + """ + return response.Response({}) + + @action( + detail=False, + methods=["get", "post"], + url_name="get_genres", + url_path="getGenres", + ) + def get_genres(self, request, *args, **kwargs): + + data = { + "genres": {"genre": [{ + "songCount": 0, + "albumCount": 0, + "value": "Rock", + }]} + } + return response.Response(data) + + @action( + detail=False, + methods=["get", "post"], + url_name="get_album_list2", + url_path="getAlbumList2", + ) + def get_album_list2(self, request, *args, **kwargs): + data = request.GET or request.POST + + queryset = Album.objects.order_by("artist__name") + filterset = AlbumList2FilterSet(data, queryset=queryset) + queryset = filterset.qs + al_type = data.get("type", "alphabeticalByArtist") + if al_type == "alphabeticalByArtist": + queryset = queryset.order_by("artist__name") + elif al_type == "random": + queryset = queryset.order_by("?") + elif al_type == "alphabeticalByName" or not al_type: + queryset = queryset.order_by("name") + elif al_type == "recent" or not al_type: + # 最近播放的 + queryset = queryset.exclude(max_year=0).order_by("-max_year") + elif al_type == "newest" or not al_type: + queryset = queryset.order_by("-created_at") + elif al_type == "frequent": + # 播放量最多的 + queryset = queryset.order_by("-plays_count") + elif al_type == "byGenre" and data.get("genre"): + genre = data.get("genre") + queryset = queryset.filter(genre__name=genre) + elif al_type == "byYear": + try: + boundaries = [ + int(data.get("fromYear", 0)), + int(data.get("toYear", 99999999)), + ] + + except (TypeError, ValueError): + return response.Response( + { + "error": { + "code": 10, + "message": "Invalid fromYear or toYear parameter", + } + } + ) + # because, yeah, the specification explicitly state that fromYear can be greater + # than toYear, to indicate reverse ordering… + # http://www.subsonic.org/pages/api.jsp#getAlbumList2 + from_year = min(boundaries) + to_year = max(boundaries) + queryset = queryset.filter( + max_year__gte=from_year, max_year__lte=to_year + ) + if boundaries[0] <= boundaries[1]: + queryset = queryset.order_by("max_year") + else: + queryset = queryset.order_by("-max_year") + try: + offset = int(data["offset"]) + except (TypeError, KeyError, ValueError): + offset = 0 + + try: + size = int(data["size"]) + except (TypeError, KeyError, ValueError): + size = 50 + + size = min(size, 500) + queryset = queryset[offset: offset + size] + data = {"albumList2": {"album": serializers.get_album_list2_data(queryset)}} + return response.Response(data) + + @action( + detail=False, + methods=["get", "post"], + url_name="get_indexes", + url_path="getIndexes", + ) + def get_indexes(self, request, *args, **kwargs): + artists = Artist.objects.all() + + data = serializers.GetArtistsSerializer(artists).data + payload = {"indexes": data} + + return response.Response(payload, status=200) + + @action( + detail=False, + methods=["get", "post"], + url_name="get_music_folders", + url_path="getMusicFolders", + ) + def get_music_folders(self, request, *args, **kwargs): + """ + 获取音乐文件夹 + """ + data = {"musicFolders": {"musicFolder": [{"id": 1, "name": "Music"}]}} + return response.Response(data, status=200) + + @action( + detail=False, + methods=["get", "post"], + url_name="get_music_directory", + url_path="getMusicDirectory", + ) + def get_music_directory(self, request, *args, **kwargs): + """ + 获取音乐文件夹 + """ + data = { + "directory": { + "id": "10", + "parent": "1", + "name": "ABBA", + "starred": "2013-11-02T12:30:00", + "child": [ + { + "id": "11", + "parent": "10", + "title": "Arrival", + "artist": "ABBA", + "isDir": "true", + "coverArt": "22" + }, + { + "id": "12", + "parent": "10", + "title": "Super Trouper", + "artist": "ABBA", + "isDir": "true", + "coverArt": "23" + } + ] + } + } + return response.Response(data) + + @action( + detail=False, + methods=["get", "post"], + url_name="get_playlists", + url_path="getPlaylists", + ) + def get_playlists(self, request, *args, **kwargs): + req_data = request.GET or request.POST + print(req_data) + qs = Playlist.objects.filter(user=request.user).all() + data = { + "playlists": {"playlist": [serializers.get_playlist_data(p) for p in qs]} + } + return response.Response(data) + + @action( + detail=False, + methods=["get", "post"], + url_name="get_starred2", + url_path="getStarred2", + ) + def get_starred2(self, request, *args, **kwargs): + favorites = request.user.track_favorites.all() + data = {"starred2": {"song": serializers.get_starred_tracks_data(favorites)}} + return response.Response(data) + + @action(detail=False, methods=["get", "post"], url_name="star", url_path="star") + def star(self, request, *args, **kwargs): + req_data = request.GET or request.POST + track = Track.objects.filter(id=req_data.get("id")).first() + if not track: + return response.Response({"error": {"code": 70, "message": "Track not found."}}) + TrackFavorite.add(user=request.user, track=track) + return response.Response({"status": "ok"}) + + @action(detail=False, methods=["get", "post"], url_name="unstar", url_path="unstar") + def unstar(self, request, *args, **kwargs): + req_data = request.GET or request.POST + track = Track.objects.filter(id=req_data.get("id")).first() + if not track: + return response.Response({"error": {"code": 70, "message": "Track not found."}}) + request.user.track_favorites.filter(track=track).delete() + return response.Response({"status": "ok"}) + + @action( + detail=False, + methods=["get", "post"], + url_name="get_scan_status", + url_path="getScanStatus", + ) + def get_scan_status(self, request, *args, **kwargs): + data = { + "scanStatus": { + "scanning": False, + "count": "5422" + } + } + return response.Response(data) + + @action( + detail=False, + methods=["get", "post"], + url_name="start_scan", + url_path="startScan", + ) + def start_scan(self, request, *args, **kwargs): + data = { + "scanStatus": { + "scanning": True, + "count": "5411" + } + } + return response.Response(data) diff --git a/applications/task/handlers.py b/applications/task/handlers.py index 3288cee..b8cde18 100644 --- a/applications/task/handlers.py +++ b/applications/task/handlers.py @@ -1,4 +1,12 @@ def init_task(sender, **kwargs): from django.contrib.auth.models import User + from applications.user.models import UserProfile + import os + from django.conf import settings + if not User.objects.filter(username="admin").exists(): User.objects.create_superuser("admin", "admin@qq.com", "admin") + + UserProfile.objects.get_or_create(user=User.objects.get(username="admin")) + music_folder = os.path.join(settings.MEDIA_ROOT, "music") + os.makedirs(music_folder, exist_ok=True) diff --git a/applications/task/tasks.py b/applications/task/tasks.py new file mode 100644 index 0000000..02b2582 --- /dev/null +++ b/applications/task/tasks.py @@ -0,0 +1,71 @@ +import music_tag +from django.conf import settings + +from applications.music.models import Folder, Track +from applications.subsonic.constants import AUDIO_EXTENSIONS_AND_MIMETYPE, COVER_TYPE +from django_vue_cli.celery_app import app +import os +import uuid + + +def get_uuid(): + return str(uuid.uuid4()) + + +@app.task +def full_scan(): + music_folder = os.path.join(settings.MEDIA_ROOT, "music") + bulk_create = [] + stack = [(None, music_folder)] + while len(stack) != 0: + # 从栈里取出数据 + parent_uid, dir_data = stack.pop(0) + if os.path.isdir(dir_data): + sub_path = os.listdir(dir_data) + my_uuid = get_uuid() + sub_path = [(my_uuid, f"{dir_data}/{i}") for i in sub_path] + stack.extend(sub_path) + bulk_create.append( + Folder(**{ + "name": dir_data.split("/")[-1], + "path": dir_data, + "file_type": "folder", + "uid": my_uuid, + "parent_id": parent_uid + }) + ) + else: + suffix = dir_data.split(".")[-1] + if suffix in dict(AUDIO_EXTENSIONS_AND_MIMETYPE): + print(dir_data) + my_uuid = get_uuid() + bulk_create.append( + Folder(**{ + "name": dir_data.split("/")[-1], + "path": dir_data, + "file_type": "music", + "uid": my_uuid, + "parent_id": parent_uid + }) + ) + elif suffix in COVER_TYPE: + my_uuid = get_uuid() + bulk_create.append( + Folder(**{ + "name": dir_data.split("/")[-1], + "path": dir_data, + "file_type": "image", + "uid": my_uuid, + "parent_id": parent_uid + }) + ) + Folder.objects.bulk_create(bulk_create, batch_size=500) + + +@app.task +def scan_music_id3(): + music_list = Folder.objects.filter(file_type="music").all() + bulk_create = [] + for music in music_list: + f = music_tag.load_file(music.path) + Track.objects.filter() \ No newline at end of file diff --git a/applications/user/migrations/0002_userprofile_subsonic_api_token.py b/applications/user/migrations/0002_userprofile_subsonic_api_token.py new file mode 100644 index 0000000..d54d47d --- /dev/null +++ b/applications/user/migrations/0002_userprofile_subsonic_api_token.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.6 on 2023-04-23 16:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='subsonic_api_token', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/applications/user/models.py b/applications/user/models.py index 91e5a98..b7574af 100644 --- a/applications/user/models.py +++ b/applications/user/models.py @@ -4,3 +4,4 @@ from django.contrib.auth.models import User class UserProfile(models.Model): user = models.OneToOneField(User, related_name="profile", on_delete=models.CASCADE) + subsonic_api_token = models.CharField(blank=True, null=True, max_length=255) diff --git a/django_vue_cli/__init__.py b/django_vue_cli/__init__.py index 2f9377f..c58e6a0 100644 --- a/django_vue_cli/__init__.py +++ b/django_vue_cli/__init__.py @@ -1,5 +1,5 @@ from __future__ import absolute_import -# from .celery_app import app as current_app -# -# __all__ = ('current_app',) +from .celery_app import app as current_app + +__all__ = ('current_app',) diff --git a/django_vue_cli/celery_app.py b/django_vue_cli/celery_app.py index 1a5666e..4a80630 100644 --- a/django_vue_cli/celery_app.py +++ b/django_vue_cli/celery_app.py @@ -1,30 +1,30 @@ -# # -*- coding: utf-8 -*- -# -# from __future__ import absolute_import, unicode_literals -# -# import os -# import time -# from celery import Celery, platforms -# from django.conf import settings -# -# platforms.C_FORCE_ROOT = True -# -# # set the default Django settings module for the 'celery' program. -# os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_vue_cli.settings") -# -# app = Celery("django_vue_cli") -# -# # Using a string here means the worker don't have to serialize -# # the configuration object to child processes. -# # - namespace='CELERY' means all celery-related configuration keys -# # should have a `CELERY_` prefix. -# app.config_from_object("django.conf:settings") -# -# # Load task modules from all registered Django app configs. -# app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) -# -# -# @app.task(bind=True) -# def debug_task(self): -# print("Request: {!r}".format(self.request)) -# time.sleep(2) +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, unicode_literals + +import os +import time +from celery import Celery, platforms +from django.conf import settings + +platforms.C_FORCE_ROOT = True + +# set the default Django settings module for the 'celery' program. +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_vue_cli.settings") + +app = Celery("django_vue_cli") + +# Using a string here means the worker don't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object("django.conf:settings") + +# Load task modules from all registered Django app configs. +app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) + + +@app.task(bind=True) +def debug_task(self): + print("Request: {!r}".format(self.request)) + time.sleep(2) diff --git a/django_vue_cli/settings.py b/django_vue_cli/settings.py index 48386b9..89c5bbf 100644 --- a/django_vue_cli/settings.py +++ b/django_vue_cli/settings.py @@ -18,8 +18,6 @@ CORS_ORIGIN_WHITELIST = [ "http://127.0.0.1:8080" ] -# Application definition - INSTALLED_APPS = [ "corsheaders", 'django.contrib.admin', @@ -32,8 +30,8 @@ INSTALLED_APPS = [ "applications.task", "applications.user", "applications.music", - "applications.navidrome", - + "applications.subsonic", + "django_extensions", ] MIDDLEWARE = [ @@ -71,26 +69,26 @@ TIME_ZONE = "Asia/Shanghai" LANGUAGE_CODE = "zh-hans" # Database # https://docs.djangoproject.com/en/3.2/ref/settings/#databases -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), - }, - # 'navidrome': { - # 'ENGINE': 'django.db.backends.sqlite3', - # 'NAME': "/Users/macbookair/Music/my_music/data2/navidrome.db", - # } -} # DATABASES = { -# "default": { -# "ENGINE": "django.db.backends.mysql", -# "NAME": 'music', # noqa -# "USER": "root", -# "PASSWORD": "123456", -# "HOST": "localhost", -# "PORT": "3306", +# 'default': { +# 'ENGINE': 'django.db.backends.sqlite3', +# 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), # }, +# 'navidrome': { +# 'ENGINE': 'django.db.backends.sqlite3', +# 'NAME': "/Users/macbookair/Music/my_music/data/navidrome.db", # } +# } +DATABASES = { + "default": { + "ENGINE": "django.db.backends.mysql", + "NAME": 'music3', # noqa + "USER": "root", + "PASSWORD": "123456", + "HOST": "localhost", + "PORT": "3306", + }, +} # Password validation # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators @@ -120,11 +118,17 @@ STATIC_ROOT = 'static' # STATICFILES_DIRS = [os.path.join(BASE_DIR, "static")] # noqa DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -IS_USE_CELERY = False +IS_USE_CELERY = True if IS_USE_CELERY: + REDIS_HOST = "127.0.0.1" + BROKER_URL = f"redis://{REDIS_HOST}:6379/1" + CELERY_TIMEZONE = 'Asia/Shanghai' INSTALLED_APPS += ("django_celery_beat", "django_celery_results") CELERY_ENABLE_UTC = False + ENABLE_UTC = False + DJANGO_CELERY_BEAT_TZ_AWARE = False + CELERY_TASK_SERIALIZER = "pickle" CELERY_ACCEPT_CONTENT = ['pickle', ] CELERYBEAT_SCHEDULER = "django_celery_beat.schedulers.DatabaseScheduler" @@ -158,7 +162,10 @@ JWT_AUTH = { 'JWT_AUTH_HEADER_PREFIX': 'JWT', } BASE_URL = "https://music.163.com/" - +REVERSE_PROXY_TYPE = "nginx" +MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, "media") +SUBSONIC_DEFAULT_TRANSCODING_FORMAT = "mp3" try: from local_settings import * # noqa except ImportError: diff --git a/django_vue_cli/urls.py b/django_vue_cli/urls.py index d32cdfe..8f3d092 100644 --- a/django_vue_cli/urls.py +++ b/django_vue_cli/urls.py @@ -4,6 +4,7 @@ from django.urls import path, include, re_path from django_vue_cli.views import index from applications.task.urls import router as task_router from applications.user.urls import router as user_router +from applications.subsonic.urls import router as subsonic_router from django.views import static from rest_framework_jwt.views import obtain_jwt_token @@ -11,8 +12,11 @@ urlpatterns = [ path('admin/', admin.site.urls), path('', index), re_path(r"^api/", include(task_router.urls)), + re_path(r"^rest/", include(subsonic_router.urls)), re_path(r"^user/", include(user_router.urls)), re_path(r'^api/token/', obtain_jwt_token), - re_path(r'^static/(?P.*)$', static.serve, - {'document_root': settings.STATIC_ROOT}, name='static'), + # nginx 处理了静态文件 + # re_path(r'^static/(?P.*)$', static.serve, + # {'document_root': settings.STATIC_ROOT}, name='static'), + # re_path(r'^media/(?P.*)$', static.serve, {'document_root': settings.MEDIA_ROOT}), ] diff --git a/requirements.txt b/requirements.txt index 88dc8f2..f0017ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ Django==2.2.6 +celery==4.4.7 +django-celery-beat==2.2.0 +django-celery-results==1.2.1 django-cors-headers==3.2.1 django-filter==2.0.0 djangorestframework==3.8.1 @@ -10,4 +13,5 @@ djangorestframework-jwt==1.11.0 music-tag==0.4.3 Pillow==9.4.0 pycryptodomex==3.17 -Mako==1.0.6 \ No newline at end of file +Mako==1.0.6 +django-mysql==3.8.1