From 9fa3a408b14abbd020824af059b06aec2a3134ee Mon Sep 17 00:00:00 2001 From: RecursiveGreen Date: Mon, 3 Jun 2019 10:59:18 -0400 Subject: [PATCH] Initial changes to Song model using Stores. --- savepointradio/radio/fields.py | 39 +++ savepointradio/radio/forms.py | 21 ++ .../management/commands/importoldradio.py | 233 ++++++++---------- savepointradio/radio/managers.py | 97 ++++---- .../0004_new_song_path_structure.py | 49 ++++ savepointradio/radio/models.py | 102 +++++--- 6 files changed, 325 insertions(+), 216 deletions(-) create mode 100644 savepointradio/radio/fields.py create mode 100644 savepointradio/radio/forms.py create mode 100644 savepointradio/radio/migrations/0004_new_song_path_structure.py diff --git a/savepointradio/radio/fields.py b/savepointradio/radio/fields.py new file mode 100644 index 0000000..8cc2b5d --- /dev/null +++ b/savepointradio/radio/fields.py @@ -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, + }) diff --git a/savepointradio/radio/forms.py b/savepointradio/radio/forms.py new file mode 100644 index 0000000..48c0667 --- /dev/null +++ b/savepointradio/radio/forms.py @@ -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)] diff --git a/savepointradio/radio/management/commands/importoldradio.py b/savepointradio/radio/management/commands/importoldradio.py index 971e8ca..60015e2 100644 --- a/savepointradio/radio/management/commands/importoldradio.py +++ b/savepointradio/radio/management/commands/importoldradio.py @@ -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') diff --git a/savepointradio/radio/managers.py b/savepointradio/radio/managers.py index 5a7c898..0d6dda7 100644 --- a/savepointradio/radio/managers.py +++ b/savepointradio/radio/managers.py @@ -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] diff --git a/savepointradio/radio/migrations/0004_new_song_path_structure.py b/savepointradio/radio/migrations/0004_new_song_path_structure.py new file mode 100644 index 0000000..691ed25 --- /dev/null +++ b/savepointradio/radio/migrations/0004_new_song_path_structure.py @@ -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'), + ), + ] diff --git a/savepointradio/radio/models.py b/savepointradio/radio/models.py index 7b0cd0d..0610577 100644 --- a/savepointradio/radio/models.py +++ b/savepointradio/radio/models.py @@ -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