Merge song-path-redux into master

Lets do this. . .
This commit is contained in:
Josh Washburne 2019-07-15 08:49:44 -04:00 committed by GitHub
commit ef7c6e535f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1516 additions and 369 deletions

View file

@ -8,22 +8,48 @@ when a song has been played.
import argparse import argparse
import json import json
import logging
from logging.handlers import RotatingFileHandler
from decouple import config
import requests import requests
DJ_TOKEN = 'place_generated_token_here' DJ_TOKEN = config('DJ_TOKEN')
API_URL = 'https://savepointradio.net/api/' API_URL = config('API_URL') # With trailing slash
RADIO_NAME = 'Save Point Radio' RADIO_NAME = config('RADIO_NAME')
HEADERS = { HEADERS = {
'Content-Type': 'application/json; charset=utf-8', 'Content-Type': 'application/json; charset=utf-8',
'Authorization': 'Token {}'.format(DJ_TOKEN) 'Authorization': 'Token {}'.format(DJ_TOKEN)
} }
ANNOTATE = 'annotate:req_id="{}",type="{}",artist="{}",title="{}",game="{}":{}' ANNOTATE = (
'annotate:req_id="{}",'
'type="{}",'
'artist="{}",'
'title="{}",'
'game="{}",'
'replay_gain="{}":{}'
)
logging.basicConfig(
handlers=[
RotatingFileHandler(
'./song_requests.log',
maxBytes=1000000,
backupCount=5,
encoding='utf8'
)
],
level=logging.INFO,
format=('[%(asctime)s] [%(levelname)s]'
' [%(name)s.%(funcName)s] === %(message)s'),
datefmt='%Y-%m-%dT%H:%M:%S'
)
LOGGER = logging.getLogger('djcontrol')
def clean_quotes(unclean_string): def clean_quotes(unclean_string):
@ -43,40 +69,28 @@ def beautify_artists(artists):
return clean_quotes(output.join(artists)) return clean_quotes(output.join(artists))
description = 'Lets the DJ control the radio.' def next_request():
'''
parser = argparse.ArgumentParser(description=description) Sends an HTTP[S] request to the radio web service to retrieve the next
subparsers = parser.add_subparsers(dest='command') requested song.
'''
parser_next = subparsers.add_parser('next', LOGGER.debug('Received command to get next song request.')
help='Gets the next song from the radio.')
parser_played = subparsers.add_parser('played',
help='Tells the radio which song just played.')
parser_played.add_argument('request',
help='Song request ID number.',
nargs=1,
type=int)
args = parser.parse_args()
if args.command == 'next':
try: try:
r = requests.get(API_URL + 'next/', resp = requests.get(API_URL + 'next/', headers=HEADERS, timeout=5)
headers=HEADERS, resp.encoding = 'utf-8'
timeout=5) resp.raise_for_status()
r.encoding = 'utf-8'
r.raise_for_status()
except requests.exceptions.HTTPError as errh: except requests.exceptions.HTTPError as errh:
print('Http Error: {}'.format(errh)) LOGGER.error('Http Error: %s', errh)
except requests.exceptions.ConnectionError as errc: except requests.exceptions.ConnectionError as errc:
print('Error Connecting: {}'.format(errc)) LOGGER.error('Error Connecting: %s', errc)
except requests.exceptions.Timeout as errt: except requests.exceptions.Timeout as errt:
print('Timeout Error: {}'.format(errt)) LOGGER.error('Timeout Error: %s', errt)
except requests.exceptions.RequestException as err: except requests.exceptions.RequestException as err:
print('Error: {}'.format(err)) LOGGER.error('Error: %s', err)
else: else:
req = json.loads(r.text) LOGGER.debug('Received JSON response: %s', resp.text)
song = req['song'] song_request = json.loads(resp.text)
song = song_request['song']
if song['song_type'] == 'J': if song['song_type'] == 'J':
artist = RADIO_NAME artist = RADIO_NAME
title = 'Jingle' title = 'Jingle'
@ -85,27 +99,86 @@ if args.command == 'next':
artist = beautify_artists(song['artists']) artist = beautify_artists(song['artists'])
title = clean_quotes(song['title']) title = clean_quotes(song['title'])
game = clean_quotes(song['game']) game = clean_quotes(song['game'])
print(ANNOTATE.format(req['id'], LOGGER.info(
song['song_type'], 'ID: %s, Artist[s]: %s, Title: %s, Game: %s, Gain: %s, Path: %s',
artist, song_request['id'],
title, artist,
game, title,
song['path'])) game,
elif args.command == 'played': song['replaygain'],
song['path']
)
annotate_string = ANNOTATE.format(
song_request['id'],
song['song_type'],
artist,
title,
game,
song['replaygain'],
song['path']
)
LOGGER.debug(annotate_string)
print(annotate_string)
def just_played(request_id):
'''
Sends an HTTP[S] request to the radio web service to let it know that a
song has been played.
'''
LOGGER.debug('Received command to report a song was just played.')
try: try:
req_played = json.dumps({'song_request': args.request[0]}) request_played = json.dumps({'song_request': request_id})
r = requests.post(API_URL + 'played/', resp = requests.post(
headers=HEADERS, API_URL + 'played/',
data=req_played, headers=HEADERS,
timeout=5) data=request_played,
r.encoding = 'utf-8' timeout=5
r.raise_for_status() )
resp.encoding = 'utf-8'
resp.raise_for_status()
except requests.exceptions.HTTPError as errh: except requests.exceptions.HTTPError as errh:
print('Http Error: {}'.format(errh)) LOGGER.error('Http Error: %s', errh)
print(r.text)
except requests.exceptions.ConnectionError as errc: except requests.exceptions.ConnectionError as errc:
print('Error Connecting: {}'.format(errc)) LOGGER.error('Error Connecting: %s', errc)
except requests.exceptions.Timeout as errt: except requests.exceptions.Timeout as errt:
print('Timeout Error: {}'.format(errt)) LOGGER.error('Timeout Error: %s', errt)
except requests.exceptions.RequestException as err: except requests.exceptions.RequestException as err:
print('Error: {}'.format(err)) LOGGER.error('Error: %s', err)
else:
LOGGER.info('Req_ID: %s', request_id)
def main():
'''Main loop of the program'''
description = 'Lets the DJ control the radio.'
parser = argparse.ArgumentParser(description=description)
subparsers = parser.add_subparsers(dest='command')
parser_next = subparsers.add_parser(
'next',
help='Gets the next song from the radio.'
)
parser_played = subparsers.add_parser(
'played',
help='Tells the radio which song just played.'
)
parser_played.add_argument(
'request',
help='Song request ID number.',
nargs=1,
type=int
)
args = parser.parse_args()
if args.command == 'next':
next_request()
elif args.command == 'played':
just_played(args.request[0])
if __name__ == '__main__':
main()

Binary file not shown.

View file

