feature:开发subsonic api 啊

This commit is contained in:
charlesxie
2023-04-26 17:33:06 +08:00
parent 40cb8d3a23
commit be0c2dcde2
39 changed files with 1933 additions and 303 deletions

View File

@@ -9,4 +9,5 @@
.travis.yml
venv
.git
/web/
/web/
/media/

1
.gitignore vendored
View File

@@ -18,3 +18,4 @@ yarn-error.log*
*.sln
local_settings.py
db.sqlite3
/media/

View File

@@ -1,3 +0,0 @@
from django.contrib import admin
# Register your models here.

View File

@@ -2,4 +2,4 @@ from django.apps import AppConfig
class MusicConfig(AppConfig):
name = 'music'
name = 'applications.music'

View File

@@ -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')},
},
),
]

View File

@@ -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=''),
),
]

View File

@@ -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)),
],
),
]

View File

@@ -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),
),
]

View File

@@ -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)

View File

@@ -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")

View File

@@ -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

View File

@@ -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)

View File

@@ -1,5 +0,0 @@
from django.apps import AppConfig
class NavidromeConfig(AppConfig):
name = 'navidrome'

View File

@@ -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 = "风格"

View File

@@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class SubsonicConfig(AppConfig):
name = 'subsonic'

View File

@@ -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)

View File

@@ -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}

View File

@@ -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])

View File

View File

@@ -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)

View File

@@ -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'<?xml version="1.0" encoding="UTF-8"?>\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

View File

@@ -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",
}

View File

@@ -0,0 +1,7 @@
from rest_framework import routers
from . import views
router = routers.DefaultRouter()
router.register(r"", views.SubsonicViewSet, base_name='subsonic')

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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),
),
]

View File

@@ -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)

View File

@@ -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',)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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<path>.*)$', static.serve,
{'document_root': settings.STATIC_ROOT}, name='static'),
# nginx 处理了静态文件
# re_path(r'^static/(?P<path>.*)$', static.serve,
# {'document_root': settings.STATIC_ROOT}, name='static'),
# re_path(r'^media/(?P<path>.*)$', static.serve, {'document_root': settings.MEDIA_ROOT}),
]

View File

@@ -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
Mako==1.0.6
django-mysql==3.8.1