Merge song-path-redux into master
Lets do this. . .
This commit is contained in:
commit
ef7c6e535f
33 changed files with 1516 additions and 369 deletions
|
@ -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(
|
||||||
|
'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'],
|
song['song_type'],
|
||||||
artist,
|
artist,
|
||||||
title,
|
title,
|
||||||
game,
|
game,
|
||||||
song['path']))
|
song['replaygain'],
|
||||||
elif args.command == 'played':
|
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(
|
||||||
|
API_URL + 'played/',
|
||||||
headers=HEADERS,
|
headers=HEADERS,
|
||||||
data=req_played,
|
data=request_played,
|
||||||
timeout=5)
|
timeout=5
|
||||||
r.encoding = 'utf-8'
|
)
|
||||||
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.
109
contrib/download_s3/download_s3.py
Normal file
109
contrib/download_s3/download_s3.py
Normal 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()
|
228
contrib/export_playlist/export_playlist.py
Normal file
228
contrib/export_playlist/export_playlist.py
Normal 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()
|
1
contrib/export_playlist/requirements.txt
Normal file
1
contrib/export_playlist/requirements.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
python-magic>=0.4.15
|
9
contrib/upload_s3/requirements.txt
Normal file
9
contrib/upload_s3/requirements.txt
Normal 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
|
234
contrib/upload_s3/upload_s3.py
Normal file
234
contrib/upload_s3/upload_s3.py
Normal 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.
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 = [
|
||||||
|
|
|
@ -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])
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
43
savepointradio/core/validators.py
Normal file
43
savepointradio/core/validators.py
Normal 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)
|
|
@ -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'
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()])
|
||||||
|
|
|
@ -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):
|
||||||
|
|
41
savepointradio/radio/fields.py
Normal file
41
savepointradio/radio/fields.py
Normal 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,
|
||||||
|
})
|
20
savepointradio/radio/forms.py
Normal file
20
savepointradio/radio/forms.py
Normal 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()]
|
|
@ -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()
|
totals = {
|
||||||
|
'albums': 0,
|
||||||
|
'artists': 0,
|
||||||
|
'games': 0,
|
||||||
|
'songs': 0,
|
||||||
|
'jingles': 0
|
||||||
|
}
|
||||||
|
|
||||||
# Fetching albums first
|
# Fetching albums first
|
||||||
for album in con.execute('SELECT title, enabled FROM albums'):
|
for album in playlist['albums']:
|
||||||
album_disabled = not bool(album[1])
|
Album.objects.create(title=album['title'],
|
||||||
Album.objects.create(title=album[0], disabled=album_disabled)
|
disabled=album['disabled'])
|
||||||
total_albums += 1
|
totals['albums'] += 1
|
||||||
|
|
||||||
self.stdout.write('Imported {} albums'.format(str(total_albums)))
|
self.stdout.write('Imported {} albums'.format(str(totals['albums'])))
|
||||||
|
|
||||||
# Next up, artists
|
# Next up, artists
|
||||||
cur.execute('''SELECT
|
for artist in playlist['artists']:
|
||||||
artists_id,
|
Artist.objects.create(alias=artist['alias'] or '',
|
||||||
alias,
|
first_name=artist['first_name'] or '',
|
||||||
firstname,
|
last_name=artist['last_name'] or '',
|
||||||
lastname,
|
disabled=artist['disabled'])
|
||||||
enabled
|
totals['artists'] += 1
|
||||||
FROM artists''')
|
|
||||||
artists = cur.fetchall()
|
|
||||||
|
|
||||||
for artist in artists:
|
self.stdout.write('Imported {} artists'.format(str(totals['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)))
|
|
||||||
|
|
||||||
# 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,
|
|
||||||
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:
|
|
||||||
try:
|
try:
|
||||||
album = Album.objects.get(title__exact=song[2])
|
album = Album.objects.get(title__exact=song['album'])
|
||||||
except Album.DoesNotExist:
|
except Album.DoesNotExist:
|
||||||
album = None
|
album = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
game = Game.objects.get(title__exact=song[1])
|
game = Game.objects.get(title__exact=song['game'])
|
||||||
except Game.DoesNotExist:
|
except Game.DoesNotExist:
|
||||||
game = None
|
game = None
|
||||||
|
|
||||||
song_disabled = not bool(song[3])
|
|
||||||
new_song = Song.objects.create(album=album,
|
new_song = Song.objects.create(album=album,
|
||||||
game=game,
|
game=game,
|
||||||
disabled=song_disabled,
|
disabled=song['disabled'],
|
||||||
song_type=song[4],
|
song_type=song['type'],
|
||||||
title=song[5],
|
title=song['title'])
|
||||||
length=song[6],
|
|
||||||
path=song[7])
|
|
||||||
if song[4] == 'S':
|
|
||||||
total_songs += 1
|
|
||||||
else:
|
|
||||||
total_jingles += 1
|
|
||||||
|
|
||||||
cur.execute('''SELECT
|
for artist in song['artists']:
|
||||||
ifnull(alias, "") AS alias,
|
new_artist = Artist.objects.get(
|
||||||
ifnull(firstname, "") AS firstname,
|
alias__exact=artist['alias'] or '',
|
||||||
ifnull(lastname, "") AS lastname
|
first_name__exact=artist['first_name'] or '',
|
||||||
FROM artists
|
last_name__exact=artist['last_name'] or ''
|
||||||
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)
|
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(
|
self.stdout.write(
|
||||||
'Imported {} requestables ({} songs, {} jingles)'.format(
|
'Imported {} requestables ({} songs, {} jingles)'.format(
|
||||||
str(total_songs + total_jingles),
|
str(totals['songs'] + totals['jingles']),
|
||||||
str(total_songs),
|
str(totals['songs']),
|
||||||
str(total_jingles)
|
str(totals['jingles'])
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
pub = input('Do you want to publish all imported objects as well? '
|
pub = input('Do you want to publish all imported objects as well? '
|
||||||
'[Y/N] ')
|
'[Y/N] ')
|
||||||
|
|
||||||
if pub == 'Y' or pub == 'y':
|
if pub in ('Y', 'y'):
|
||||||
for al in Album.objects.all():
|
for album in Album.objects.all():
|
||||||
al.publish()
|
album.publish()
|
||||||
for ar in Artist.objects.all():
|
for artist in Artist.objects.all():
|
||||||
ar.publish()
|
artist.publish()
|
||||||
for g in Game.objects.all():
|
for game in Game.objects.all():
|
||||||
g.publish()
|
game.publish()
|
||||||
for s in Song.objects.all():
|
for song in Song.objects.all():
|
||||||
s.publish()
|
song.publish()
|
||||||
self.stdout.write('Published imported objects successfully')
|
self.stdout.write('Published imported objects successfully')
|
||||||
else:
|
else:
|
||||||
self.stdout.write('Skipped publishing songs')
|
self.stdout.write('Skipped publishing songs')
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
23
savepointradio/radio/migrations/0005_replaygain_data.py
Normal file
23
savepointradio/radio/migrations/0005_replaygain_data.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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,13 +291,13 @@ 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
|
||||||
|
|
|
@ -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',
|
|
@ -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',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
'''
|
|
Loading…
Reference in a new issue