@ -0,0 +1,109 @@
'''
download_s3.py
This is the helper script that downloads songs from an Amazon S3 instance
(or other implementations, like DigialOcean Spaces). Currently used as a
workaround for a pipe-leaking issue with the "aws-cli" client.
'''
import argparse
import logging
import sys
import traceback
from decouple import config
import boto3
# If these four are not defined, then boto3 will look for defaults in the
# ~/.aws configurations
S3_REGION = config('S3_REGION', default=None)
S3_ENDPOINT = config('S3_ENDPOINT', default=None)
S3_ACCESS_KEY = config('S3_ACCESS_KEY', default=None)
S3_SECRET_KEY = config('S3_SECRET_KEY', default=None)
# Radio name for metadata
RADIO_NAME = config('RADIO_NAME', default='Save Point Radio')
logging.basicConfig(
handlers=[logging.FileHandler('./s3_downloads.log', encoding='utf8')],
level=logging.INFO,
format=('[%(asctime)s] [%(levelname)s]'
' [%(name)s.%(funcName)s] === %(message)s'),
datefmt='%Y-%m-%dT%H:%M:%S'
)
LOGGER = logging.getLogger('download_s3')
def download_file(s3path, filepath):
'''
Downloads a file from an S3 instance and saves it to a specified path.
'''
obj_parts = s3path[5:].split('/')
obj_bucket = obj_parts[0]
obj_key = '/'.join(obj_parts[1:])
session = boto3.session.Session()
client = session.client(
's3',
region_name=S3_REGION,
endpoint_url=S3_ENDPOINT,
aws_access_key_id=S3_ACCESS_KEY,
aws_secret_access_key=S3_SECRET_KEY
)
try:
client.download_file(obj_bucket, obj_key, filepath)
except Exception:
LOGGER.error(
'Download failed for: %s -- %s',
s3path,
traceback.print_exc()
)
result = 1
else:
LOGGER.info(
'Successful download of: %s to %s',
s3path,
filepath
)
result = 0
return result
def main():
'''Main loop of the program'''
description = 'Downloads songs from an Amazon S3 (or similar) instance.'
parser = argparse.ArgumentParser(description=description)
parser.add_argument(
's3path',
help='Path to the S3 object',
nargs=1
)
parser.add_argument(
'filepath',
help='Path to place the downloaded file',
nargs=1
)
if len(sys.argv) == 1:
sys.stderr.write('Error: please specify a command\n\n')
parser.print_help(sys.stderr)
sys.exit(1)
args = parser.parse_args()
if args.s3path and args.filepath:
result = download_file(args.s3path[0], args.filepath[0])
LOGGER.info('Program finished. Exiting.')
sys.exit(result)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,228 @@
'''
export_playlist.py
This is the helper script that exports old playlist databases to be reimported
by the new database later.
'''
import argparse
from decimal import Decimal, getcontext
import hashlib
import json
import mimetypes
import os
import sqlite3
import sys
import unicodedata
import magic
def scrub(text):
'''
Forcing a Unicode NFC normalization to remove combining marks that mess
with certain Python functions.
'''
if text:
return unicodedata.normalize('NFC', text)
return None
def detect_mime(path):
'''
Guess a file's mimetype from it's magic number. If inconclusive, then
guess based on it's file extension.
'''
mimetype = magic.from_file(path, mime=True)
if mimetype == 'application/octet-stream':
return mimetypes.guess_type(path, strict=True)[0]
return mimetype
def hash_file(path):
'''
Run a music file through a hashing algorithm (SHA3_256) and return the
hexidecimal digest.
'''
try:
with open(path, 'rb') as file:
filehash = hashlib.sha3_256(file.read()).hexdigest()
except OSError:
filehash = None
return filehash
def adapt_decimal(number):
'''Sqlite3 adapter for Decimal types'''
return str(number)
def convert_decimal(text):
'''Sqlite3 converter for Decimal types'''
return float(text.decode('utf8'))
def import_sqlite3(db_file):
'''
Imports a playlist from an SQLite3 database file and exports to a
JSON file.
'''
totals = {
'albums': 0,
'artists': 0,
'games': 0,
'songs': 0,
'jingles': 0
}
if not os.path.isfile(db_file):
raise FileNotFoundError
detect_types = sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES
con = sqlite3.connect(db_file, detect_types=detect_types)
cur = con.cursor()
# Fetching albums first
albums = list()
for album in con.execute('SELECT title, enabled FROM albums'):
albums.append({
'title': scrub(album[0]),
'disabled': not bool(album[1])
})
totals['albums'] += 1
print('Exported {} albums'.format(str(totals['albums'])))
# Next up, artists
artists = list()
artist_query = 'SELECT alias, firstname, lastname, enabled FROM artists'
for artist in con.execute(artist_query):
artists.append({
'alias': scrub(artist[0]) or '',
'first_name': scrub(artist[1]) or '',
'last_name': scrub(artist[2]) or '',
'disabled': not bool(artist[3])
})
totals['artists'] += 1
print('Exported {} artists'.format(str(totals['artists'])))
# On to games
games = list()
for game in con.execute('SELECT title, enabled FROM games'):
games.append({
'title': scrub(game[0]),
'disabled': not bool(game[1])
})
totals['games'] += 1
print('Exported {} games'.format(str(totals['games'])))
# Now the songs
songs = list()
songs_query = '''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)'''
cur.execute(songs_query)
old_songs = cur.fetchall()
for song in old_songs:
# Deal with the list of artists
song_artists = list()
song_artist_query = '''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 = ?)'''
cur.execute(song_artist_query, [song[0]])
song_artist_results = cur.fetchall()
for artist in song_artist_results:
song_artists.append({
'alias': scrub(artist[0]),
'first_name': scrub(artist[1]),
'last_name': scrub(artist[2])
})
store = {'path': scrub(song[7]),
'mime': detect_mime(scrub(song[7])),
'filesize': os.stat(scrub(song[7])).st_size,
'filehash': hash_file(scrub(song[7])),
'length': song[6]}
songs.append({'album': scrub(song[2]),
'artists': song_artists,
'game': scrub(song[1]),
'disabled': not bool(song[3]),
'type': song[4],
'title': scrub(song[5]),
'store': store})
if song[4] == 'S':
totals['songs'] += 1
else:
totals['jingles'] += 1
print('Exported {} requestables ({} songs, {} jingles)'.format(
str(totals['songs'] + totals['jingles']),
str(totals['songs']),
str(totals['jingles'])
))
return {'albums': albums,
'artists': artists,
'games': games,
'songs': songs}
def main():
'''Main loop of the program'''
getcontext().prec = 8
sqlite3.register_adapter(Decimal, adapt_decimal)
sqlite3.register_converter("decimal", convert_decimal)
description = 'Exports old playlist to a file for reimporting later.'
parser = argparse.ArgumentParser(description=description)
subparsers = parser.add_subparsers(dest='command')
parser_sqlite3 = subparsers.add_parser(
'sqlite3',
help='Imports old Sqlite3 database.'
)
parser_sqlite3.add_argument(
'db_file',
help='Path to the sqlite3 database file.',
nargs=1
)
if len(sys.argv) == 1:
sys.stderr.write('Error: please specify a command\n\n')
parser.print_help(sys.stderr)
sys.exit(1)
results = None
args = parser.parse_args()
if args.command == 'sqlite3':
results = import_sqlite3(args.db_file[0])
if results:
with open('playlist.json', 'w', encoding='utf8') as file:
json.dump(results,
file,
ensure_ascii=False,
sort_keys=True,
indent=4)
if __name__ == '__main__':
main()

View file

@ -0,0 +1 @@
python-magic>=0.4.15

View file

@ -0,0 +1,9 @@
boto3>=1.9.166
botocore>=1.12.166
docutils>=0.14
jmespath>=0.9.4
python-dateutil>=2.8.0
python-decouple>=3.1
s3transfer>=0.2.1
six>=1.12.0
urllib3>=1.25.3

View file

