Initial changes to Song model using Stores.

This commit is contained in:
RecursiveGreen 2019-06-03 10:59:18 -04:00
parent 7e0b2a5a45
commit 9fa3a408b1
6 changed files with 325 additions and 216 deletions

View file

@ -0,0 +1,39 @@
'''
Custom model fields for the Save Point Radio project.
'''
from django.core import validators
from django.db import models
from django.utils.translation import ugettext_lazy as _
from .forms import ALLOWED_SCHEMES, RadioIRIFormField
class RadioIRIField(models.TextField):
'''
A custom URL model field that allows schemes that match those from
Liquidsoap. This is necessary due to a bug in how Django currently
handles custom URLFields:
https://code.djangoproject.com/ticket/25594
https://stackoverflow.com/questions/41756572/
'''
default_validators = [validators.URLValidator(schemes=ALLOWED_SCHEMES)]
description = _("Long IRI")
def __init__(self, verbose_name=None, name=None, **kwargs):
# This is a limit for Internet Explorer URLs
kwargs.setdefault('max_length', 2000)
super().__init__(verbose_name, name, **kwargs)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
if kwargs.get("max_length") == 2000:
del kwargs['max_length']
return name, path, args, kwargs
def formfield(self, **kwargs):
return super().formfield(**{
'form_class': RadioIRIFormField,
**kwargs,
})

View file

@ -0,0 +1,21 @@
'''
Custom forms/formfields for the Save Point Radio project.
'''
from django.core import validators
from django.forms.fields import URLField
ALLOWED_SCHEMES = ['http', 'https', 'file', 'ftp', 'ftps', 's3']
class RadioIRIFormField(URLField):
'''
A custom URL form field that allows schemes that match those from
Liquidsoap. This is necessary due to a bug in how Django currently
handles custom URLFields:
https://code.djangoproject.com/ticket/25594
https://stackoverflow.com/questions/41756572/
'''
default_validators = [validators.URLValidator(schemes=ALLOWED_SCHEMES)]

View file

