mirror of
https://github.com/xhongc/music-tag-web.git
synced 2026-04-28 20:51:14 +08:00
feature:开发subsonic api 啊
This commit is contained in:
@@ -9,4 +9,5 @@
|
||||
.travis.yml
|
||||
venv
|
||||
.git
|
||||
/web/
|
||||
/web/
|
||||
/media/
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,3 +18,4 @@ yarn-error.log*
|
||||
*.sln
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
/media/
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
|
||||
@@ -2,4 +2,4 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class MusicConfig(AppConfig):
|
||||
name = 'music'
|
||||
name = 'applications.music'
|
||||
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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=''),
|
||||
),
|
||||
]
|
||||
27
applications/music/migrations/0002_folder.py
Normal file
27
applications/music/migrations/0002_folder.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
48
applications/music/migrations/0004_auto_20230426_1732.py
Normal file
48
applications/music/migrations/0004_auto_20230426_1732.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
167
applications/music/validators.py
Normal file
167
applications/music/validators.py
Normal 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
|
||||
@@ -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)
|
||||
@@ -1,5 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NavidromeConfig(AppConfig):
|
||||
name = 'navidrome'
|
||||
@@ -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 = "风格"
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
3
applications/subsonic/admin.py
Normal file
3
applications/subsonic/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
5
applications/subsonic/apps.py
Normal file
5
applications/subsonic/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SubsonicConfig(AppConfig):
|
||||
name = 'subsonic'
|
||||
63
applications/subsonic/authentication.py
Normal file
63
applications/subsonic/authentication.py
Normal 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)
|
||||
20
applications/subsonic/constants.py
Normal file
20
applications/subsonic/constants.py
Normal 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}
|
||||
23
applications/subsonic/filters.py
Normal file
23
applications/subsonic/filters.py
Normal 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])
|
||||
0
applications/subsonic/models.py
Normal file
0
applications/subsonic/models.py
Normal file
18
applications/subsonic/negotiation.py
Normal file
18
applications/subsonic/negotiation.py
Normal 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)
|
||||
90
applications/subsonic/renderers.py
Normal file
90
applications/subsonic/renderers.py
Normal 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
|
||||
318
applications/subsonic/serializers.py
Normal file
318
applications/subsonic/serializers.py
Normal 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",
|
||||
}
|
||||
7
applications/subsonic/urls.py
Normal file
7
applications/subsonic/urls.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from rest_framework import routers
|
||||
|
||||
from . import views
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
router.register(r"", views.SubsonicViewSet, base_name='subsonic')
|
||||
|
||||
42
applications/subsonic/utils.py
Normal file
42
applications/subsonic/utils.py
Normal 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
|
||||
472
applications/subsonic/views.py
Normal file
472
applications/subsonic/views.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
71
applications/task/tasks.py
Normal file
71
applications/task/tasks.py
Normal 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()
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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',)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user