@ -0,0 +1,234 @@
'''
upload_s3.py
This is the helper script that uploads songs from an exported playlist into
an Amazon S3 instance (or other implementations, like DigialOcean Spaces).
'''
import argparse
import json
import logging
import os
import sys
import threading
import traceback
from unicodedata import normalize
from decouple import config
import boto3
# If these four are not defined, then boto3 will look for defaults in the
# ~/.aws configurations
S3_REGION = config('S3_REGION', default=None)
S3_ENDPOINT = config('S3_ENDPOINT', default=None)
S3_ACCESS_KEY = config('S3_ACCESS_KEY', default=None)
S3_SECRET_KEY = config('S3_SECRET_KEY', default=None)
# This has to be defined regardless.
S3_BUCKET = config('S3_BUCKET')
# Radio name for metadata
RADIO_NAME = config('RADIO_NAME', default='Save Point Radio')
logging.basicConfig(
handlers=[logging.FileHandler('./s3_uploads.log', encoding='utf8')],
level=logging.INFO,
format=('[%(asctime)s] [%(levelname)s]'
' [%(name)s.%(funcName)s] === %(message)s'),
datefmt='%Y-%m-%dT%H:%M:%S'
)
LOGGER = logging.getLogger('upload_s3')
class Progress(object):
'''
A callback class for the Amazon S3 upload to detect how far along in an
upload we are.
'''
def __init__(self, filepath):
self._filepath = filepath
self._filename = os.path.basename(filepath)
self._size = float(os.path.getsize(filepath))
self._seen_so_far = 0
self._lock = threading.Lock()
def __call__(self, bytes_amount):
with self._lock:
self._seen_so_far += bytes_amount
percentage = (self._seen_so_far / self._size) * 100
sys.stdout.write(
"\r%s %s / %s (%.2f%%)" % (
self._filename, self._seen_so_far, self._size,
percentage
)
)
sys.stdout.flush()
def asciify(text):
'''
Converts a unicode string to pure ascii.
'''
normal = normalize('NFKC', text)
return normal.encode('ascii', 'backslashreplace').decode('ascii')
def get_fullname(artist):
'''
String representing the artist's full name including an alias, if
available.
'''
if artist['alias']:
if artist['first_name'] or artist['last_name']:
return '{} "{}" {}'.format(artist['first_name'],
artist['alias'],
artist['last_name'])
return artist['alias']
return '{} {}'.format(artist['first_name'], artist['last_name'])
def beautify_artists(artists):
'''
Turns a list of one or more artists into a proper English listing.
'''
fullnames = [get_fullname(artist) for artist in artists]
output = ', '
if len(fullnames) == 2:
output = ' & '
return output.join(fullnames)
def import_playlist(playlist_file):
'''
Imports a playlist from a JSON file, uploads the files to an S3[-like]
instance, and exports a new JSON file with the updated paths.
'''
if not os.path.isfile(playlist_file):
raise FileNotFoundError
with open(playlist_file, 'r', encoding='utf8') as pfile:
playlist = json.load(pfile)
session = boto3.session.Session()
client = session.client(
's3',
region_name=S3_REGION,
endpoint_url=S3_ENDPOINT,
aws_access_key_id=S3_ACCESS_KEY,
aws_secret_access_key=S3_SECRET_KEY
)
totals = {'success': 0, 'fail': 0}
for song in playlist['songs']:
old_path = song['store']['path']
if song['type'] == 'S':
prefix = 'songs'
metadata = {
'album': asciify(song['album']),
'artists': asciify(beautify_artists(song['artists'])),
'game': asciify(song['game']),
'title': asciify(song['title']),
'length': str(song['store']['length']),
'original-path': asciify(old_path)
}
else:
prefix = 'jingles'
metadata = {
'artists': asciify(RADIO_NAME),
'title': asciify(song['title']),
'length': str(song['store']['length']),
'original-path': asciify(old_path)
}
file_hash = song['store']['filehash']
ext = os.path.splitext(old_path)[1]
new_path = '{}/{}{}'.format(prefix, file_hash, ext)
LOGGER.info('Begin upload of: %s', old_path)
try:
client.upload_file(
old_path,
S3_BUCKET,
new_path,
ExtraArgs={
'Metadata': metadata,
'ContentType': song['store']['mime']
},
Callback=Progress(old_path)
)
except Exception:
LOGGER.error(
'Upload failed for: %s -- %s',
old_path,
traceback.print_exc()
)
totals['fail'] += 1
else:
song['store']['path'] = 's3://{}/{}'.format(S3_BUCKET, new_path)
LOGGER.info(
'Successful upload of: %s to %s',
old_path,
song['store']['path']
)
totals['success'] += 1
sys.stdout.write("\r\n")
sys.stdout.flush()
result_message = 'Uploads complete -- {} successful, {} failures'.format(
totals['success'],
totals['fail']
)
print(result_message)
LOGGER.info(result_message)
return playlist
def main():
'''Main loop of the program'''
description = 'Uploads song files to an Amazon S3 (or similar) instance.'
parser = argparse.ArgumentParser(description=description)
subparsers = parser.add_subparsers(dest='command')
parser_playlist = subparsers.add_parser(
'playlist',
help='Import playlist song data.'
)
parser_playlist.add_argument(
'filepath',
help='Path to the playlist file.',
nargs=1
)
if len(sys.argv) == 1:
sys.stderr.write('Error: please specify a command\n\n')
parser.print_help(sys.stderr)
sys.exit(1)
results = None
args = parser.parse_args()
if args.command == 'playlist':
results = import_playlist(args.filepath[0])
if results:
LOGGER.info('Exporting new playlist file to \'playlist_s3.json\'')
with open('playlist_s3.json', 'w', encoding='utf8') as file:
json.dump(
results,
file,
ensure_ascii=False,
sort_keys=True,
indent=4
)
LOGGER.info('Program finished. Exiting.')
if __name__ == '__main__':
main()

Binary file not shown.

View file

@ -1,10 +1,12 @@
argon2-cffi>=18.3.0 argon2-cffi>=19.1.0
cffi>=1.11.5 cffi>=1.12.3
dj-database-url>=0.5.0 dj-database-url>=0.5.0
Django>=2.1.5 Django>=2.2.2
django-authtools>=1.6.0 django-authtools>=1.6.0
djangorestframework>=3.9.0 djangorestframework>=3.9.4
psycopg2>=2.7.6.1 psycopg2-binary>=2.8.2
pycparser>=2.19 pycparser>=2.19
python-decouple>=3.1 python-decouple>=3.1
pytz>=2018.9 pytz>=2019.1
six>=1.12.0
sqlparse>=0.3.0

View file

@ -1,7 +1,6 @@
from rest_framework.serializers import (IntegerField, from rest_framework.serializers import (IntegerField,
ModelSerializer, ModelSerializer,
Serializer, Serializer)
StringRelatedField)
from profiles.models import SongRequest from profiles.models import SongRequest
from .radio import RadioSongSerializer from .radio import RadioSongSerializer

View file

@ -4,7 +4,7 @@ from rest_framework.serializers import (IntegerField, ModelSerializer,
Serializer) Serializer)
from profiles.models import RadioProfile, SongRequest, Rating from profiles.models import RadioProfile, SongRequest, Rating
from .radio import BasicSongRetrieveSerializer from .radio import SongMinimalSerializer
User = get_user_model() User = get_user_model()
@ -44,7 +44,7 @@ class RateSongSerializer(Serializer):
class HistorySerializer(ModelSerializer): class HistorySerializer(ModelSerializer):
profile = BasicProfileSerializer() profile = BasicProfileSerializer()
song = BasicSongRetrieveSerializer() song = SongMinimalSerializer()
class Meta: class Meta:
model = SongRequest model = SongRequest
@ -52,7 +52,7 @@ class HistorySerializer(ModelSerializer):
class BasicProfileRatingsSerializer(ModelSerializer): class BasicProfileRatingsSerializer(ModelSerializer):
song = BasicSongRetrieveSerializer() song = SongMinimalSerializer()
class Meta: class Meta:
model = Rating model = Rating

View file