@ -1,160 +1,129 @@
from decimal import *
'''
Django management command to import old playlist data. This should only be used
for seeding a newly created database.
'''
import decimal
import json
import os
import sqlite3
from django.core.management.base import BaseCommand, CommandError
from radio.models import Album, Artist, Game, Song
from core.utils import path_to_iri
from radio.models import Album, Artist, Game, Store, Song
getcontext().prec = 8
def adapt_decimal(d):
return str(d)
def convert_decimal(s):
return Decimal(s.decode('utf8'))
sqlite3.register_adapter(Decimal, adapt_decimal)
sqlite3.register_converter("decimal", convert_decimal)
decimal.getcontext().prec = 8
class Command(BaseCommand):
help = 'Imports the old radio data from the original sqlite3 database'
'''Main "importoldreadio" command class'''
help = 'Imports the old radio data from an exported playlist'
def add_arguments(self, parser):
parser.add_argument('sqlite3_db_file', nargs=1)
parser.add_argument('playlist_file', nargs=1)
def handle(self, *args, **options):
if not os.path.isfile(options['sqlite3_db_file'][0]):
playlist_file = options['playlist_file'][0]
if not os.path.isfile(playlist_file):
raise CommandError('File does not exist')
else:
total_albums = 0
total_artists = 0
total_games = 0
total_songs = 0
total_jingles = 0
detect_types = sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES
con = sqlite3.connect(options['sqlite3_db_file'][0],
detect_types=detect_types)
cur = con.cursor()
with open(playlist_file, 'r', encoding='utf8') as pfile:
playlist = json.load(pfile, parse_float=decimal.Decimal)
# Fetching albums first
for album in con.execute('SELECT title, enabled FROM albums'):
album_disabled = not bool(album[1])
Album.objects.create(title=album[0], disabled=album_disabled)
total_albums += 1
totals = {
'albums': 0,
'artists': 0,
'games': 0,
'songs': 0,
'jingles': 0
}
self.stdout.write('Imported {} albums'.format(str(total_albums)))
# Fetching albums first
for album in playlist['albums']:
Album.objects.create(title=album['title'],
disabled=album['disabled'])
total_albums += 1
# Next up, artists
cur.execute('''SELECT
artists_id,
alias,
firstname,
lastname,
enabled
FROM artists''')
artists = cur.fetchall()
self.stdout.write('Imported {} albums'.format(str(totals['albums'])))
for artist in artists:
artist_disabled = not bool(artist[4])
Artist.objects.create(alias=artist[1] or '',
first_name=artist[2] or '',
last_name=artist[3] or '',
disabled=artist_disabled)
total_artists += 1
# Next up, artists
for artist in playlist['artists']:
Artist.objects.create(alias=artist['alias'] or '',
first_name=artist['first_name'] or '',
last_name=artist['last_name'] or '',
disabled=artist['disabled'])
total_artists += 1
self.stdout.write('Imported {} artists'.format(str(total_artists)))
self.stdout.write('Imported {} artists'.format(str(totals['artists'])))
# On to games
for game in con.execute('SELECT title, enabled FROM games'):
game_disabled = not bool(game[1])
Game.objects.create(title=game[0], disabled=game_disabled)
total_games += 1
# On to games
for game in playlist['games']:
Game.objects.create(title=game['title'],
disabled=game['disabled'])
total_games += 1
self.stdout.write('Imported {} games'.format(str(total_games)))
self.stdout.write('Imported {} games'.format(str(totals['games'])))
# Followed by the songs
cur.execute('''SELECT
songs.songs_id AS id,
games.title AS game,
albums.title AS album,
songs.enabled AS enabled,
songs.type AS type,
songs.title AS title,
songs.length AS length,
songs.path AS path
FROM songs
LEFT JOIN games
ON (songs.game = games.games_id)
LEFT JOIN albums
ON (songs.album = albums.albums_id)''')
songs = cur.fetchall()
# Followed by the songs
for song in playlist['songs']:
try:
album = Album.objects.get(title__exact=song['album'])
except Album.DoesNotExist:
album = None
for song in songs:
try:
album = Album.objects.get(title__exact=song[2])
except Album.DoesNotExist:
album = None
try:
game = Game.objects.get(title__exact=song['game'])
except Game.DoesNotExist:
game = None
try:
game = Game.objects.get(title__exact=song[1])
except Game.DoesNotExist:
game = None
new_song = Song.objects.create(album=album,
game=game,
disabled=song['disabled'],
song_type=song['type'],
title=song['title'])
song_disabled = not bool(song[3])
new_song = Song.objects.create(album=album,
game=game,
disabled=song_disabled,
song_type=song[4],
title=song[5],
length=song[6],
path=song[7])
if song[4] == 'S':
total_songs += 1
else:
total_jingles += 1
cur.execute('''SELECT
ifnull(alias, "") AS alias,
ifnull(firstname, "") AS firstname,
ifnull(lastname, "") AS lastname
FROM artists
WHERE artists_id
IN (SELECT artists_artists_id
FROM songs_have_artists
WHERE songs_songs_id = ?)''', [song[0]])
old_artists = cur.fetchall()
for old_artist in old_artists:
new_artist = Artist.objects.get(alias__exact=old_artist[0],
first_name__exact=old_artist[1],
last_name__exact=old_artist[2])
new_song.artists.add(new_artist)
self.stdout.write(
'Imported {} requestables ({} songs, {} jingles)'.format(
str(total_songs + total_jingles),
str(total_songs),
str(total_jingles)
for artist in song['artists']:
new_artist = Artist.objects.get(
alias__exact=artist['alias'] or '',
first_name__exact=artist['first_name'] or '',
last_name__exact=artist['last_name'] or ''
)
new_song.artists.add(new_artist)
new_store = Store.objects.create(
iri=path_to_iri(song['store']['path']),
mime_type=song['store']['mime'],
file_size=song['store']['filesize'],
length=song['store']['length']
)
pub = input('Do you want to publish all imported objects as well? '
'[Y/N] ')
if pub == 'Y' or pub == 'y':
for al in Album.objects.all():
al.publish()
for ar in Artist.objects.all():
ar.publish()
for g in Game.objects.all():
g.publish()
for s in Song.objects.all():
s.publish()
self.stdout.write('Published imported objects successfully')
new_song.stores.add(new_store)
new_song.current_store = new_store
new_song.save()
if song['type'] == 'S':
totals['songs'] += 1
else:
self.stdout.write('Skipped publishing songs')
totals['jingles'] += 1
self.stdout.write(
'Imported {} requestables ({} songs, {} jingles)'.format(
str(totals['songs'] + totals['jingles']),
str(totals['songs']),
str(totals['jingles'])
)
)
pub = input('Do you want to publish all imported objects as well? '
'[Y/N] ')
if pub in ('Y', 'y'):
for album in Album.objects.all():
album.publish()
for artist in Artist.objects.all():
artist.publish()
for game in Game.objects.all():
game.publish()
for song in Song.objects.all():
song.publish()
self.stdout.write('Published imported objects successfully')
else:
self.stdout.write('Skipped publishing songs')

View file

@ -1,3 +1,7 @@
'''
Django Model Managers for the Radio application.
'''
from datetime import timedelta
from decimal import getcontext, Decimal, ROUND_UP
from random import randint
@ -15,123 +19,126 @@ getcontext().prec = 16
class RadioManager(models.Manager):
"""
'''
Custom object manager for filtering out common behaviors for radio
objects.
"""
'''
def get_queryset(self):
"""
'''
Return customized default QuerySet.
"""
'''
return RadioQuerySet(self.model, using=self._db)
def disabled(self):
"""
'''
Radio objects that are marked as disabled.
"""
'''
return self.get_queryset().disabled()
def enabled(self):
"""
'''
Radio objects that are marked as enabled.
"""
'''
return self.get_queryset().enabled()
def published(self):
"""
'''
Radio objects that are marked as published.
"""
'''
return self.get_queryset().published()
def unpublished(self):
"""
'''
Radio objects that are marked as unpublished.
"""
'''
return self.get_queryset().unpublished()
def available(self):
"""
'''
Radio objects that are enabled and published.
"""
'''
return self.enabled().published()
class SongManager(RadioManager):
"""
'''
Custom object manager for filtering out common behaviors for Song objects.
"""
'''
def get_queryset(self):
"""
'''
Return customized default QuerySet for Songs.
"""
'''
return SongQuerySet(self.model, using=self._db)
def available_jingles(self):
"""
'''
Jingles that are currently published and are enabled.
"""
'''
return self.available().jingles()
def available_songs(self):
"""
'''
Songs that are currently published and are enabled.
"""
'''
return self.available().songs()
def playlist_length(self):
"""
'''
Total length of available songs in the playlist (in seconds).
"""
length = self.available_songs().aggregate(models.Sum('length'))
return length['length__sum']
'''
a_songs = self.available_songs()
length = a_songs.aggregate(
total_time=models.Sum('current_store__length')
)
return length['total_time']
def wait_total(self, adjusted_ratio=0.0):
"""
'''
Default length in seconds before a song can be played again. This is
based on the replay ratio set in the application settings.
"""
'''
total_ratio = get_setting('replay_ratio') + adjusted_ratio
wait = self.playlist_length() * Decimal(total_ratio)
wait = wait.quantize(Decimal('.01'), rounding=ROUND_UP)
return timedelta(seconds=float(wait))
def datetime_from_wait(self):
"""
'''
Datetime of now minus the default wait time for played songs.
"""
'''
return timezone.now() - self.wait_total()
def playable(self):
"""
'''
Songs that are playable because they are available (enabled &
published) and they have not been played within the default wait time
(or at all).
"""
'''
return self.available_songs().filter(
models.Q(next_play__lt=timezone.now()) |
models.Q(next_play__isnull=True)
)
models.Q(next_play__lt=timezone.now()) |
models.Q(next_play__isnull=True)
)
def requestable(self):
"""
'''
Songs that can be placed in the request queue for playback.
"""
'''
# Import SongRequest here to get rid of circular dependencies
SongRequest = apps.get_model(app_label='profiles',
model_name='SongRequest')
requests = SongRequest.music.unplayed().values_list('song__id',
flat=True)
song_request = apps.get_model(app_label='profiles',
model_name='SongRequest')
requests = song_request.music.unplayed().values_list('song__id',
flat=True)
return self.playable().exclude(id__in=requests)
def get_random_requestable_song(self):
"""
'''
Pick a random requestable song and return it.
"""
'''
return self.requestable()[randint(0, self.requestable().count() - 1)]
def get_random_jingle(self):
"""
'''
Pick a random jingle and return it.
"""
'''
random_index = randint(0, self.available_jingles().count() - 1)
return self.available_jingles()[random_index]

View file

@ -0,0 +1,49 @@
# Generated by Django 2.2.1 on 2019-05-31 03:00
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import radio.fields
class Migration(migrations.Migration):
dependencies = [
('radio', '0003_song_next_play'),
]
operations = [
migrations.CreateModel(
name='Store',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(auto_now_add=True, verbose_name='added on')),
('modified_date', models.DateTimeField(auto_now=True, verbose_name='last modified')),
('iri', radio.fields.RadioIRIField()),
('mime_type', models.CharField(blank=True, max_length=64, verbose_name='file MIME type')),
('file_size', models.BigIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='file size')),
('length', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True, verbose_name='song length (in seconds)')),
],
options={
'abstract': False,
},
),
migrations.RemoveField(
model_name='song',
name='length',
),
migrations.RemoveField(
model_name='song',
name='path',
),
migrations.AddField(
model_name='song',
name='current_store',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='current_of', to='radio.Store'),
),
migrations.AddField(
model_name='song',
name='stores',
field=models.ManyToManyField(blank=True, related_name='song', to='radio.Store'),
),
]

