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 json
import logging
from logging.handlers import RotatingFileHandler
from decouple import config
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 = {
'Content-Type': 'application/json; charset=utf-8',
'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):
@ -43,40 +69,28 @@ def beautify_artists(artists):
return clean_quotes(output.join(artists))
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':
def next_request():
'''
Sends an HTTP[S] request to the radio web service to retrieve the next
requested song.
'''
LOGGER.debug('Received command to get next song request.')
try:
r = requests.get(API_URL + 'next/',
headers=HEADERS,
timeout=5)
r.encoding = 'utf-8'
r.raise_for_status()
resp = requests.get(API_URL + 'next/', headers=HEADERS, timeout=5)
resp.encoding = 'utf-8'
resp.raise_for_status()
except requests.exceptions.HTTPError as errh:
print('Http Error: {}'.format(errh))
LOGGER.error('Http Error: %s', errh)
except requests.exceptions.ConnectionError as errc:
print('Error Connecting: {}'.format(errc))
LOGGER.error('Error Connecting: %s', errc)
except requests.exceptions.Timeout as errt:
print('Timeout Error: {}'.format(errt))
LOGGER.error('Timeout Error: %s', errt)
except requests.exceptions.RequestException as err:
print('Error: {}'.format(err))
LOGGER.error('Error: %s', err)
else:
req = json.loads(r.text)
song = req['song']
LOGGER.debug('Received JSON response: %s', resp.text)
song_request = json.loads(resp.text)
song = song_request['song']
if song['song_type'] == 'J':
artist = RADIO_NAME
title = 'Jingle'
@ -85,27 +99,86 @@ if args.command == 'next':
artist = beautify_artists(song['artists'])
title = clean_quotes(song['title'])
game = clean_quotes(song['game'])
print(ANNOTATE.format(req['id'],
LOGGER.info(
'ID: %s, Artist[s]: %s, Title: %s, Game: %s, Gain: %s, Path: %s',
song_request['id'],
artist,
title,
game,
song['replaygain'],
song['path']
)
annotate_string = ANNOTATE.format(
song_request['id'],
song['song_type'],
artist,
title,
game,
song['path']))
elif args.command == 'played':
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:
req_played = json.dumps({'song_request': args.request[0]})
r = requests.post(API_URL + 'played/',
request_played = json.dumps({'song_request': request_id})
resp = requests.post(
API_URL + 'played/',
headers=HEADERS,
data=req_played,
timeout=5)
r.encoding = 'utf-8'
r.raise_for_status()
data=request_played,
timeout=5
)
resp.encoding = 'utf-8'
resp.raise_for_status()
except requests.exceptions.HTTPError as errh:
print('Http Error: {}'.format(errh))
print(r.text)
LOGGER.error('Http Error: %s', errh)
except requests.exceptions.ConnectionError as errc:
print('Error Connecting: {}'.format(errc))
LOGGER.error('Error Connecting: %s', errc)
except requests.exceptions.Timeout as errt:
print('Timeout Error: {}'.format(errt))
LOGGER.error('Timeout Error: %s', errt)
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
cffi>=1.11.5
argon2-cffi>=19.1.0
cffi>=1.12.3
dj-database-url>=0.5.0
Django>=2.1.5
Django>=2.2.2
django-authtools>=1.6.0
djangorestframework>=3.9.0
psycopg2>=2.7.6.1
djangorestframework>=3.9.4
psycopg2-binary>=2.8.2
pycparser>=2.19
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,
ModelSerializer,
Serializer,
StringRelatedField)
Serializer)
from profiles.models import SongRequest
from .radio import RadioSongSerializer

View file