@ -1,71 +1,155 @@
from rest_framework.serializers import (IntegerField, ListField, from rest_framework.serializers import (BooleanField, CharField, DecimalField,
IntegerField, ListField,
ModelSerializer, Serializer, ModelSerializer, Serializer,
SerializerMethodField,
StringRelatedField) StringRelatedField)
from radio.models import Album, Artist, Game, Song from core.utils import iri_to_path
from radio.models import Album, Artist, Game, Song, Store
class AlbumSerializer(ModelSerializer): class AlbumSerializer(ModelSerializer):
'''A base serializer for an album model.'''
class Meta: class Meta:
model = Album model = Album
fields = ('id', 'title') fields = ('id', 'title')
class ArtistSerializer(ModelSerializer): class ArtistSerializer(ModelSerializer):
'''A base serializer for an artist model.'''
class Meta: class Meta:
model = Artist model = Artist
fields = ('id', 'alias', 'first_name', 'last_name') fields = ('id', 'alias', 'first_name', 'last_name')
class ArtistFullnameSerializer(ModelSerializer): class ArtistFullnameSerializer(ModelSerializer):
'''
A base serializer for an artist model, but combining all name
attributes into one field.
'''
class Meta: class Meta:
model = Artist model = Artist
fields = ('id', 'full_name') fields = ('id', 'full_name')
class GameSerializer(ModelSerializer): class GameSerializer(ModelSerializer):
'''A base serializer for a game model.'''
class Meta: class Meta:
model = Game model = Game
fields = ('id', 'title') fields = ('id', 'title')
class BasicSongSerializer(ModelSerializer): class StoreSerializer(ModelSerializer):
'''A base serializer for a data store model.'''
active = SerializerMethodField()
class Meta: class Meta:
model = Song model = Store
fields = ('id', 'album', 'artists', 'game', 'title', 'average_rating', fields = ('id', 'active', 'iri', 'file_size', 'length', 'mime_type')
'is_requestable')
def get_active(self, obj):
'''Checks to see if this store is active for a song.'''
if obj.active_for.all():
return True
return False
class FullSongSerializer(ModelSerializer): class SongSerializer(ModelSerializer):
'''A base serializer for a song model.'''
length = DecimalField(
max_digits=10,
decimal_places=2,
source='active_store.length'
)
class Meta: class Meta:
model = Song model = Song
fields = ('id', 'album', 'artists', 'published_date', 'game', fields = ('id', 'album', 'artists', 'published_date', 'game',
'num_played', 'last_played', 'length', 'song_type', 'title', 'num_played', 'last_played', 'length', 'next_play',
'average_rating', 'is_requestable') 'song_type', 'title', 'average_rating', 'is_requestable')
class BasicSongRetrieveSerializer(BasicSongSerializer): class SongMinimalSerializer(ModelSerializer):
'''Minimal song information, usually appended to favorites/ratings.'''
album = AlbumSerializer() album = AlbumSerializer()
artists = ArtistFullnameSerializer(many=True) artists = ArtistFullnameSerializer(many=True)
game = GameSerializer() game = GameSerializer()
class Meta:
model = Song
fields = ('id', 'album', 'artists', 'game', 'title')
class FullSongRetrieveSerializer(FullSongSerializer):
class SongListSerializer(ModelSerializer):
'''Song information used in large listings.'''
album = AlbumSerializer()
artists = ArtistFullnameSerializer(many=True)
game = GameSerializer()
length = DecimalField(
max_digits=10,
decimal_places=2,
source='active_store.length'
)
class Meta:
model = Song
fields = ('id', 'album', 'artists', 'game', 'title', 'average_rating',
'length', 'is_requestable')
class SongRetrieveSerializer(SongSerializer):
'''
An almost complete listing of a song's information, based on a single
object retrieval.
'''
album = AlbumSerializer() album = AlbumSerializer()
artists = ArtistSerializer(many=True) artists = ArtistSerializer(many=True)
game = GameSerializer() game = GameSerializer()
class RadioSongSerializer(ModelSerializer): class RadioSongSerializer(ModelSerializer):
'''
A song serializer that is specific to the radio DJ and the underlying
audio manipulation application.
'''
album = StringRelatedField() album = StringRelatedField()
artists = StringRelatedField(many=True) artists = StringRelatedField(many=True)
game = StringRelatedField() game = StringRelatedField()
length = DecimalField(
max_digits=10,
decimal_places=2,
source='active_store.length'
)
replaygain = CharField(source='active_store.replaygain')
path = SerializerMethodField()
class Meta: class Meta:
model = Song model = Song
fields = ('album', 'artists', 'game', 'song_type', 'title', 'length', fields = ('album', 'artists', 'game', 'song_type', 'title', 'length',
'path') 'replaygain', 'path')
def get_path(self, obj):
'''Converts the IRI into a filesystem path.'''
iri = str(obj.active_store.iri)
if iri.startswith('file://'):
return iri_to_path(iri)
return iri
class SongArtistsListSerializer(Serializer): class SongArtistsListSerializer(Serializer):
'''
A serializer for adding or removing artists from a song based on
the song's id number.
'''
# TODO: Probably should move to PrimaryKeyRelatedField.
artists = ListField(child=IntegerField(), min_length=1, max_length=10) artists = ListField(child=IntegerField(), min_length=1, max_length=10)
class SongStoresSerializer(Serializer):
'''
A serializer for adding or removing a data store from a song based on
the song's id number.
'''
# TODO: Probably should move to PrimaryKeyRelatedField.
store = IntegerField()
set_active = BooleanField(default=False)

View file