View file

@ -1,13 +1,19 @@
'''
Django Models for the Radio application.
'''
from datetime import timedelta
from decimal import getcontext, Decimal, ROUND_UP
from django.apps import apps
from django.core.validators import MinValueValidator
from django.db import models
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from core.behaviors import Disableable, Publishable, Timestampable
from core.utils import get_setting
from .fields import RadioIRIField
from .managers import RadioManager, SongManager
@ -16,9 +22,9 @@ getcontext().prec = 16
class Album(Disableable, Publishable, Timestampable, models.Model):
"""
'''
A model for a music album.
"""
'''
title = models.CharField(_('title'), max_length=255, unique=True)
sorted_title = models.CharField(_('naturalized title'),
@ -37,9 +43,9 @@ class Album(Disableable, Publishable, Timestampable, models.Model):
class Artist(Disableable, Publishable, Timestampable, models.Model):
"""
'''
A model for a music artist.
"""
'''
alias = models.CharField(_('alias'), max_length=127, blank=True)
first_name = models.CharField(_('first name'), max_length=127, blank=True)
last_name = models.CharField(_('last name'), max_length=127, blank=True)
@ -57,10 +63,10 @@ class Artist(Disableable, Publishable, Timestampable, models.Model):
@property
def full_name(self):
"""
'''
String representing the artist's full name including an alias, if
available.
"""
'''
if self.alias:
if self.first_name or self.last_name:
return '{} "{}" {}'.format(self.first_name,
@ -74,9 +80,9 @@ class Artist(Disableable, Publishable, Timestampable, models.Model):
class Game(Disableable, Publishable, Timestampable, models.Model):
"""
'''
A model for a game.
"""
'''
title = models.CharField(_('title'), max_length=255, unique=True)
sorted_title = models.CharField(_('naturalized title'),
@ -94,10 +100,29 @@ class Game(Disableable, Publishable, Timestampable, models.Model):
return self.title
class Store(Timestampable, models.Model):
'''
A model to represent various data locations (stores) for the song.
'''
iri = RadioIRIField()
mime_type = models.CharField(_('file MIME type'),
max_length=64,
blank=True)
file_size = models.BigIntegerField(_('file size'),
validators=[MinValueValidator(0)],
blank=True,
null=True)
length = models.DecimalField(_('song length (in seconds)'),
max_digits=8,
decimal_places=2,
null=True,
blank=True)
class Song(Disableable, Publishable, Timestampable, models.Model):
"""
'''
A model for a song.
"""
'''
JINGLE = 'J'
SONG = 'S'
TYPE_CHOICES = (
@ -128,13 +153,12 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
null=True,
blank=True,
editable=False)
length = models.DecimalField(_('song length (in seconds)'),
max_digits=8,
decimal_places=2,
null=True,
blank=True)
path = models.TextField(_('absolute path to song file'))
stores = models.ManyToManyField(Store, blank=True, related_name='song')
current_store = models.ForeignKey(Store,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='current_of')
sorted_title = models.CharField(_('naturalized title'),
db_index=True,
editable=False,
@ -147,34 +171,34 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
ordering = ['sorted_title', ]
def _is_jingle(self):
"""
'''
Is the object a jingle?
"""
'''
return self.song_type == 'J'
_is_jingle.boolean = True
is_jingle = property(_is_jingle)
def _is_song(self):
"""
'''
Is the object a song?
"""
'''
return self.song_type == 'S'
_is_song.boolean = True
is_song = property(_is_song)
def _is_available(self):
"""
'''
Is the object both enabled and published?
"""
'''
return self._is_enabled() and self._is_published()
_is_available.boolean = True
is_available = property(_is_available)
def _full_title(self):
"""
'''
String representing the entire song title, including the game and
artists involved.
"""
'''
if self._is_song():
enabled_artists = self.artists.all().filter(disabled=False)
all_artists = ', '.join([a.full_name for a in enabled_artists])
@ -185,9 +209,9 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
full_title = property(_full_title)
def _average_rating(self):
"""
'''
Decimal number of the average rating of a song from 1 - 5.
"""
'''
ratings = self.rating_set.all()
if ratings:
avg = Decimal(ratings.aggregate(avg=models.Avg('value'))['avg'])
@ -196,9 +220,9 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
average_rating = property(_average_rating)
def get_time_until_requestable(self):
"""
'''
Length of time before a song can be requested again.
"""
'''
if self._is_song() and self._is_available():
if self.last_played:
allowed_datetime = Song.music.datetime_from_wait()
@ -209,9 +233,9 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
return None
def get_date_when_requestable(self, last_play=None):
"""
'''
Datetime when a song can be requested again.
"""
'''
last = self.last_played if last_play is None else last_play
if self._is_song() and self._is_available():
@ -232,10 +256,10 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
return None
def _is_playable(self):
"""
'''
Is the song available and not been played within the default waiting
period (or at all)?
"""
'''
if self._is_song() and self._is_available():
return self.get_date_when_requestable() <= timezone.now()
return False
@ -243,14 +267,14 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
is_playable = property(_is_playable)
def _is_requestable(self):
"""
'''
Is the song playable and has it not already been requested?
"""
'''
if self._is_playable():
SongRequest = apps.get_model(app_label='profiles',
model_name='SongRequest')
requests = SongRequest.music.unplayed().values_list('song__id',
flat=True)
song_request = apps.get_model(app_label='profiles',
model_name='SongRequest')
requests = song_request.music.unplayed().values_list('song__id',
flat=True)
return self.pk not in requests
return False
_is_requestable.boolean = True