@ -4,7 +4,7 @@ from rest_framework.serializers import (IntegerField, ModelSerializer,
Serializer)
from profiles.models import RadioProfile, SongRequest, Rating
from .radio import BasicSongRetrieveSerializer
from .radio import SongMinimalSerializer
User = get_user_model()
@ -44,7 +44,7 @@ class RateSongSerializer(Serializer):
class HistorySerializer(ModelSerializer):
profile = BasicProfileSerializer()
song = BasicSongRetrieveSerializer()
song = SongMinimalSerializer()
class Meta:
model = SongRequest
@ -52,7 +52,7 @@ class HistorySerializer(ModelSerializer):
class BasicProfileRatingsSerializer(ModelSerializer):
song = BasicSongRetrieveSerializer()
song = SongMinimalSerializer()
class Meta:
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,
SerializerMethodField,
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):
'''A base serializer for an album model.'''
class Meta:
model = Album
fields = ('id', 'title')
class ArtistSerializer(ModelSerializer):
'''A base serializer for an artist model.'''
class Meta:
model = Artist
fields = ('id', 'alias', 'first_name', 'last_name')
class ArtistFullnameSerializer(ModelSerializer):
'''
A base serializer for an artist model, but combining all name
attributes into one field.
'''
class Meta:
model = Artist
fields = ('id', 'full_name')
class GameSerializer(ModelSerializer):
'''A base serializer for a game model.'''
class Meta:
model = Game
fields = ('id', 'title')
class BasicSongSerializer(ModelSerializer):
class StoreSerializer(ModelSerializer):
'''A base serializer for a data store model.'''
active = SerializerMethodField()
class Meta:
model = Song
fields = ('id', 'album', 'artists', 'game', 'title', 'average_rating',
'is_requestable')
model = Store
fields = ('id', 'active', 'iri', 'file_size', 'length', 'mime_type')
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:
model = Song
fields = ('id', 'album', 'artists', 'published_date', 'game',
'num_played', 'last_played', 'length', 'song_type', 'title',
'average_rating', 'is_requestable')
'num_played', 'last_played', 'length', 'next_play',
'song_type', 'title', 'average_rating', 'is_requestable')
class BasicSongRetrieveSerializer(BasicSongSerializer):
class SongMinimalSerializer(ModelSerializer):
'''Minimal song information, usually appended to favorites/ratings.'''
album = AlbumSerializer()
artists = ArtistFullnameSerializer(many=True)
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()
artists = ArtistSerializer(many=True)
game = GameSerializer()
class RadioSongSerializer(ModelSerializer):
'''
A song serializer that is specific to the radio DJ and the underlying
audio manipulation application.
'''
album = StringRelatedField()
artists = StringRelatedField(many=True)
game = StringRelatedField()
length = DecimalField(
max_digits=10,
decimal_places=2,
source='active_store.length'
)
replaygain = CharField(source='active_store.replaygain')
path = SerializerMethodField()
class Meta:
model = Song
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):
'''
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)
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.profiles import HistoryViewSet, ProfileViewSet
from api.views.radio import (AlbumViewSet, ArtistViewSet,
GameViewSet, SongViewSet)
from api.views.radio import (AlbumViewSet, ArtistViewSet, GameViewSet,
StoreViewSet, SongViewSet)
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'artists', ArtistViewSet, base_name='artist')
router.register(r'games', GameViewSet, base_name='game')
router.register(r'stores', StoreViewSet, base_name='store')
router.register(r'songs', SongViewSet, base_name='song')
urlpatterns = [

View file

@ -11,7 +11,7 @@ from ..serializers.profiles import (BasicProfileSerializer,
FullProfileSerializer,
HistorySerializer,
BasicProfileRatingsSerializer)
from ..serializers.radio import BasicSongRetrieveSerializer
from ..serializers.radio import SongListSerializer
class ProfileViewSet(viewsets.ModelViewSet):
@ -52,10 +52,10 @@ class ProfileViewSet(viewsets.ModelViewSet):
page = self.paginate_queryset(favorites)
if page is not None:
serializer = BasicSongRetrieveSerializer(page, many=True)
serializer = SongListSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = BasicSongRetrieveSerializer(favorites, many=True)
serializer = SongListSerializer(favorites, many=True)
return Response(serializer.data)
@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 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 ..serializers.profiles import (BasicProfileSerializer,
BasicSongRatingsSerializer,
RateSongSerializer)
from ..serializers.radio import (AlbumSerializer, ArtistSerializer,
GameSerializer, FullSongSerializer,
GameSerializer, StoreSerializer,
SongSerializer, SongListSerializer,
SongRetrieveSerializer,
SongArtistsListSerializer,
FullSongRetrieveSerializer)
SongStoresSerializer)
class AlbumViewSet(viewsets.ModelViewSet):
@ -63,6 +65,12 @@ class GameViewSet(viewsets.ModelViewSet):
return Game.music.available()
class StoreViewSet(viewsets.ModelViewSet):
queryset = Store.objects.all()
permission_classes = [IsAdminUser]
serializer_class = StoreSerializer
class SongViewSet(viewsets.ModelViewSet):
permission_classes = [IsAdminOrReadOnly]
@ -83,38 +91,99 @@ class SongViewSet(viewsets.ModelViewSet):
(Thanks to https://stackoverflow.com/questions/22616973/)
'''
if self.action in ['list', 'retrieve']:
return FullSongRetrieveSerializer
return FullSongSerializer
if self.action == 'list':
return SongListSerializer
if self.action == 'retrieve':
return SongRetrieveSerializer
return SongSerializer
def _artists_change(self, request, remove=False):
song = self.get_object()
serializer = SongArtistsListSerializer(data=request.data)
if serializer.is_valid():
artists = Artist.objects.filter(pk__in=serializer.data['artists'])
for artist in artists:
if remove:
song.artists.remove(artist)
else:
song.artists.add(artist)
song.save()
if song.artists.count() == 0:
song.disable('No artists specified for song.')
message = 'Artists {} song.'.format(('added to',
'removed from')[remove])
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])
def artists_add(self, request, pk=None):
'''Adds an artist to a song.'''
return self._artists_change(request)
@action(methods=['post'], detail=True, permission_classes=[IsAdminUser])
def artists_remove(self, request, pk=None):
'''Removes an artist from a song.'''
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])
def favorites(self, request, pk=None):
'''Get a list of users who added this song to their favorites list.'''
song = self.get_object()
profiles = song.song_favorites.all().order_by('user__name')
@ -130,6 +199,7 @@ class SongViewSet(viewsets.ModelViewSet):
detail=True,
permission_classes=[IsAuthenticatedAndNotDJ])
def favorite(self, request, pk=None):
'''Add a song to the user's favorites list.'''
song = self.get_object()
profile = RadioProfile.objects.get(user=request.user)
if song not in profile.favorites.all():
@ -144,6 +214,7 @@ class SongViewSet(viewsets.ModelViewSet):
detail=True,
permission_classes=[IsAuthenticatedAndNotDJ])
def unfavorite(self, request, pk=None):
'''Remove a song from the user's favorites list.'''
song = self.get_object()
profile = RadioProfile.objects.get(user=request.user)
if song in profile.favorites.all():
@ -157,6 +228,7 @@ class SongViewSet(viewsets.ModelViewSet):
@action(detail=True, permission_classes=[AllowAny])
def ratings(self, request, pk=None):
'''Get a list of a song's ratings.'''
song = self.get_object()
ratings = song.rating_set.all().order_by('-created_date')
@ -172,6 +244,7 @@ class SongViewSet(viewsets.ModelViewSet):
detail=True,
permission_classes=[IsAuthenticatedAndNotDJ])
def rate(self, request, pk=None):
'''Add a user's rating to a song.'''
serializer = RateSongSerializer(data=request.data)
if serializer.is_valid():
song = self.get_object()
@ -195,6 +268,7 @@ class SongViewSet(viewsets.ModelViewSet):
detail=True,
permission_classes=[IsAuthenticatedAndNotDJ])
def unrate(self, request, pk=None):
'''Remove a user's rating from a song.'''
song = self.get_object()
profile = RadioProfile.objects.get(user=request.user)
rating = song.rating_set.filter(profile=profile)