@ -4,8 +4,8 @@ from rest_framework.routers import DefaultRouter
from api.views.controls import JustPlayed, MakeRequest, NextRequest from api.views.controls import JustPlayed, MakeRequest, NextRequest
from api.views.profiles import HistoryViewSet, ProfileViewSet from api.views.profiles import HistoryViewSet, ProfileViewSet
from api.views.radio import (AlbumViewSet, ArtistViewSet, from api.views.radio import (AlbumViewSet, ArtistViewSet, GameViewSet,
GameViewSet, SongViewSet) StoreViewSet, SongViewSet)
class OptionalSlashRouter(DefaultRouter): class OptionalSlashRouter(DefaultRouter):
@ -28,6 +28,7 @@ router.register(r'profiles', ProfileViewSet, base_name='profile')
router.register(r'albums', AlbumViewSet, base_name='album') router.register(r'albums', AlbumViewSet, base_name='album')
router.register(r'artists', ArtistViewSet, base_name='artist') router.register(r'artists', ArtistViewSet, base_name='artist')
router.register(r'games', GameViewSet, base_name='game') router.register(r'games', GameViewSet, base_name='game')
router.register(r'stores', StoreViewSet, base_name='store')
router.register(r'songs', SongViewSet, base_name='song') router.register(r'songs', SongViewSet, base_name='song')
urlpatterns = [ urlpatterns = [

View file

@ -11,7 +11,7 @@ from ..serializers.profiles import (BasicProfileSerializer,
FullProfileSerializer, FullProfileSerializer,
HistorySerializer, HistorySerializer,
BasicProfileRatingsSerializer) BasicProfileRatingsSerializer)
from ..serializers.radio import BasicSongRetrieveSerializer from ..serializers.radio import SongListSerializer
class ProfileViewSet(viewsets.ModelViewSet): class ProfileViewSet(viewsets.ModelViewSet):
@ -52,10 +52,10 @@ class ProfileViewSet(viewsets.ModelViewSet):
page = self.paginate_queryset(favorites) page = self.paginate_queryset(favorites)
if page is not None: if page is not None:
serializer = BasicSongRetrieveSerializer(page, many=True) serializer = SongListSerializer(page, many=True)
return self.get_paginated_response(serializer.data) return self.get_paginated_response(serializer.data)
serializer = BasicSongRetrieveSerializer(favorites, many=True) serializer = SongListSerializer(favorites, many=True)
return Response(serializer.data) return Response(serializer.data)
@action(detail=True, permission_classes=[AllowAny]) @action(detail=True, permission_classes=[AllowAny])

View file

@ -4,15 +4,17 @@ from rest_framework.permissions import AllowAny, IsAdminUser
from rest_framework.response import Response from rest_framework.response import Response
from profiles.models import RadioProfile, Rating from profiles.models import RadioProfile, Rating
from radio.models import Album, Artist, Game, Song from radio.models import Album, Artist, Game, Song, Store
from ..permissions import IsAdminOrReadOnly, IsAuthenticatedAndNotDJ from ..permissions import IsAdminOrReadOnly, IsAuthenticatedAndNotDJ
from ..serializers.profiles import (BasicProfileSerializer, from ..serializers.profiles import (BasicProfileSerializer,
BasicSongRatingsSerializer, BasicSongRatingsSerializer,
RateSongSerializer) RateSongSerializer)
from ..serializers.radio import (AlbumSerializer, ArtistSerializer, from ..serializers.radio import (AlbumSerializer, ArtistSerializer,
GameSerializer, FullSongSerializer, GameSerializer, StoreSerializer,
SongSerializer, SongListSerializer,
SongRetrieveSerializer,
SongArtistsListSerializer, SongArtistsListSerializer,
FullSongRetrieveSerializer) SongStoresSerializer)
class AlbumViewSet(viewsets.ModelViewSet): class AlbumViewSet(viewsets.ModelViewSet):
@ -63,6 +65,12 @@ class GameViewSet(viewsets.ModelViewSet):
return Game.music.available() return Game.music.available()
class StoreViewSet(viewsets.ModelViewSet):
queryset = Store.objects.all()
permission_classes = [IsAdminUser]
serializer_class = StoreSerializer
class SongViewSet(viewsets.ModelViewSet): class SongViewSet(viewsets.ModelViewSet):
permission_classes = [IsAdminOrReadOnly] permission_classes = [IsAdminOrReadOnly]
@ -83,38 +91,99 @@ class SongViewSet(viewsets.ModelViewSet):
(Thanks to https://stackoverflow.com/questions/22616973/) (Thanks to https://stackoverflow.com/questions/22616973/)
''' '''
if self.action in ['list', 'retrieve']: if self.action == 'list':
return FullSongRetrieveSerializer return SongListSerializer
return FullSongSerializer if self.action == 'retrieve':
return SongRetrieveSerializer
return SongSerializer
def _artists_change(self, request, remove=False): def _artists_change(self, request, remove=False):
song = self.get_object() song = self.get_object()
serializer = SongArtistsListSerializer(data=request.data) serializer = SongArtistsListSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
artists = Artist.objects.filter(pk__in=serializer.data['artists']) artists = Artist.objects.filter(pk__in=serializer.data['artists'])
for artist in artists: for artist in artists:
if remove: if remove:
song.artists.remove(artist) song.artists.remove(artist)
else: else:
song.artists.add(artist) song.artists.add(artist)
song.save() song.save()
if song.artists.count() == 0:
song.disable('No artists specified for song.')
message = 'Artists {} song.'.format(('added to', message = 'Artists {} song.'.format(('added to',
'removed from')[remove]) 'removed from')[remove])
return Response({'detail': message}) return Response({'detail': message})
else: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(serializer.errors,
status=status.HTTP_400_BAD_REQUEST)
@action(methods=['post'], detail=True, permission_classes=[IsAdminUser]) @action(methods=['post'], detail=True, permission_classes=[IsAdminUser])
def artists_add(self, request, pk=None): def artists_add(self, request, pk=None):
'''Adds an artist to a song.'''
return self._artists_change(request) return self._artists_change(request)
@action(methods=['post'], detail=True, permission_classes=[IsAdminUser]) @action(methods=['post'], detail=True, permission_classes=[IsAdminUser])
def artists_remove(self, request, pk=None): def artists_remove(self, request, pk=None):
'''Removes an artist from a song.'''
return self._artists_change(request, remove=True) return self._artists_change(request, remove=True)
def _store_change(self, request, remove=False):
song = self.get_object()
serializer = SongStoresSerializer(data=request.data)
if serializer.is_valid():
try:
store = Store.objects.get(pk=serializer.data['store'])
except Store.DoesNotExist:
return Response({'detail': 'Store does not exist.'},
status=status.HTTP_400_BAD_REQUEST)
if remove:
song.stores.remove(store)
else:
song.stores.add(store)
if serializer.data['set_active'] and not remove:
song.active_store = store
song.save()
if song.stores.count() == 0:
song.disable('No stores specified for song.')
message = 'Store {} song.'.format(('added to',
'removed from')[remove])
return Response({'detail': message})
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@action(methods=['post'], detail=True, permission_classes=[IsAdminUser])
def store_add(self, request, pk=None):
'''Adds a data store to a song.'''
return self._store_change(request)
@action(methods=['post'], detail=True, permission_classes=[IsAdminUser])
def store_remove(self, request, pk=None):
'''Removes a data store from a song.'''
return self._store_change(request, remove=True)
@action(detail=True, permission_classes=[IsAdminUser])
def stores(self, request, pk=None):
'''Get a list of data stores associate with this song.'''
song = self.get_object()
stores = song.stores.all().order_by('-created_date')
page = self.paginate_queryset(stores)
if page is not None:
serializer = StoreSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = StoreSerializer(stores, many=True)
return Response(serializer.data)
@action(detail=True, permission_classes=[AllowAny]) @action(detail=True, permission_classes=[AllowAny])
def favorites(self, request, pk=None): def favorites(self, request, pk=None):
'''Get a list of users who added this song to their favorites list.'''
song = self.get_object() song = self.get_object()
profiles = song.song_favorites.all().order_by('user__name') profiles = song.song_favorites.all().order_by('user__name')
@ -130,6 +199,7 @@ class SongViewSet(viewsets.ModelViewSet):
detail=True, detail=True,
permission_classes=[IsAuthenticatedAndNotDJ]) permission_classes=[IsAuthenticatedAndNotDJ])
def favorite(self, request, pk=None): def favorite(self, request, pk=None):
'''Add a song to the user's favorites list.'''
song = self.get_object() song = self.get_object()
profile = RadioProfile.objects.get(user=request.user) profile = RadioProfile.objects.get(user=request.user)
if song not in profile.favorites.all(): if song not in profile.favorites.all():
@ -144,6 +214,7 @@ class SongViewSet(viewsets.ModelViewSet):
detail=True, detail=True,
permission_classes=[IsAuthenticatedAndNotDJ]) permission_classes=[IsAuthenticatedAndNotDJ])
def unfavorite(self, request, pk=None): def unfavorite(self, request, pk=None):
'''Remove a song from the user's favorites list.'''
song = self.get_object() song = self.get_object()
profile = RadioProfile.objects.get(user=request.user) profile = RadioProfile.objects.get(user=request.user)
if song in profile.favorites.all(): if song in profile.favorites.all():
@ -157,6 +228,7 @@ class SongViewSet(viewsets.ModelViewSet):
@action(detail=True, permission_classes=[AllowAny]) @action(detail=True, permission_classes=[AllowAny])
def ratings(self, request, pk=None): def ratings(self, request, pk=None):
'''Get a list of a song's ratings.'''
song = self.get_object() song = self.get_object()
ratings = song.rating_set.all().order_by('-created_date') ratings = song.rating_set.all().order_by('-created_date')
@ -172,6 +244,7 @@ class SongViewSet(viewsets.ModelViewSet):
detail=True, detail=True,
permission_classes=[IsAuthenticatedAndNotDJ]) permission_classes=[IsAuthenticatedAndNotDJ])
def rate(self, request, pk=None): def rate(self, request, pk=None):
'''Add a user's rating to a song.'''
serializer = RateSongSerializer(data=request.data) serializer = RateSongSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
song = self.get_object() song = self.get_object()
@ -195,6 +268,7 @@ class SongViewSet(viewsets.ModelViewSet):
detail=True, detail=True,
permission_classes=[IsAuthenticatedAndNotDJ]) permission_classes=[IsAuthenticatedAndNotDJ])
def unrate(self, request, pk=None): def unrate(self, request, pk=None):
'''Remove a user's rating from a song.'''
song = self.get_object() song = self.get_object()
profile = RadioProfile.objects.get(user=request.user) profile = RadioProfile.objects.get(user=request.user)
rating = song.rating_set.filter(profile=profile) rating = song.rating_set.filter(profile=profile)

View file

@ -1,6 +1,6 @@
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
class Disableable(models.Model): class Disableable(models.Model):

View file

@ -1,5 +1,5 @@
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from authtools.models import AbstractNamedUser from authtools.models import AbstractNamedUser

View file

@ -1,25 +1,59 @@
'''
Various utlity functions that are independant of any Django app or
model.
'''
from nturl2path import pathname2url as ntpathname2url
from nturl2path import url2pathname as url2ntpathname
import random import random
import re import re
import string import string
from unicodedata import normalize from unicodedata import normalize
from urllib.parse import urljoin, urlparse
from urllib.request import pathname2url, url2pathname
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db import connection from django.db import connection
from django.utils.encoding import iri_to_uri, uri_to_iri
from .models import Setting from .models import Setting
GROUP_NT_UNC = r'file://[A-Za-z0-9!@#$%^&\'\)\(\.\-_{}~]+/'
GROUP_NT_DRIVE_LETTER = r'file:///[A-Za-z](?:\:|\|)/'
GROUP_NON_AUTH = r'file:///[A-Za-z0-9!@#$%^&\'\)\(\.\-_{}~]+'
FILE_IRI_PATTERN = (
r'^(?P<unc>' +
GROUP_NT_UNC +
r')|(?P<driveletter>' +
GROUP_NT_DRIVE_LETTER +
r')|(?P<nonauth>' +
GROUP_NON_AUTH +
r')'
)
def generate_password(length=32): def generate_password(length=32):
'''
Quick and dirty random password generator.
***WARNING*** - Although this is likely "good enough" to create a secure
password, there are no validations (suitible entropy, dictionary words,
etc.) and should not be completely trusted. YOU HAVE BEEN WARNED.
'''
chars = string.ascii_letters + string.digits + string.punctuation chars = string.ascii_letters + string.digits + string.punctuation
rng = random.SystemRandom() rng = random.SystemRandom()
return ''.join([rng.choice(chars) for i in range(length)]) return ''.join([rng.choice(chars) for i in range(length)])
def get_len(rawqueryset): def get_len(rawqueryset):
""" '''
Adds/Overrides a dynamic implementation of the length protocol to the Adds/Overrides a dynamic implementation of the length protocol to the
definition of RawQuerySet. definition of RawQuerySet.
""" '''
def __len__(self): def __len__(self):
params = ['{}'.format(p) for p in self.params] params = ['{}'.format(p) for p in self.params]
sql = ''.join(('SELECT COUNT(*) FROM (', sql = ''.join(('SELECT COUNT(*) FROM (',
@ -33,11 +67,13 @@ def get_len(rawqueryset):
def get_setting(name): def get_setting(name):
'''Helper function to get dynamic settings from the database.'''
setting = Setting.objects.get(name=name) setting = Setting.objects.get(name=name)
return setting.get() return setting.get()
def set_setting(name, value, setting_type=None): def set_setting(name, value, setting_type=None):
'''Helper function to set dynamic settings from the database.'''
setting_types = {'Integer': 0, 'Float': 1, 'String': 2, 'Bool': 3} setting_types = {'Integer': 0, 'Float': 1, 'String': 2, 'Bool': 3}
try: try:
setting = Setting.objects.get(name=name) setting = Setting.objects.get(name=name)
@ -57,13 +93,13 @@ def set_setting(name, value, setting_type=None):
def naturalize(text): def naturalize(text):
""" '''
Return a normalized unicode string, with removed starting articles, for use Return a normalized unicode string, with removed starting articles, for use
in natural sorting. in natural sorting.
Code was inspired by 'django-naturalsortfield' from Nathan Reynolds: Code was inspired by 'django-naturalsortfield' from Nathan Reynolds:
https://github.com/nathforge/django-naturalsortfield https://github.com/nathforge/django-naturalsortfield
""" '''
def naturalize_int_match(match): def naturalize_int_match(match):
return '{:08d}'.format(int(match.group(0))) return '{:08d}'.format(int(match.group(0)))
@ -79,9 +115,9 @@ def naturalize(text):
def quantify(quantity, model): def quantify(quantity, model):
""" '''
A message based on the quantity and singular/plural name of the model. A message based on the quantity and singular/plural name of the model.
""" '''
if quantity == 1: if quantity == 1:
message = '1 {}'.format(model._meta.verbose_name) message = '1 {}'.format(model._meta.verbose_name)
else: else:
@ -92,21 +128,20 @@ def quantify(quantity, model):
def create_success_message(parent_model, parent_quantity, child_model, def create_success_message(parent_model, parent_quantity, child_model,
child_quantity, remove=False): child_quantity, remove=False):
""" '''
Creates a message for displaying the success of model modification. Creates a message for displaying the success of model modification.
""" '''
p_message = quantify(parent_quantity, parent_model) p_message = quantify(parent_quantity, parent_model)
c_message = quantify(child_quantity, child_model) c_message = quantify(child_quantity, child_model)
if remove: if remove:
return '{} successfully removed from {}'.format(c_message, p_message) return '{} successfully removed from {}'.format(c_message, p_message)
else: return '{} successfully added to {}.'.format(c_message, p_message)
return '{} successfully added to {}.'.format(c_message, p_message)
def get_pretty_time(seconds): def get_pretty_time(seconds):
""" '''
Displays a human-readable representation of time. Displays a human-readable representation of time.
""" '''
if seconds > 0: if seconds > 0:
periods = [ periods = [
('year', 60*60*24*365.25), ('year', 60*60*24*365.25),
@ -123,5 +158,36 @@ def get_pretty_time(seconds):
period_name, period_name,
('s', '')[period_value == 1])) ('s', '')[period_value == 1]))
return ', '.join(strings) return ', '.join(strings)
else: return 'Now'
return 'Now'
def path_to_iri(path):
'''
OS-independant attempt at converting any OS absolute path to an
RFC3987-defined IRI along with the file scheme from RFC8089.
'''
# Looking to see if the path starts with a drive letter or UNC path
# (eg. 'D:\' or '\\')
windows = re.match(r'^(?:[A-Za-z]:|\\)\\', path)
if windows:
return uri_to_iri(urljoin('file:', ntpathname2url(path)))
return uri_to_iri(urljoin('file:', pathname2url(path)))
def iri_to_path(iri):
'''
OS-independant attempt at converting an RFC3987-defined IRI with a file
scheme from RFC8089 to an OS-specific absolute path.
'''
# Drive letter IRI will have three slashes followed by the drive letter
# UNC path IRI will have two slashes followed by the UNC path
uri = iri_to_uri(iri)
patt = r'^(?:' + GROUP_NT_DRIVE_LETTER + r'|' + GROUP_NT_UNC + r')'
windows = re.match(patt, uri)
if windows:
parse = urlparse(uri)
# UNC path URIs put the server name in the 'netloc' parameter.
if parse.netloc:
return '\\' + url2ntpathname('/' + parse.netloc + parse.path)
return url2ntpathname(parse.path)
return url2pathname(urlparse(uri).path)

View file

@ -0,0 +1,43 @@
'''
Custom Django model/form field validators for the Save Point Radio project.
'''
import re
from django.core import validators
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from .utils import FILE_IRI_PATTERN
class RadioIRIValidator(validators.URLValidator):
'''
Validates an RFC3987-defined IRI along with RFC8089 for file:// and other
custom schemes.
'''
message = _('Enter a valid IRI.')
schemes = ['http', 'https', 'file', 'ftp', 'ftps', 's3']
def __init__(self, schemes=None, **kwargs):
super().__init__(**kwargs)
if schemes is not None:
self.schemes = schemes
def __call__(self, value):
# Check the schemes first
scheme = value.split('://')[0].lower()
if scheme not in self.schemes:
raise ValidationError(self.message, code=self.code)
# Ignore the non-standard IRI
if scheme == 'file':
pattern = re.compile(FILE_IRI_PATTERN)
if not pattern.match(value):
raise ValidationError(self.message, code=self.code)
elif scheme == 's3':
# Nothing to validate, really. . .
return
else:
super().__call__(value)

View file

@ -40,7 +40,30 @@ class ProfileAdmin(admin.ModelAdmin):
@admin.register(SongRequest) @admin.register(SongRequest)
class RequestAdmin(admin.ModelAdmin): class RequestAdmin(admin.ModelAdmin):
model = SongRequest # Detail List display
list_display = ('get_user',
'song',
'created_date',
'queued_at',
'played_at')
search_fields = ['song', 'profile']
# Edit Form display
readonly_fields = (
'created_date',
'modified_date',
'profile',
'song',
'queued_at',
'played_at'
)
verbose_name = 'request' verbose_name = 'request'
verbose_name_plural = 'requests' verbose_name_plural = 'requests'
extra = 0 extra = 0
def get_user(self, obj):
'''Returns the username from the profile.'''
return obj.profile.user
get_user.admin_order_field = 'profile'
get_user.short_description = 'User Name'

View file

@ -2,7 +2,7 @@ from django.conf import settings
from django.core.validators import (MaxLengthValidator, MinValueValidator, from django.core.validators import (MaxLengthValidator, MinValueValidator,
MaxValueValidator) MaxValueValidator)
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.behaviors import Disableable, Timestampable from core.behaviors import Disableable, Timestampable
from core.utils import get_setting from core.utils import get_setting

View file

@ -3,7 +3,7 @@ from django.db import models
from django.forms import TextInput from django.forms import TextInput
from .actions import change_items, publish_items, remove_items from .actions import change_items, publish_items, remove_items
from .models import Album, Artist, Game, Song from .models import Album, Artist, Game, Song, Store
class ArtistInline(admin.TabularInline): class ArtistInline(admin.TabularInline):
@ -13,6 +13,13 @@ class ArtistInline(admin.TabularInline):
extra = 0 extra = 0
class StoreInline(admin.TabularInline):
model = Song.stores.through
verbose_name = 'data store'
verbose_name_plural = 'data stores'
extra = 0
@admin.register(Album) @admin.register(Album)
class AlbumAdmin(admin.ModelAdmin): class AlbumAdmin(admin.ModelAdmin):
# Detail List display # Detail List display
@ -101,6 +108,34 @@ class GameAdmin(admin.ModelAdmin):
publish_games.short_description = "Publish selected games" publish_games.short_description = "Publish selected games"
@admin.register(Store)
class StoreAdmin(admin.ModelAdmin):
# Detail List display
list_display = ('iri',
'mime_type',
'file_size',
'length',
'_replaygain')
search_fields = ['iri']
# Edit Form display
readonly_fields = ('created_date', 'modified_date')
fieldsets = (
('Main', {
'fields': ('iri',
'mime_type',
'file_size',
'length',
'track_gain',
'track_peak')
}),
('Stats', {
'classes': ('collapse',),
'fields': ('created_date', 'modified_date')
})
)
@admin.register(Song) @admin.register(Song)
class SongAdmin(admin.ModelAdmin): class SongAdmin(admin.ModelAdmin):
formfield_overrides = { formfield_overrides = {
@ -123,8 +158,7 @@ class SongAdmin(admin.ModelAdmin):
# Edit Form display # Edit Form display
exclude = ('artists',) exclude = ('artists',)
readonly_fields = ('length', readonly_fields = ('last_played',
'last_played',
'num_played', 'num_played',
'created_date', 'created_date',
'modified_date', 'modified_date',
@ -137,8 +171,8 @@ class SongAdmin(admin.ModelAdmin):
('Main', { ('Main', {
'fields': ('song_type', 'fields': ('song_type',
'title', 'title',
'path', 'published_date',
'published_date') 'active_store')
}), }),
('Stats', { ('Stats', {
'classes': ('collapse',), 'classes': ('collapse',),
@ -146,8 +180,7 @@ class SongAdmin(admin.ModelAdmin):
'modified_date', 'modified_date',
'last_played', 'last_played',
'num_played', 'num_played',
'next_play', 'next_play')
'length')
}), }),
('Album', { ('Album', {
'fields': ('album',) 'fields': ('album',)
@ -156,7 +189,14 @@ class SongAdmin(admin.ModelAdmin):
'fields': ('game',) 'fields': ('game',)
}) })
) )
inlines = [ArtistInline] inlines = [ArtistInline, StoreInline]
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == 'active_store':
kwargs['queryset'] = Store.objects.filter(
song__pk=request.resolver_match.kwargs['object_id']
)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
def artist_list(self, obj): def artist_list(self, obj):
return ', '.join([a.full_name for a in obj.artists.all()]) return ', '.join([a.full_name for a in obj.artists.all()])

View file

@ -1,6 +1,6 @@
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
class Disableable(models.Model): class Disableable(models.Model):

View file

@ -0,0 +1,41 @@
'''
Custom model fields for the Save Point Radio project.
'''
from django.db import models
from django.utils.translation import gettext_lazy as _
from core.validators import RadioIRIValidator
from .forms import 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 = [RadioIRIValidator()]
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,20 @@
'''
Custom forms/formfields for the Save Point Radio project.
'''
from django.forms.fields import URLField
from core.validators import RadioIRIValidator
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 = [RadioIRIValidator()]

View file

@ -1,160 +1,153 @@
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 os
import sqlite3 import re
from django.core.management.base import BaseCommand, CommandError 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 decimal.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)
class Command(BaseCommand): 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): 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): 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') 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 with open(playlist_file, 'r', encoding='utf8') as pfile:
con = sqlite3.connect(options['sqlite3_db_file'][0], playlist = json.load(pfile, parse_float=decimal.Decimal)
detect_types=detect_types)
cur = con.cursor()
# Fetching albums first totals = {
for album in con.execute('SELECT title, enabled FROM albums'): 'albums': 0,
album_disabled = not bool(album[1]) 'artists': 0,
Album.objects.create(title=album[0], disabled=album_disabled) 'games': 0,
total_albums += 1 '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'])
totals['albums'] += 1
# Next up, artists self.stdout.write('Imported {} albums'.format(str(totals['albums'])))
cur.execute('''SELECT
artists_id,
alias,
firstname,
lastname,
enabled
FROM artists''')
artists = cur.fetchall()
for artist in artists: # Next up, artists
artist_disabled = not bool(artist[4]) for artist in playlist['artists']:
Artist.objects.create(alias=artist[1] or '', Artist.objects.create(alias=artist['alias'] or '',
first_name=artist[2] or '', first_name=artist['first_name'] or '',
last_name=artist[3] or '', last_name=artist['last_name'] or '',
disabled=artist_disabled) disabled=artist['disabled'])
total_artists += 1 totals['artists'] += 1
self.stdout.write('Imported {} artists'.format(str(total_artists))) self.stdout.write('Imported {} artists'.format(str(totals['artists'])))
# On to games # On to games
for game in con.execute('SELECT title, enabled FROM games'): for game in playlist['games']:
game_disabled = not bool(game[1]) Game.objects.create(title=game['title'],
Game.objects.create(title=game[0], disabled=game_disabled) disabled=game['disabled'])
total_games += 1 totals['games'] += 1
self.stdout.write('Imported {} games'.format(str(total_games))) self.stdout.write('Imported {} games'.format(str(totals['games'])))
# Followed by the songs # Followed by the songs
cur.execute('''SELECT for song in playlist['songs']:
songs.songs_id AS id, try:
games.title AS game, album = Album.objects.get(title__exact=song['album'])
albums.title AS album, except Album.DoesNotExist:
songs.enabled AS enabled, album = None
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()
for song in songs: try:
try: game = Game.objects.get(title__exact=song['game'])
album = Album.objects.get(title__exact=song[2]) except Game.DoesNotExist:
except Album.DoesNotExist: game = None
album = None
try: new_song = Song.objects.create(album=album,
game = Game.objects.get(title__exact=song[1]) game=game,
except Game.DoesNotExist: disabled=song['disabled'],
game = None song_type=song['type'],
title=song['title'])
song_disabled = not bool(song[3]) for artist in song['artists']:
new_song = Song.objects.create(album=album, new_artist = Artist.objects.get(
game=game, alias__exact=artist['alias'] or '',
disabled=song_disabled, first_name__exact=artist['first_name'] or '',
song_type=song[4], last_name__exact=artist['last_name'] or ''
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)
) )
new_song.artists.add(new_artist)
localfile = re.match(
r'^(?:(?:[A-Za-z]:|\\)\\|\/)',
song['store']['path']
) )
pub = input('Do you want to publish all imported objects as well? ' if localfile:
'[Y/N] ') iri = path_to_iri(song['store']['path'])
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')
else: else:
self.stdout.write('Skipped publishing songs') iri = song['store']['path']
if song['store']['track_gain']:
gain_str = re.sub(r'[dB\+ ]', '', song['store']['track_gain'])
gain = decimal.Decimal(gain_str)
else:
gain = None
if song['store']['track_peak']:
peak = decimal.Decimal(song['store']['track_peak'])
else:
peak = None
new_store = Store.objects.create(
iri=iri,
mime_type=song['store']['mime'],
file_size=song['store']['filesize'],
length=song['store']['length'],
track_gain=gain,
track_peak=peak
)
new_song.stores.add(new_store)
new_song.active_store = new_store
new_song.save()
if song['type'] == 'S':
totals['songs'] += 1
else:
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 datetime import timedelta
from decimal import getcontext, Decimal, ROUND_UP from decimal import getcontext, Decimal, ROUND_UP
from random import randint from random import randint
@ -15,123 +19,126 @@ getcontext().prec = 16
class RadioManager(models.Manager): class RadioManager(models.Manager):
""" '''
Custom object manager for filtering out common behaviors for radio Custom object manager for filtering out common behaviors for radio
objects. objects.
""" '''
def get_queryset(self): def get_queryset(self):
""" '''
Return customized default QuerySet. Return customized default QuerySet.
""" '''
return RadioQuerySet(self.model, using=self._db) return RadioQuerySet(self.model, using=self._db)
def disabled(self): def disabled(self):
""" '''
Radio objects that are marked as disabled. Radio objects that are marked as disabled.
""" '''
return self.get_queryset().disabled() return self.get_queryset().disabled()
def enabled(self): def enabled(self):
""" '''
Radio objects that are marked as enabled. Radio objects that are marked as enabled.
""" '''
return self.get_queryset().enabled() return self.get_queryset().enabled()
def published(self): def published(self):
""" '''
Radio objects that are marked as published. Radio objects that are marked as published.
""" '''
return self.get_queryset().published() return self.get_queryset().published()
def unpublished(self): def unpublished(self):
""" '''
Radio objects that are marked as unpublished. Radio objects that are marked as unpublished.
""" '''
return self.get_queryset().unpublished() return self.get_queryset().unpublished()
def available(self): def available(self):
""" '''
Radio objects that are enabled and published. Radio objects that are enabled and published.
""" '''
return self.enabled().published() return self.enabled().published()
class SongManager(RadioManager): class SongManager(RadioManager):
""" '''
Custom object manager for filtering out common behaviors for Song objects. Custom object manager for filtering out common behaviors for Song objects.
""" '''
def get_queryset(self): def get_queryset(self):
""" '''
Return customized default QuerySet for Songs. Return customized default QuerySet for Songs.
""" '''
return SongQuerySet(self.model, using=self._db) return SongQuerySet(self.model, using=self._db)
def available_jingles(self): def available_jingles(self):
""" '''
Jingles that are currently published and are enabled. Jingles that are currently published and are enabled.
""" '''
return self.available().jingles() return self.available().jingles()
def available_songs(self): def available_songs(self):
""" '''
Songs that are currently published and are enabled. Songs that are currently published and are enabled.
""" '''
return self.available().songs() return self.available().songs()
def playlist_length(self): def playlist_length(self):
""" '''
Total length of available songs in the playlist (in seconds). Total length of available songs in the playlist (in seconds).
""" '''
length = self.available_songs().aggregate(models.Sum('length')) a_songs = self.available_songs()
return length['length__sum'] length = a_songs.aggregate(
total_time=models.Sum('active_store__length')
)
return length['total_time']
def wait_total(self, adjusted_ratio=0.0): def wait_total(self, adjusted_ratio=0.0):
""" '''
Default length in seconds before a song can be played again. This is Default length in seconds before a song can be played again. This is
based on the replay ratio set in the application settings. based on the replay ratio set in the application settings.
""" '''
total_ratio = get_setting('replay_ratio') + adjusted_ratio total_ratio = get_setting('replay_ratio') + adjusted_ratio
wait = self.playlist_length() * Decimal(total_ratio) wait = self.playlist_length() * Decimal(total_ratio)
wait = wait.quantize(Decimal('.01'), rounding=ROUND_UP) wait = wait.quantize(Decimal('.01'), rounding=ROUND_UP)
return timedelta(seconds=float(wait)) return timedelta(seconds=float(wait))
def datetime_from_wait(self): def datetime_from_wait(self):
""" '''
Datetime of now minus the default wait time for played songs. Datetime of now minus the default wait time for played songs.
""" '''
return timezone.now() - self.wait_total() return timezone.now() - self.wait_total()
def playable(self): def playable(self):
""" '''
Songs that are playable because they are available (enabled & Songs that are playable because they are available (enabled &
published) and they have not been played within the default wait time published) and they have not been played within the default wait time
(or at all). (or at all).
""" '''
return self.available_songs().filter( return self.available_songs().filter(
models.Q(next_play__lt=timezone.now()) | models.Q(next_play__lt=timezone.now()) |
models.Q(next_play__isnull=True) models.Q(next_play__isnull=True)
) )
def requestable(self): def requestable(self):
""" '''
Songs that can be placed in the request queue for playback. Songs that can be placed in the request queue for playback.
""" '''
# Import SongRequest here to get rid of circular dependencies # Import SongRequest here to get rid of circular dependencies
SongRequest = apps.get_model(app_label='profiles', song_request = apps.get_model(app_label='profiles',
model_name='SongRequest') model_name='SongRequest')
requests = SongRequest.music.unplayed().values_list('song__id', requests = song_request.music.unplayed().values_list('song__id',
flat=True) flat=True)
return self.playable().exclude(id__in=requests) return self.playable().exclude(id__in=requests)
def get_random_requestable_song(self): def get_random_requestable_song(self):
""" '''
Pick a random requestable song and return it. Pick a random requestable song and return it.
""" '''
return self.requestable()[randint(0, self.requestable().count() - 1)] return self.requestable()[randint(0, self.requestable().count() - 1)]
def get_random_jingle(self): def get_random_jingle(self):
""" '''
Pick a random jingle and return it. Pick a random jingle and return it.
""" '''
random_index = randint(0, self.available_jingles().count() - 1) random_index = randint(0, self.available_jingles().count() - 1)
return self.available_jingles()[random_index] return self.available_jingles()[random_index]

View file

@ -0,0 +1,49 @@
# Generated by Django 2.2.1 on 2019-06-06 19:06
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(verbose_name='IRI path to song file')),
('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='active_store',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='active_for', to='radio.Store'),
),
migrations.AddField(
model_name='song',
name='stores',
field=models.ManyToManyField(blank=True, related_name='song', to='radio.Store'),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 2.2.2 on 2019-07-03 13:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('radio', '0004_new_song_path_structure'),
]
operations = [
migrations.AddField(
model_name='store',
name='track_gain',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='recommended replaygain adjustment'),
),
migrations.AddField(
model_name='store',
name='track_peak',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='highest volume level in the track'),
),
]

View file

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

View file

@ -1,6 +1,11 @@
'''
Django settings file.
'''
import os import os
from decouple import config from decouple import config
from dj_database_url import parse as db_url
SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__)) SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__))
@ -8,6 +13,8 @@ CONFIG_DIR = os.path.dirname(SETTINGS_DIR)
PROJECT_DIR = os.path.dirname(CONFIG_DIR) PROJECT_DIR = os.path.dirname(CONFIG_DIR)
BASE_DIR = os.path.dirname(PROJECT_DIR) BASE_DIR = os.path.dirname(PROJECT_DIR)
DEBUG = config('DEBUG', default=False, cast=bool)
# #
# Django-specific settings # Django-specific settings
# #
@ -29,6 +36,17 @@ AUTH_PASSWORD_VALIDATORS = [
AUTH_USER_MODEL = 'core.RadioUser' AUTH_USER_MODEL = 'core.RadioUser'
DATABASES = {
'default': config(
'DATABASE_URL',
default='sqlite:///' + os.path.join(PROJECT_DIR, 'spradio.sqlite3'),
cast=db_url
)
}
if DEBUG:
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
INSTALLED_APPS = [ INSTALLED_APPS = [
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',

View file

@ -1,38 +0,0 @@
from decouple import config
from dj_database_url import parse as db_url
from .base import *
ALLOWED_HOSTS = []
DATABASES = {
'default': config(
'DATABASE_URL',
default='sqlite:///' + os.path.join(PROJECT_DIR, 'testdb.sqlite3'),
cast=db_url
)
}
DEBUG = True
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
'''
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
}
},
'loggers': {
'django.db.backends': {
'handlers': ['console'],
'level': 'DEBUG',
},
}
}
'''