View file

@ -1,6 +1,6 @@
from django.db import models
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):

View file

@ -1,5 +1,5 @@
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

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 re
import string
from unicodedata import normalize
from urllib.parse import urljoin, urlparse
from urllib.request import pathname2url, url2pathname
from django.core.exceptions import ObjectDoesNotExist
from django.db import connection
from django.utils.encoding import iri_to_uri, uri_to_iri
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):
'''
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
rng = random.SystemRandom()
return ''.join([rng.choice(chars) for i in range(length)])
def get_len(rawqueryset):
"""
'''
Adds/Overrides a dynamic implementation of the length protocol to the
definition of RawQuerySet.
"""
'''
def __len__(self):
params = ['{}'.format(p) for p in self.params]
sql = ''.join(('SELECT COUNT(*) FROM (',
@ -33,11 +67,13 @@ def get_len(rawqueryset):
def get_setting(name):
'''Helper function to get dynamic settings from the database.'''
setting = Setting.objects.get(name=name)
return setting.get()
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}
try:
setting = Setting.objects.get(name=name)
@ -57,13 +93,13 @@ def set_setting(name, value, setting_type=None):
def naturalize(text):
"""
'''
Return a normalized unicode string, with removed starting articles, for use
in natural sorting.
Code was inspired by 'django-naturalsortfield' from Nathan Reynolds:
https://github.com/nathforge/django-naturalsortfield
"""
'''
def naturalize_int_match(match):
return '{:08d}'.format(int(match.group(0)))
@ -79,9 +115,9 @@ def naturalize(text):
def quantify(quantity, model):
"""
'''
A message based on the quantity and singular/plural name of the model.
"""
'''
if quantity == 1:
message = '1 {}'.format(model._meta.verbose_name)
else:
@ -92,21 +128,20 @@ def quantify(quantity, model):
def create_success_message(parent_model, parent_quantity, child_model,
child_quantity, remove=False):
"""
'''
Creates a message for displaying the success of model modification.
"""
'''
p_message = quantify(parent_quantity, parent_model)
c_message = quantify(child_quantity, child_model)
if remove:
return '{} successfully removed from {}'.format(c_message, p_message)
else:
return '{} successfully added to {}.'.format(c_message, p_message)
def get_pretty_time(seconds):
"""
'''
Displays a human-readable representation of time.
"""
'''
if seconds > 0:
periods = [
('year', 60*60*24*365.25),
@ -123,5 +158,36 @@ def get_pretty_time(seconds):
period_name,
('s', '')[period_value == 1]))
return ', '.join(strings)
else:
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)
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_plural = 'requests'
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,
MaxValueValidator)
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.utils import get_setting

View file

@ -3,7 +3,7 @@ from django.db import models
from django.forms import TextInput
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):
@ -13,6 +13,13 @@ class ArtistInline(admin.TabularInline):
extra = 0
class StoreInline(admin.TabularInline):
model = Song.stores.through
verbose_name = 'data store'
verbose_name_plural = 'data stores'
extra = 0
@admin.register(Album)
class AlbumAdmin(admin.ModelAdmin):
# Detail List display
@ -101,6 +108,34 @@ class GameAdmin(admin.ModelAdmin):
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)
class SongAdmin(admin.ModelAdmin):
formfield_overrides = {
@ -123,8 +158,7 @@ class SongAdmin(admin.ModelAdmin):
# Edit Form display
exclude = ('artists',)
readonly_fields = ('length',
'last_played',
readonly_fields = ('last_played',
'num_played',
'created_date',
'modified_date',
@ -137,8 +171,8 @@ class SongAdmin(admin.ModelAdmin):
('Main', {
'fields': ('song_type',
'title',
'path',
'published_date')
'published_date',
'active_store')
}),
('Stats', {
'classes': ('collapse',),
@ -146,8 +180,7 @@ class SongAdmin(admin.ModelAdmin):
'modified_date',
'last_played',
'num_played',
'next_play',
'length')
'next_play')
}),
('Album', {
'fields': ('album',)
@ -156,7 +189,14 @@ class SongAdmin(admin.ModelAdmin):
'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):
return ', '.join([a.full_name for a in obj.artists.all()])

View file

@ -1,6 +1,6 @@
from django.db import models
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):

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 sqlite3
import re
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)
totals = {
'albums': 0,
'artists': 0,
'games': 0,
'songs': 0,
'jingles': 0
}
# 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
for album in playlist['albums']:
Album.objects.create(title=album['title'],
disabled=album['disabled'])
totals['albums'] += 1
self.stdout.write('Imported {} albums'.format(str(total_albums)))
self.stdout.write('Imported {} albums'.format(str(totals['albums'])))
# Next up, artists
cur.execute('''SELECT
artists_id,
alias,
firstname,
lastname,
enabled
FROM artists''')
artists = cur.fetchall()
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'])
totals['artists'] += 1
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
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
for game in playlist['games']:
Game.objects.create(title=game['title'],
disabled=game['disabled'])
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
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()
for song in songs:
for song in playlist['songs']:
try:
album = Album.objects.get(title__exact=song[2])
album = Album.objects.get(title__exact=song['album'])
except Album.DoesNotExist:
album = None
try:
game = Game.objects.get(title__exact=song[1])
game = Game.objects.get(title__exact=song['game'])
except Game.DoesNotExist:
game = None
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
disabled=song['disabled'],
song_type=song['type'],
title=song['title'])
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])
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)
localfile = re.match(
r'^(?:(?:[A-Za-z]:|\\)\\|\/)',
song['store']['path']
)
if localfile:
iri = path_to_iri(song['store']['path'])
else:
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(total_songs + total_jingles),
str(total_songs),
str(total_jingles)
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 == '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()
if pub in ('Y', 'y'):
for album in Album.objects.all():
album.publish()
for artist in Artist.objects.all():
artist.publish()
for game in Game.objects.all():
game.publish()
for song in Song.objects.all():
song.publish()
self.stdout.write('Published imported objects successfully')
else:
self.stdout.write('Skipped publishing songs')

View file

@ -1,3 +1,7 @@
'''
Django Model Managers for the Radio application.
'''
from datetime import timedelta
from decimal import getcontext, Decimal, ROUND_UP
from random import randint
@ -15,123 +19,126 @@ getcontext().prec = 16
class RadioManager(models.Manager):
"""
'''
Custom object manager for filtering out common behaviors for radio
objects.
"""
'''
def get_queryset(self):
"""
'''
Return customized default QuerySet.
"""
'''
return RadioQuerySet(self.model, using=self._db)
def disabled(self):
"""
'''
Radio objects that are marked as disabled.
"""
'''
return self.get_queryset().disabled()
def enabled(self):
"""
'''
Radio objects that are marked as enabled.
"""
'''
return self.get_queryset().enabled()
def published(self):
"""
'''
Radio objects that are marked as published.
"""
'''
return self.get_queryset().published()
def unpublished(self):
"""
'''
Radio objects that are marked as unpublished.
"""
'''
return self.get_queryset().unpublished()
def available(self):
"""
'''
Radio objects that are enabled and published.
"""
'''
return self.enabled().published()
class SongManager(RadioManager):
"""
'''
Custom object manager for filtering out common behaviors for Song objects.
"""
'''
def get_queryset(self):
"""
'''
Return customized default QuerySet for Songs.
"""
'''
return SongQuerySet(self.model, using=self._db)
def available_jingles(self):
"""
'''
Jingles that are currently published and are enabled.
"""
'''
return self.available().jingles()
def available_songs(self):
"""
'''
Songs that are currently published and are enabled.
"""
'''
return self.available().songs()
def playlist_length(self):
"""
'''
Total length of available songs in the playlist (in seconds).
"""
length = self.available_songs().aggregate(models.Sum('length'))
return length['length__sum']
'''
a_songs = self.available_songs()
length = a_songs.aggregate(
total_time=models.Sum('active_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)
)
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',
song_request = apps.get_model(app_label='profiles',
model_name='SongRequest')
requests = SongRequest.music.unplayed().values_list('song__id',
requests = song_request.music.unplayed().values_list('song__id',
flat=True)
return self.playable().exclude(id__in=requests)
def get_random_requestable_song(self):
"""
'''
Pick a random requestable song and return it.
"""
'''
return self.requestable()[randint(0, self.requestable().count() - 1)]
def get_random_jingle(self):
"""
'''
Pick a random jingle and return it.
"""
'''
random_index = randint(0, self.available_jingles().count() - 1)
return self.available_jingles()[random_index]

View file

@ -0,0 +1,49 @@
# Generated by Django 2.2.1 on 2019-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 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 django.utils.translation import gettext_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,53 @@ 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(_('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):
"""
'''
A model for a song.
"""
'''
JINGLE = 'J'
SONG = 'S'
TYPE_CHOICES = (
@ -128,13 +177,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,
stores = models.ManyToManyField(Store, blank=True, related_name='song')
active_store = models.ForeignKey(Store,
on_delete=models.SET_NULL,
null=True,
blank=True)
path = models.TextField(_('absolute path to song file'))
blank=True,
related_name='active_for')
sorted_title = models.CharField(_('naturalized title'),
db_index=True,
editable=False,
@ -147,34 +195,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 +233,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 +244,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 +257,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 +280,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,13 +291,13 @@ 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',
song_request = apps.get_model(app_label='profiles',
model_name='SongRequest')
requests = SongRequest.music.unplayed().values_list('song__id',
requests = song_request.music.unplayed().values_list('song__id',
flat=True)
return self.pk not in requests
return False

View file

@ -1,6 +1,11 @@
'''
Django settings file.
'''
import os
from decouple import config
from dj_database_url import parse as db_url
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)
BASE_DIR = os.path.dirname(PROJECT_DIR)
DEBUG = config('DEBUG', default=False, cast=bool)
#
# Django-specific settings
#
@ -29,6 +36,17 @@ AUTH_PASSWORD_VALIDATORS = [
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 = [
'django.contrib.admin',
'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',
},
}
}
'''