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 json
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
from decouple import config
|
||||
import requests
|
||||
|
||||
|
||||
DJ_TOKEN = 'place_generated_token_here'
|
||||
DJ_TOKEN = config('DJ_TOKEN')
|
||||
|
||||
API_URL = 'https://savepointradio.net/api/'
|
||||
API_URL = config('API_URL') # With trailing slash
|
||||
|
||||
RADIO_NAME = 'Save Point Radio'
|
||||
RADIO_NAME = config('RADIO_NAME')
|
||||
|
||||
HEADERS = {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Authorization': 'Token {}'.format(DJ_TOKEN)
|
||||
}
|
||||
|
||||
ANNOTATE = 'annotate:req_id="{}",type="{}",artist="{}",title="{}",game="{}":{}'
|
||||
ANNOTATE = (
|
||||
'annotate:req_id="{}",'
|
||||
'type="{}",'
|
||||
'artist="{}",'
|
||||
'title="{}",'
|
||||
'game="{}",'
|
||||
'replay_gain="{}":{}'
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
handlers=[
|
||||
RotatingFileHandler(
|
||||
'./song_requests.log',
|
||||
maxBytes=1000000,
|
||||
backupCount=5,
|
||||
encoding='utf8'
|
||||
)
|
||||
],
|
||||
level=logging.INFO,
|
||||
format=('[%(asctime)s] [%(levelname)s]'
|
||||
' [%(name)s.%(funcName)s] === %(message)s'),
|
||||
datefmt='%Y-%m-%dT%H:%M:%S'
|
||||
)
|
||||
LOGGER = logging.getLogger('djcontrol')
|
||||
|
||||
|
||||
def clean_quotes(unclean_string):
|
||||
|
@ -43,40 +69,28 @@ def beautify_artists(artists):
|
|||
return clean_quotes(output.join(artists))
|
||||
|
||||
|
||||
description = 'Lets the DJ control the radio.'
|
||||
|
||||
parser = argparse.ArgumentParser(description=description)
|
||||
subparsers = parser.add_subparsers(dest='command')
|
||||
|
||||
parser_next = subparsers.add_parser('next',
|
||||
help='Gets the next song from the radio.')
|
||||
parser_played = subparsers.add_parser('played',
|
||||
help='Tells the radio which song just played.')
|
||||
parser_played.add_argument('request',
|
||||
help='Song request ID number.',
|
||||
nargs=1,
|
||||
type=int)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == 'next':
|
||||
def next_request():
|
||||
'''
|
||||
Sends an HTTP[S] request to the radio web service to retrieve the next
|
||||
requested song.
|
||||
'''
|
||||
LOGGER.debug('Received command to get next song request.')
|
||||
try:
|
||||
r = requests.get(API_URL + 'next/',
|
||||
headers=HEADERS,
|
||||
timeout=5)
|
||||
r.encoding = 'utf-8'
|
||||
r.raise_for_status()
|
||||
resp = requests.get(API_URL + 'next/', headers=HEADERS, timeout=5)
|
||||
resp.encoding = 'utf-8'
|
||||
resp.raise_for_status()
|
||||
except requests.exceptions.HTTPError as errh:
|
||||
print('Http Error: {}'.format(errh))
|
||||
LOGGER.error('Http Error: %s', errh)
|
||||
except requests.exceptions.ConnectionError as errc:
|
||||
print('Error Connecting: {}'.format(errc))
|
||||
LOGGER.error('Error Connecting: %s', errc)
|
||||
except requests.exceptions.Timeout as errt:
|
||||
print('Timeout Error: {}'.format(errt))
|
||||
LOGGER.error('Timeout Error: %s', errt)
|
||||
except requests.exceptions.RequestException as err:
|
||||
print('Error: {}'.format(err))
|
||||
LOGGER.error('Error: %s', err)
|
||||
else:
|
||||
req = json.loads(r.text)
|
||||
song = req['song']
|
||||
LOGGER.debug('Received JSON response: %s', resp.text)
|
||||
song_request = json.loads(resp.text)
|
||||
song = song_request['song']
|
||||
if song['song_type'] == 'J':
|
||||
artist = RADIO_NAME
|
||||
title = 'Jingle'
|
||||
|
@ -85,27 +99,86 @@ if args.command == 'next':
|
|||
artist = beautify_artists(song['artists'])
|
||||
title = clean_quotes(song['title'])
|
||||
game = clean_quotes(song['game'])
|
||||
print(ANNOTATE.format(req['id'],
|
||||
LOGGER.info(
|
||||
'ID: %s, Artist[s]: %s, Title: %s, Game: %s, Gain: %s, Path: %s',
|
||||
song_request['id'],
|
||||
artist,
|
||||
title,
|
||||
game,
|
||||
song['replaygain'],
|
||||
song['path']
|
||||
)
|
||||
annotate_string = ANNOTATE.format(
|
||||
song_request['id'],
|
||||
song['song_type'],
|
||||
artist,
|
||||
title,
|
||||
game,
|
||||
song['path']))
|
||||
elif args.command == 'played':
|
||||
song['replaygain'],
|
||||
song['path']
|
||||
)
|
||||
LOGGER.debug(annotate_string)
|
||||
print(annotate_string)
|
||||
|
||||
|
||||
def just_played(request_id):
|
||||
'''
|
||||
Sends an HTTP[S] request to the radio web service to let it know that a
|
||||
song has been played.
|
||||
'''
|
||||
LOGGER.debug('Received command to report a song was just played.')
|
||||
try:
|
||||
req_played = json.dumps({'song_request': args.request[0]})
|
||||
r = requests.post(API_URL + 'played/',
|
||||
request_played = json.dumps({'song_request': request_id})
|
||||
resp = requests.post(
|
||||
API_URL + 'played/',
|
||||
headers=HEADERS,
|
||||
data=req_played,
|
||||
timeout=5)
|
||||
r.encoding = 'utf-8'
|
||||
r.raise_for_status()
|
||||
data=request_played,
|
||||
timeout=5
|
||||
)
|
||||
resp.encoding = 'utf-8'
|
||||
resp.raise_for_status()
|
||||
except requests.exceptions.HTTPError as errh:
|
||||
print('Http Error: {}'.format(errh))
|
||||
print(r.text)
|
||||
LOGGER.error('Http Error: %s', errh)
|
||||
except requests.exceptions.ConnectionError as errc:
|
||||
print('Error Connecting: {}'.format(errc))
|
||||
LOGGER.error('Error Connecting: %s', errc)
|
||||
except requests.exceptions.Timeout as errt:
|
||||
print('Timeout Error: {}'.format(errt))
|
||||
LOGGER.error('Timeout Error: %s', errt)
|
||||
except requests.exceptions.RequestException as err:
|
||||
print('Error: {}'.format(err))
|
||||
LOGGER.error('Error: %s', err)
|
||||
else:
|
||||
LOGGER.info('Req_ID: %s', request_id)
|
||||
|
||||
|
||||
def main():
|
||||
'''Main loop of the program'''
|
||||
description = 'Lets the DJ control the radio.'
|
||||
|
||||
parser = argparse.ArgumentParser(description=description)
|
||||
subparsers = parser.add_subparsers(dest='command')
|
||||
|
||||
parser_next = subparsers.add_parser(
|
||||
'next',
|
||||
help='Gets the next song from the radio.'
|
||||
)
|
||||
|
||||
parser_played = subparsers.add_parser(
|
||||
'played',
|
||||
help='Tells the radio which song just played.'
|
||||
)
|
||||
parser_played.add_argument(
|
||||
'request',
|
||||
help='Song request ID number.',
|
||||
nargs=1,
|
||||
type=int
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == 'next':
|
||||
next_request()
|
||||
elif args.command == 'played':
|
||||
just_played(args.request[0])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
Binary file not shown.
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
|
||||
cffi>=1.11.5
|
||||
argon2-cffi>=19.1.0
|
||||
cffi>=1.12.3
|
||||
dj-database-url>=0.5.0
|
||||
Django>=2.1.5
|
||||
Django>=2.2.2
|
||||
django-authtools>=1.6.0
|
||||
djangorestframework>=3.9.0
|
||||
psycopg2>=2.7.6.1
|
||||
djangorestframework>=3.9.4
|
||||
psycopg2-binary>=2.8.2
|
||||
pycparser>=2.19
|
||||
python-decouple>=3.1
|
||||
pytz>=2018.9
|
||||
pytz>=2019.1
|
||||
six>=1.12.0
|
||||
sqlparse>=0.3.0
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
from rest_framework.serializers import (IntegerField,
|
||||
ModelSerializer,
|
||||
Serializer,
|
||||
StringRelatedField)
|
||||
Serializer)
|
||||
|
||||
from profiles.models import SongRequest
|
||||
from .radio import RadioSongSerializer
|
||||
|
|
|
@ -4,7 +4,7 @@ from rest_framework.serializers import (IntegerField, ModelSerializer,
|
|||
Serializer)
|
||||
|
||||
from profiles.models import RadioProfile, SongRequest, Rating
|
||||
from .radio import BasicSongRetrieveSerializer
|
||||
from .radio import SongMinimalSerializer
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
@ -44,7 +44,7 @@ class RateSongSerializer(Serializer):
|
|||
|
||||
class HistorySerializer(ModelSerializer):
|
||||
profile = BasicProfileSerializer()
|
||||
song = BasicSongRetrieveSerializer()
|
||||
song = SongMinimalSerializer()
|
||||
|
||||
class Meta:
|
||||
model = SongRequest
|
||||
|
@ -52,7 +52,7 @@ class HistorySerializer(ModelSerializer):
|
|||
|
||||
|
||||
class BasicProfileRatingsSerializer(ModelSerializer):
|
||||
song = BasicSongRetrieveSerializer()
|
||||
song = SongMinimalSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Rating
|
||||
|
|
|
@ -1,71 +1,155 @@
|
|||
from rest_framework.serializers import (IntegerField, ListField,
|
||||
from rest_framework.serializers import (BooleanField, CharField, DecimalField,
|
||||
IntegerField, ListField,
|
||||
ModelSerializer, Serializer,
|
||||
SerializerMethodField,
|
||||
StringRelatedField)
|
||||
|
||||
from radio.models import Album, Artist, Game, Song
|
||||
from core.utils import iri_to_path
|
||||
from radio.models import Album, Artist, Game, Song, Store
|
||||
|
||||
|
||||
class AlbumSerializer(ModelSerializer):
|
||||
'''A base serializer for an album model.'''
|
||||
class Meta:
|
||||
model = Album
|
||||
fields = ('id', 'title')
|
||||
|
||||
|
||||
class ArtistSerializer(ModelSerializer):
|
||||
'''A base serializer for an artist model.'''
|
||||
class Meta:
|
||||
model = Artist
|
||||
fields = ('id', 'alias', 'first_name', 'last_name')
|
||||
|
||||
|
||||
class ArtistFullnameSerializer(ModelSerializer):
|
||||
'''
|
||||
A base serializer for an artist model, but combining all name
|
||||
attributes into one field.
|
||||
'''
|
||||
class Meta:
|
||||
model = Artist
|
||||
fields = ('id', 'full_name')
|
||||
|
||||
|
||||
class GameSerializer(ModelSerializer):
|
||||
'''A base serializer for a game model.'''
|
||||
class Meta:
|
||||
model = Game
|
||||
fields = ('id', 'title')
|
||||
|
||||
|
||||
class BasicSongSerializer(ModelSerializer):
|
||||
class StoreSerializer(ModelSerializer):
|
||||
'''A base serializer for a data store model.'''
|
||||
active = SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Song
|
||||
fields = ('id', 'album', 'artists', 'game', 'title', 'average_rating',
|
||||
'is_requestable')
|
||||
model = Store
|
||||
fields = ('id', 'active', 'iri', 'file_size', 'length', 'mime_type')
|
||||
|
||||
def get_active(self, obj):
|
||||
'''Checks to see if this store is active for a song.'''
|
||||
if obj.active_for.all():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class FullSongSerializer(ModelSerializer):
|
||||
class SongSerializer(ModelSerializer):
|
||||
'''A base serializer for a song model.'''
|
||||
length = DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
source='active_store.length'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Song
|
||||
fields = ('id', 'album', 'artists', 'published_date', 'game',
|
||||
'num_played', 'last_played', 'length', 'song_type', 'title',
|
||||
'average_rating', 'is_requestable')
|
||||
'num_played', 'last_played', 'length', 'next_play',
|
||||
'song_type', 'title', 'average_rating', 'is_requestable')
|
||||
|
||||
|
||||
class BasicSongRetrieveSerializer(BasicSongSerializer):
|
||||
class SongMinimalSerializer(ModelSerializer):
|
||||
'''Minimal song information, usually appended to favorites/ratings.'''
|
||||
album = AlbumSerializer()
|
||||
artists = ArtistFullnameSerializer(many=True)
|
||||
game = GameSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Song
|
||||
fields = ('id', 'album', 'artists', 'game', 'title')
|
||||
|
||||
class FullSongRetrieveSerializer(FullSongSerializer):
|
||||
|
||||
class SongListSerializer(ModelSerializer):
|
||||
'''Song information used in large listings.'''
|
||||
album = AlbumSerializer()
|
||||
artists = ArtistFullnameSerializer(many=True)
|
||||
game = GameSerializer()
|
||||
length = DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
source='active_store.length'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Song
|
||||
fields = ('id', 'album', 'artists', 'game', 'title', 'average_rating',
|
||||
'length', 'is_requestable')
|
||||
|
||||
|
||||
class SongRetrieveSerializer(SongSerializer):
|
||||
'''
|
||||
An almost complete listing of a song's information, based on a single
|
||||
object retrieval.
|
||||
'''
|
||||
album = AlbumSerializer()
|
||||
artists = ArtistSerializer(many=True)
|
||||
game = GameSerializer()
|
||||
|
||||
|
||||
class RadioSongSerializer(ModelSerializer):
|
||||
'''
|
||||
A song serializer that is specific to the radio DJ and the underlying
|
||||
audio manipulation application.
|
||||
'''
|
||||
album = StringRelatedField()
|
||||
artists = StringRelatedField(many=True)
|
||||
game = StringRelatedField()
|
||||
length = DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
source='active_store.length'
|
||||
)
|
||||
replaygain = CharField(source='active_store.replaygain')
|
||||
path = SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Song
|
||||
fields = ('album', 'artists', 'game', 'song_type', 'title', 'length',
|
||||
'path')
|
||||
'replaygain', 'path')
|
||||
|
||||
def get_path(self, obj):
|
||||
'''Converts the IRI into a filesystem path.'''
|
||||
iri = str(obj.active_store.iri)
|
||||
if iri.startswith('file://'):
|
||||
return iri_to_path(iri)
|
||||
return iri
|
||||
|
||||
|
||||
class SongArtistsListSerializer(Serializer):
|
||||
'''
|
||||
A serializer for adding or removing artists from a song based on
|
||||
the song's id number.
|
||||
'''
|
||||
# TODO: Probably should move to PrimaryKeyRelatedField.
|
||||
artists = ListField(child=IntegerField(), min_length=1, max_length=10)
|
||||
|
||||
|
||||
class SongStoresSerializer(Serializer):
|
||||
'''
|
||||
A serializer for adding or removing a data store from a song based on
|
||||
the song's id number.
|
||||
'''
|
||||
# TODO: Probably should move to PrimaryKeyRelatedField.
|
||||
store = IntegerField()
|
||||
set_active = BooleanField(default=False)
|
||||
|
|
|
@ -4,8 +4,8 @@ from rest_framework.routers import DefaultRouter
|
|||
|
||||
from api.views.controls import JustPlayed, MakeRequest, NextRequest
|
||||
from api.views.profiles import HistoryViewSet, ProfileViewSet
|
||||
from api.views.radio import (AlbumViewSet, ArtistViewSet,
|
||||
GameViewSet, SongViewSet)
|
||||
from api.views.radio import (AlbumViewSet, ArtistViewSet, GameViewSet,
|
||||
StoreViewSet, SongViewSet)
|
||||
|
||||
|
||||
class OptionalSlashRouter(DefaultRouter):
|
||||
|
@ -28,6 +28,7 @@ router.register(r'profiles', ProfileViewSet, base_name='profile')
|
|||
router.register(r'albums', AlbumViewSet, base_name='album')
|
||||
router.register(r'artists', ArtistViewSet, base_name='artist')
|
||||
router.register(r'games', GameViewSet, base_name='game')
|
||||
router.register(r'stores', StoreViewSet, base_name='store')
|
||||
router.register(r'songs', SongViewSet, base_name='song')
|
||||
|
||||
urlpatterns = [
|
||||
|
|
|
@ -11,7 +11,7 @@ from ..serializers.profiles import (BasicProfileSerializer,
|
|||
FullProfileSerializer,
|
||||
HistorySerializer,
|
||||
BasicProfileRatingsSerializer)
|
||||
from ..serializers.radio import BasicSongRetrieveSerializer
|
||||
from ..serializers.radio import SongListSerializer
|
||||
|
||||
|
||||
class ProfileViewSet(viewsets.ModelViewSet):
|
||||
|
@ -52,10 +52,10 @@ class ProfileViewSet(viewsets.ModelViewSet):
|
|||
|
||||
page = self.paginate_queryset(favorites)
|
||||
if page is not None:
|
||||
serializer = BasicSongRetrieveSerializer(page, many=True)
|
||||
serializer = SongListSerializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = BasicSongRetrieveSerializer(favorites, many=True)
|
||||
serializer = SongListSerializer(favorites, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, permission_classes=[AllowAny])
|
||||
|
|
|
@ -4,15 +4,17 @@ from rest_framework.permissions import AllowAny, IsAdminUser
|
|||
from rest_framework.response import Response
|
||||
|
||||
from profiles.models import RadioProfile, Rating
|
||||
from radio.models import Album, Artist, Game, Song
|
||||
from radio.models import Album, Artist, Game, Song, Store
|
||||
from ..permissions import IsAdminOrReadOnly, IsAuthenticatedAndNotDJ
|
||||
from ..serializers.profiles import (BasicProfileSerializer,
|
||||
BasicSongRatingsSerializer,
|
||||
RateSongSerializer)
|
||||
from ..serializers.radio import (AlbumSerializer, ArtistSerializer,
|
||||
GameSerializer, FullSongSerializer,
|
||||
GameSerializer, StoreSerializer,
|
||||
SongSerializer, SongListSerializer,
|
||||
SongRetrieveSerializer,
|
||||
SongArtistsListSerializer,
|
||||
FullSongRetrieveSerializer)
|
||||
SongStoresSerializer)
|
||||
|
||||
|
||||
class AlbumViewSet(viewsets.ModelViewSet):
|
||||
|
@ -63,6 +65,12 @@ class GameViewSet(viewsets.ModelViewSet):
|
|||
return Game.music.available()
|
||||
|
||||
|
||||
class StoreViewSet(viewsets.ModelViewSet):
|
||||
queryset = Store.objects.all()
|
||||
permission_classes = [IsAdminUser]
|
||||
serializer_class = StoreSerializer
|
||||
|
||||
|
||||
class SongViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAdminOrReadOnly]
|
||||
|
||||
|
@ -83,38 +91,99 @@ class SongViewSet(viewsets.ModelViewSet):
|
|||
|
||||
(Thanks to https://stackoverflow.com/questions/22616973/)
|
||||
'''
|
||||
if self.action in ['list', 'retrieve']:
|
||||
return FullSongRetrieveSerializer
|
||||
return FullSongSerializer
|
||||
if self.action == 'list':
|
||||
return SongListSerializer
|
||||
if self.action == 'retrieve':
|
||||
return SongRetrieveSerializer
|
||||
return SongSerializer
|
||||
|
||||
def _artists_change(self, request, remove=False):
|
||||
song = self.get_object()
|
||||
serializer = SongArtistsListSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
artists = Artist.objects.filter(pk__in=serializer.data['artists'])
|
||||
|
||||
for artist in artists:
|
||||
if remove:
|
||||
song.artists.remove(artist)
|
||||
else:
|
||||
song.artists.add(artist)
|
||||
|
||||
song.save()
|
||||
|
||||
if song.artists.count() == 0:
|
||||
song.disable('No artists specified for song.')
|
||||
|
||||
message = 'Artists {} song.'.format(('added to',
|
||||
'removed from')[remove])
|
||||
return Response({'detail': message})
|
||||
else:
|
||||
return Response(serializer.errors,
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(methods=['post'], detail=True, permission_classes=[IsAdminUser])
|
||||
def artists_add(self, request, pk=None):
|
||||
'''Adds an artist to a song.'''
|
||||
return self._artists_change(request)
|
||||
|
||||
@action(methods=['post'], detail=True, permission_classes=[IsAdminUser])
|
||||
def artists_remove(self, request, pk=None):
|
||||
'''Removes an artist from a song.'''
|
||||
return self._artists_change(request, remove=True)
|
||||
|
||||
def _store_change(self, request, remove=False):
|
||||
song = self.get_object()
|
||||
serializer = SongStoresSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
try:
|
||||
store = Store.objects.get(pk=serializer.data['store'])
|
||||
except Store.DoesNotExist:
|
||||
return Response({'detail': 'Store does not exist.'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if remove:
|
||||
song.stores.remove(store)
|
||||
else:
|
||||
song.stores.add(store)
|
||||
|
||||
if serializer.data['set_active'] and not remove:
|
||||
song.active_store = store
|
||||
|
||||
song.save()
|
||||
|
||||
if song.stores.count() == 0:
|
||||
song.disable('No stores specified for song.')
|
||||
|
||||
message = 'Store {} song.'.format(('added to',
|
||||
'removed from')[remove])
|
||||
return Response({'detail': message})
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(methods=['post'], detail=True, permission_classes=[IsAdminUser])
|
||||
def store_add(self, request, pk=None):
|
||||
'''Adds a data store to a song.'''
|
||||
return self._store_change(request)
|
||||
|
||||
@action(methods=['post'], detail=True, permission_classes=[IsAdminUser])
|
||||
def store_remove(self, request, pk=None):
|
||||
'''Removes a data store from a song.'''
|
||||
return self._store_change(request, remove=True)
|
||||
|
||||
@action(detail=True, permission_classes=[IsAdminUser])
|
||||
def stores(self, request, pk=None):
|
||||
'''Get a list of data stores associate with this song.'''
|
||||
song = self.get_object()
|
||||
stores = song.stores.all().order_by('-created_date')
|
||||
|
||||
page = self.paginate_queryset(stores)
|
||||
if page is not None:
|
||||
serializer = StoreSerializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = StoreSerializer(stores, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, permission_classes=[AllowAny])
|
||||
def favorites(self, request, pk=None):
|
||||
'''Get a list of users who added this song to their favorites list.'''
|
||||
song = self.get_object()
|
||||
profiles = song.song_favorites.all().order_by('user__name')
|
||||
|
||||
|
@ -130,6 +199,7 @@ class SongViewSet(viewsets.ModelViewSet):
|
|||
detail=True,
|
||||
permission_classes=[IsAuthenticatedAndNotDJ])
|
||||
def favorite(self, request, pk=None):
|
||||
'''Add a song to the user's favorites list.'''
|
||||
song = self.get_object()
|
||||
profile = RadioProfile.objects.get(user=request.user)
|
||||
if song not in profile.favorites.all():
|
||||
|
@ -144,6 +214,7 @@ class SongViewSet(viewsets.ModelViewSet):
|
|||
detail=True,
|
||||
permission_classes=[IsAuthenticatedAndNotDJ])
|
||||
def unfavorite(self, request, pk=None):
|
||||
'''Remove a song from the user's favorites list.'''
|
||||
song = self.get_object()
|
||||
profile = RadioProfile.objects.get(user=request.user)
|
||||
if song in profile.favorites.all():
|
||||
|
@ -157,6 +228,7 @@ class SongViewSet(viewsets.ModelViewSet):
|
|||
|
||||
@action(detail=True, permission_classes=[AllowAny])
|
||||
def ratings(self, request, pk=None):
|
||||
'''Get a list of a song's ratings.'''
|
||||
song = self.get_object()
|
||||
ratings = song.rating_set.all().order_by('-created_date')
|
||||
|
||||
|
@ -172,6 +244,7 @@ class SongViewSet(viewsets.ModelViewSet):
|
|||
detail=True,
|
||||
permission_classes=[IsAuthenticatedAndNotDJ])
|
||||
def rate(self, request, pk=None):
|
||||
'''Add a user's rating to a song.'''
|
||||
serializer = RateSongSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
song = self.get_object()
|
||||
|
@ -195,6 +268,7 @@ class SongViewSet(viewsets.ModelViewSet):
|
|||
detail=True,
|
||||
permission_classes=[IsAuthenticatedAndNotDJ])
|
||||
def unrate(self, request, pk=None):
|
||||
'''Remove a user's rating from a song.'''
|
||||
song = self.get_object()
|
||||
profile = RadioProfile.objects.get(user=request.user)
|
||||
rating = song.rating_set.filter(profile=profile)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class Disableable(models.Model):
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
from django.db import models
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from authtools.models import AbstractNamedUser
|
||||
|
||||
|
|
|
@ -1,25 +1,59 @@
|
|||
'''
|
||||
Various utlity functions that are independant of any Django app or
|
||||
model.
|
||||
'''
|
||||
|
||||
from nturl2path import pathname2url as ntpathname2url
|
||||
from nturl2path import url2pathname as url2ntpathname
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
from unicodedata import normalize
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from urllib.request import pathname2url, url2pathname
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import connection
|
||||
from django.utils.encoding import iri_to_uri, uri_to_iri
|
||||
|
||||
from .models import Setting
|
||||
|
||||
|
||||
GROUP_NT_UNC = r'file://[A-Za-z0-9!@#$%^&\'\)\(\.\-_{}~]+/'
|
||||
|
||||
GROUP_NT_DRIVE_LETTER = r'file:///[A-Za-z](?:\:|\|)/'
|
||||
|
||||
GROUP_NON_AUTH = r'file:///[A-Za-z0-9!@#$%^&\'\)\(\.\-_{}~]+'
|
||||
|
||||
FILE_IRI_PATTERN = (
|
||||
r'^(?P<unc>' +
|
||||
GROUP_NT_UNC +
|
||||
r')|(?P<driveletter>' +
|
||||
GROUP_NT_DRIVE_LETTER +
|
||||
r')|(?P<nonauth>' +
|
||||
GROUP_NON_AUTH +
|
||||
r')'
|
||||
)
|
||||
|
||||
|
||||
def generate_password(length=32):
|
||||
'''
|
||||
Quick and dirty random password generator.
|
||||
|
||||
***WARNING*** - Although this is likely "good enough" to create a secure
|
||||
password, there are no validations (suitible entropy, dictionary words,
|
||||
etc.) and should not be completely trusted. YOU HAVE BEEN WARNED.
|
||||
'''
|
||||
chars = string.ascii_letters + string.digits + string.punctuation
|
||||
rng = random.SystemRandom()
|
||||
return ''.join([rng.choice(chars) for i in range(length)])
|
||||
|
||||
|
||||
def get_len(rawqueryset):
|
||||
"""
|
||||
'''
|
||||
Adds/Overrides a dynamic implementation of the length protocol to the
|
||||
definition of RawQuerySet.
|
||||
"""
|
||||
'''
|
||||
def __len__(self):
|
||||
params = ['{}'.format(p) for p in self.params]
|
||||
sql = ''.join(('SELECT COUNT(*) FROM (',
|
||||
|
@ -33,11 +67,13 @@ def get_len(rawqueryset):
|
|||
|
||||
|
||||
def get_setting(name):
|
||||
'''Helper function to get dynamic settings from the database.'''
|
||||
setting = Setting.objects.get(name=name)
|
||||
return setting.get()
|
||||
|
||||
|
||||
def set_setting(name, value, setting_type=None):
|
||||
'''Helper function to set dynamic settings from the database.'''
|
||||
setting_types = {'Integer': 0, 'Float': 1, 'String': 2, 'Bool': 3}
|
||||
try:
|
||||
setting = Setting.objects.get(name=name)
|
||||
|
@ -57,13 +93,13 @@ def set_setting(name, value, setting_type=None):
|
|||
|
||||
|
||||
def naturalize(text):
|
||||
"""
|
||||
'''
|
||||
Return a normalized unicode string, with removed starting articles, for use
|
||||
in natural sorting.
|
||||
|
||||
Code was inspired by 'django-naturalsortfield' from Nathan Reynolds:
|
||||
https://github.com/nathforge/django-naturalsortfield
|
||||
"""
|
||||
'''
|
||||
def naturalize_int_match(match):
|
||||
return '{:08d}'.format(int(match.group(0)))
|
||||
|
||||
|
@ -79,9 +115,9 @@ def naturalize(text):
|
|||
|
||||
|
||||
def quantify(quantity, model):
|
||||
"""
|
||||
'''
|
||||
A message based on the quantity and singular/plural name of the model.
|
||||
"""
|
||||
'''
|
||||
if quantity == 1:
|
||||
message = '1 {}'.format(model._meta.verbose_name)
|
||||
else:
|
||||
|
@ -92,21 +128,20 @@ def quantify(quantity, model):
|
|||
|
||||
def create_success_message(parent_model, parent_quantity, child_model,
|
||||
child_quantity, remove=False):
|
||||
"""
|
||||
'''
|
||||
Creates a message for displaying the success of model modification.
|
||||
"""
|
||||
'''
|
||||
p_message = quantify(parent_quantity, parent_model)
|
||||
c_message = quantify(child_quantity, child_model)
|
||||
if remove:
|
||||
return '{} successfully removed from {}'.format(c_message, p_message)
|
||||
else:
|
||||
return '{} successfully added to {}.'.format(c_message, p_message)
|
||||
|
||||
|
||||
def get_pretty_time(seconds):
|
||||
"""
|
||||
'''
|
||||
Displays a human-readable representation of time.
|
||||
"""
|
||||
'''
|
||||
if seconds > 0:
|
||||
periods = [
|
||||
('year', 60*60*24*365.25),
|
||||
|
@ -123,5 +158,36 @@ def get_pretty_time(seconds):
|
|||
period_name,
|
||||
('s', '')[period_value == 1]))
|
||||
return ', '.join(strings)
|
||||
else:
|
||||
return 'Now'
|
||||
|
||||
|
||||
def path_to_iri(path):
|
||||
'''
|
||||
OS-independant attempt at converting any OS absolute path to an
|
||||
RFC3987-defined IRI along with the file scheme from RFC8089.
|
||||
'''
|
||||
# Looking to see if the path starts with a drive letter or UNC path
|
||||
# (eg. 'D:\' or '\\')
|
||||
windows = re.match(r'^(?:[A-Za-z]:|\\)\\', path)
|
||||
if windows:
|
||||
return uri_to_iri(urljoin('file:', ntpathname2url(path)))
|
||||
return uri_to_iri(urljoin('file:', pathname2url(path)))
|
||||
|
||||
|
||||
def iri_to_path(iri):
|
||||
'''
|
||||
OS-independant attempt at converting an RFC3987-defined IRI with a file
|
||||
scheme from RFC8089 to an OS-specific absolute path.
|
||||
'''
|
||||
# Drive letter IRI will have three slashes followed by the drive letter
|
||||
# UNC path IRI will have two slashes followed by the UNC path
|
||||
uri = iri_to_uri(iri)
|
||||
patt = r'^(?:' + GROUP_NT_DRIVE_LETTER + r'|' + GROUP_NT_UNC + r')'
|
||||
windows = re.match(patt, uri)
|
||||
if windows:
|
||||
parse = urlparse(uri)
|
||||
# UNC path URIs put the server name in the 'netloc' parameter.
|
||||
if parse.netloc:
|
||||
return '\\' + url2ntpathname('/' + parse.netloc + parse.path)
|
||||
return url2ntpathname(parse.path)
|
||||
return url2pathname(urlparse(uri).path)
|
||||
|
|
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)
|
||||
class RequestAdmin(admin.ModelAdmin):
|
||||
model = SongRequest
|
||||
# Detail List display
|
||||
list_display = ('get_user',
|
||||
'song',
|
||||
'created_date',
|
||||
'queued_at',
|
||||
'played_at')
|
||||
search_fields = ['song', 'profile']
|
||||
|
||||
# Edit Form display
|
||||
readonly_fields = (
|
||||
'created_date',
|
||||
'modified_date',
|
||||
'profile',
|
||||
'song',
|
||||
'queued_at',
|
||||
'played_at'
|
||||
)
|
||||
|
||||
verbose_name = 'request'
|
||||
verbose_name_plural = 'requests'
|
||||
extra = 0
|
||||
|
||||
def get_user(self, obj):
|
||||
'''Returns the username from the profile.'''
|
||||
return obj.profile.user
|
||||
get_user.admin_order_field = 'profile'
|
||||
get_user.short_description = 'User Name'
|
||||
|
|
|
@ -2,7 +2,7 @@ from django.conf import settings
|
|||
from django.core.validators import (MaxLengthValidator, MinValueValidator,
|
||||
MaxValueValidator)
|
||||
from django.db import models
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.behaviors import Disableable, Timestampable
|
||||
from core.utils import get_setting
|
||||
|
|
|
@ -3,7 +3,7 @@ from django.db import models
|
|||
from django.forms import TextInput
|
||||
|
||||
from .actions import change_items, publish_items, remove_items
|
||||
from .models import Album, Artist, Game, Song
|
||||
from .models import Album, Artist, Game, Song, Store
|
||||
|
||||
|
||||
class ArtistInline(admin.TabularInline):
|
||||
|
@ -13,6 +13,13 @@ class ArtistInline(admin.TabularInline):
|
|||
extra = 0
|
||||
|
||||
|
||||
class StoreInline(admin.TabularInline):
|
||||
model = Song.stores.through
|
||||
verbose_name = 'data store'
|
||||
verbose_name_plural = 'data stores'
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(Album)
|
||||
class AlbumAdmin(admin.ModelAdmin):
|
||||
# Detail List display
|
||||
|
@ -101,6 +108,34 @@ class GameAdmin(admin.ModelAdmin):
|
|||
publish_games.short_description = "Publish selected games"
|
||||
|
||||
|
||||
@admin.register(Store)
|
||||
class StoreAdmin(admin.ModelAdmin):
|
||||
# Detail List display
|
||||
list_display = ('iri',
|
||||
'mime_type',
|
||||
'file_size',
|
||||
'length',
|
||||
'_replaygain')
|
||||
search_fields = ['iri']
|
||||
|
||||
# Edit Form display
|
||||
readonly_fields = ('created_date', 'modified_date')
|
||||
fieldsets = (
|
||||
('Main', {
|
||||
'fields': ('iri',
|
||||
'mime_type',
|
||||
'file_size',
|
||||
'length',
|
||||
'track_gain',
|
||||
'track_peak')
|
||||
}),
|
||||
('Stats', {
|
||||
'classes': ('collapse',),
|
||||
'fields': ('created_date', 'modified_date')
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Song)
|
||||
class SongAdmin(admin.ModelAdmin):
|
||||
formfield_overrides = {
|
||||
|
@ -123,8 +158,7 @@ class SongAdmin(admin.ModelAdmin):
|
|||
|
||||
# Edit Form display
|
||||
exclude = ('artists',)
|
||||
readonly_fields = ('length',
|
||||
'last_played',
|
||||
readonly_fields = ('last_played',
|
||||
'num_played',
|
||||
'created_date',
|
||||
'modified_date',
|
||||
|
@ -137,8 +171,8 @@ class SongAdmin(admin.ModelAdmin):
|
|||
('Main', {
|
||||
'fields': ('song_type',
|
||||
'title',
|
||||
'path',
|
||||
'published_date')
|
||||
'published_date',
|
||||
'active_store')
|
||||
}),
|
||||
('Stats', {
|
||||
'classes': ('collapse',),
|
||||
|
@ -146,8 +180,7 @@ class SongAdmin(admin.ModelAdmin):
|
|||
'modified_date',
|
||||
'last_played',
|
||||
'num_played',
|
||||
'next_play',
|
||||
'length')
|
||||
'next_play')
|
||||
}),
|
||||
('Album', {
|
||||
'fields': ('album',)
|
||||
|
@ -156,7 +189,14 @@ class SongAdmin(admin.ModelAdmin):
|
|||
'fields': ('game',)
|
||||
})
|
||||
)
|
||||
inlines = [ArtistInline]
|
||||
inlines = [ArtistInline, StoreInline]
|
||||
|
||||
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||
if db_field.name == 'active_store':
|
||||
kwargs['queryset'] = Store.objects.filter(
|
||||
song__pk=request.resolver_match.kwargs['object_id']
|
||||
)
|
||||
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
||||
|
||||
def artist_list(self, obj):
|
||||
return ', '.join([a.full_name for a in obj.artists.all()])
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class Disableable(models.Model):
|
||||
|
|
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 sqlite3
|
||||
import re
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from radio.models import Album, Artist, Game, Song
|
||||
from core.utils import path_to_iri
|
||||
from radio.models import Album, Artist, Game, Store, Song
|
||||
|
||||
getcontext().prec = 8
|
||||
|
||||
|
||||
def adapt_decimal(d):
|
||||
return str(d)
|
||||
|
||||
|
||||
def convert_decimal(s):
|
||||
return Decimal(s.decode('utf8'))
|
||||
|
||||
|
||||
sqlite3.register_adapter(Decimal, adapt_decimal)
|
||||
sqlite3.register_converter("decimal", convert_decimal)
|
||||
decimal.getcontext().prec = 8
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Imports the old radio data from the original sqlite3 database'
|
||||
'''Main "importoldreadio" command class'''
|
||||
help = 'Imports the old radio data from an exported playlist'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('sqlite3_db_file', nargs=1)
|
||||
parser.add_argument('playlist_file', nargs=1)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if not os.path.isfile(options['sqlite3_db_file'][0]):
|
||||
playlist_file = options['playlist_file'][0]
|
||||
if not os.path.isfile(playlist_file):
|
||||
raise CommandError('File does not exist')
|
||||
else:
|
||||
total_albums = 0
|
||||
total_artists = 0
|
||||
total_games = 0
|
||||
total_songs = 0
|
||||
total_jingles = 0
|
||||
|
||||
detect_types = sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES
|
||||
con = sqlite3.connect(options['sqlite3_db_file'][0],
|
||||
detect_types=detect_types)
|
||||
cur = con.cursor()
|
||||
with open(playlist_file, 'r', encoding='utf8') as pfile:
|
||||
playlist = json.load(pfile, parse_float=decimal.Decimal)
|
||||
|
||||
totals = {
|
||||
'albums': 0,
|
||||
'artists': 0,
|
||||
'games': 0,
|
||||
'songs': 0,
|
||||
'jingles': 0
|
||||
}
|
||||
|
||||
# Fetching albums first
|
||||
for album in con.execute('SELECT title, enabled FROM albums'):
|
||||
album_disabled = not bool(album[1])
|
||||
Album.objects.create(title=album[0], disabled=album_disabled)
|
||||
total_albums += 1
|
||||
for album in playlist['albums']:
|
||||
Album.objects.create(title=album['title'],
|
||||
disabled=album['disabled'])
|
||||
totals['albums'] += 1
|
||||
|
||||
self.stdout.write('Imported {} albums'.format(str(total_albums)))
|
||||
self.stdout.write('Imported {} albums'.format(str(totals['albums'])))
|
||||
|
||||
# Next up, artists
|
||||
cur.execute('''SELECT
|
||||
artists_id,
|
||||
alias,
|
||||
firstname,
|
||||
lastname,
|
||||
enabled
|
||||
FROM artists''')
|
||||
artists = cur.fetchall()
|
||||
for artist in playlist['artists']:
|
||||
Artist.objects.create(alias=artist['alias'] or '',
|
||||
first_name=artist['first_name'] or '',
|
||||
last_name=artist['last_name'] or '',
|
||||
disabled=artist['disabled'])
|
||||
totals['artists'] += 1
|
||||
|
||||
for artist in artists:
|
||||
artist_disabled = not bool(artist[4])
|
||||
Artist.objects.create(alias=artist[1] or '',
|
||||
first_name=artist[2] or '',
|
||||
last_name=artist[3] or '',
|
||||
disabled=artist_disabled)
|
||||
total_artists += 1
|
||||
|
||||
self.stdout.write('Imported {} artists'.format(str(total_artists)))
|
||||
self.stdout.write('Imported {} artists'.format(str(totals['artists'])))
|
||||
|
||||
# On to games
|
||||
for game in con.execute('SELECT title, enabled FROM games'):
|
||||
game_disabled = not bool(game[1])
|
||||
Game.objects.create(title=game[0], disabled=game_disabled)
|
||||
total_games += 1
|
||||
for game in playlist['games']:
|
||||
Game.objects.create(title=game['title'],
|
||||
disabled=game['disabled'])
|
||||
totals['games'] += 1
|
||||
|
||||
self.stdout.write('Imported {} games'.format(str(total_games)))
|
||||
self.stdout.write('Imported {} games'.format(str(totals['games'])))
|
||||
|
||||
# Followed by the songs
|
||||
cur.execute('''SELECT
|
||||
songs.songs_id AS id,
|
||||
games.title AS game,
|
||||
albums.title AS album,
|
||||
songs.enabled AS enabled,
|
||||
songs.type AS type,
|
||||
songs.title AS title,
|
||||
songs.length AS length,
|
||||
songs.path AS path
|
||||
FROM songs
|
||||
LEFT JOIN games
|
||||
ON (songs.game = games.games_id)
|
||||
LEFT JOIN albums
|
||||
ON (songs.album = albums.albums_id)''')
|
||||
songs = cur.fetchall()
|
||||
|
||||
for song in songs:
|
||||
for song in playlist['songs']:
|
||||
try:
|
||||
album = Album.objects.get(title__exact=song[2])
|
||||
album = Album.objects.get(title__exact=song['album'])
|
||||
except Album.DoesNotExist:
|
||||
album = None
|
||||
|
||||
try:
|
||||
game = Game.objects.get(title__exact=song[1])
|
||||
game = Game.objects.get(title__exact=song['game'])
|
||||
except Game.DoesNotExist:
|
||||
game = None
|
||||
|
||||
song_disabled = not bool(song[3])
|
||||
new_song = Song.objects.create(album=album,
|
||||
game=game,
|
||||
disabled=song_disabled,
|
||||
song_type=song[4],
|
||||
title=song[5],
|
||||
length=song[6],
|
||||
path=song[7])
|
||||
if song[4] == 'S':
|
||||
total_songs += 1
|
||||
else:
|
||||
total_jingles += 1
|
||||
disabled=song['disabled'],
|
||||
song_type=song['type'],
|
||||
title=song['title'])
|
||||
|
||||
cur.execute('''SELECT
|
||||
ifnull(alias, "") AS alias,
|
||||
ifnull(firstname, "") AS firstname,
|
||||
ifnull(lastname, "") AS lastname
|
||||
FROM artists
|
||||
WHERE artists_id
|
||||
IN (SELECT artists_artists_id
|
||||
FROM songs_have_artists
|
||||
WHERE songs_songs_id = ?)''', [song[0]])
|
||||
old_artists = cur.fetchall()
|
||||
for old_artist in old_artists:
|
||||
new_artist = Artist.objects.get(alias__exact=old_artist[0],
|
||||
first_name__exact=old_artist[1],
|
||||
last_name__exact=old_artist[2])
|
||||
for artist in song['artists']:
|
||||
new_artist = Artist.objects.get(
|
||||
alias__exact=artist['alias'] or '',
|
||||
first_name__exact=artist['first_name'] or '',
|
||||
last_name__exact=artist['last_name'] or ''
|
||||
)
|
||||
new_song.artists.add(new_artist)
|
||||
|
||||
localfile = re.match(
|
||||
r'^(?:(?:[A-Za-z]:|\\)\\|\/)',
|
||||
song['store']['path']
|
||||
)
|
||||
|
||||
if localfile:
|
||||
iri = path_to_iri(song['store']['path'])
|
||||
else:
|
||||
iri = song['store']['path']
|
||||
|
||||
if song['store']['track_gain']:
|
||||
gain_str = re.sub(r'[dB\+ ]', '', song['store']['track_gain'])
|
||||
gain = decimal.Decimal(gain_str)
|
||||
else:
|
||||
gain = None
|
||||
|
||||
if song['store']['track_peak']:
|
||||
peak = decimal.Decimal(song['store']['track_peak'])
|
||||
else:
|
||||
peak = None
|
||||
|
||||
new_store = Store.objects.create(
|
||||
iri=iri,
|
||||
mime_type=song['store']['mime'],
|
||||
file_size=song['store']['filesize'],
|
||||
length=song['store']['length'],
|
||||
track_gain=gain,
|
||||
track_peak=peak
|
||||
)
|
||||
new_song.stores.add(new_store)
|
||||
new_song.active_store = new_store
|
||||
new_song.save()
|
||||
if song['type'] == 'S':
|
||||
totals['songs'] += 1
|
||||
else:
|
||||
totals['jingles'] += 1
|
||||
|
||||
self.stdout.write(
|
||||
'Imported {} requestables ({} songs, {} jingles)'.format(
|
||||
str(total_songs + total_jingles),
|
||||
str(total_songs),
|
||||
str(total_jingles)
|
||||
str(totals['songs'] + totals['jingles']),
|
||||
str(totals['songs']),
|
||||
str(totals['jingles'])
|
||||
)
|
||||
)
|
||||
|
||||
pub = input('Do you want to publish all imported objects as well? '
|
||||
'[Y/N] ')
|
||||
|
||||
if pub == 'Y' or pub == 'y':
|
||||
for al in Album.objects.all():
|
||||
al.publish()
|
||||
for ar in Artist.objects.all():
|
||||
ar.publish()
|
||||
for g in Game.objects.all():
|
||||
g.publish()
|
||||
for s in Song.objects.all():
|
||||
s.publish()
|
||||
if pub in ('Y', 'y'):
|
||||
for album in Album.objects.all():
|
||||
album.publish()
|
||||
for artist in Artist.objects.all():
|
||||
artist.publish()
|
||||
for game in Game.objects.all():
|
||||
game.publish()
|
||||
for song in Song.objects.all():
|
||||
song.publish()
|
||||
self.stdout.write('Published imported objects successfully')
|
||||
else:
|
||||
self.stdout.write('Skipped publishing songs')
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
'''
|
||||
Django Model Managers for the Radio application.
|
||||
'''
|
||||
|
||||
from datetime import timedelta
|
||||
from decimal import getcontext, Decimal, ROUND_UP
|
||||
from random import randint
|
||||
|
@ -15,123 +19,126 @@ getcontext().prec = 16
|
|||
|
||||
|
||||
class RadioManager(models.Manager):
|
||||
"""
|
||||
'''
|
||||
Custom object manager for filtering out common behaviors for radio
|
||||
objects.
|
||||
"""
|
||||
'''
|
||||
def get_queryset(self):
|
||||
"""
|
||||
'''
|
||||
Return customized default QuerySet.
|
||||
"""
|
||||
'''
|
||||
return RadioQuerySet(self.model, using=self._db)
|
||||
|
||||
def disabled(self):
|
||||
"""
|
||||
'''
|
||||
Radio objects that are marked as disabled.
|
||||
"""
|
||||
'''
|
||||
return self.get_queryset().disabled()
|
||||
|
||||
def enabled(self):
|
||||
"""
|
||||
'''
|
||||
Radio objects that are marked as enabled.
|
||||
"""
|
||||
'''
|
||||
return self.get_queryset().enabled()
|
||||
|
||||
def published(self):
|
||||
"""
|
||||
'''
|
||||
Radio objects that are marked as published.
|
||||
"""
|
||||
'''
|
||||
return self.get_queryset().published()
|
||||
|
||||
def unpublished(self):
|
||||
"""
|
||||
'''
|
||||
Radio objects that are marked as unpublished.
|
||||
"""
|
||||
'''
|
||||
return self.get_queryset().unpublished()
|
||||
|
||||
def available(self):
|
||||
"""
|
||||
'''
|
||||
Radio objects that are enabled and published.
|
||||
"""
|
||||
'''
|
||||
return self.enabled().published()
|
||||
|
||||
|
||||
class SongManager(RadioManager):
|
||||
"""
|
||||
'''
|
||||
Custom object manager for filtering out common behaviors for Song objects.
|
||||
"""
|
||||
'''
|
||||
def get_queryset(self):
|
||||
"""
|
||||
'''
|
||||
Return customized default QuerySet for Songs.
|
||||
"""
|
||||
'''
|
||||
return SongQuerySet(self.model, using=self._db)
|
||||
|
||||
def available_jingles(self):
|
||||
"""
|
||||
'''
|
||||
Jingles that are currently published and are enabled.
|
||||
"""
|
||||
'''
|
||||
return self.available().jingles()
|
||||
|
||||
def available_songs(self):
|
||||
"""
|
||||
'''
|
||||
Songs that are currently published and are enabled.
|
||||
"""
|
||||
'''
|
||||
return self.available().songs()
|
||||
|
||||
def playlist_length(self):
|
||||
"""
|
||||
'''
|
||||
Total length of available songs in the playlist (in seconds).
|
||||
"""
|
||||
length = self.available_songs().aggregate(models.Sum('length'))
|
||||
return length['length__sum']
|
||||
'''
|
||||
a_songs = self.available_songs()
|
||||
length = a_songs.aggregate(
|
||||
total_time=models.Sum('active_store__length')
|
||||
)
|
||||
return length['total_time']
|
||||
|
||||
def wait_total(self, adjusted_ratio=0.0):
|
||||
"""
|
||||
'''
|
||||
Default length in seconds before a song can be played again. This is
|
||||
based on the replay ratio set in the application settings.
|
||||
"""
|
||||
'''
|
||||
total_ratio = get_setting('replay_ratio') + adjusted_ratio
|
||||
wait = self.playlist_length() * Decimal(total_ratio)
|
||||
wait = wait.quantize(Decimal('.01'), rounding=ROUND_UP)
|
||||
return timedelta(seconds=float(wait))
|
||||
|
||||
def datetime_from_wait(self):
|
||||
"""
|
||||
'''
|
||||
Datetime of now minus the default wait time for played songs.
|
||||
"""
|
||||
'''
|
||||
return timezone.now() - self.wait_total()
|
||||
|
||||
def playable(self):
|
||||
"""
|
||||
'''
|
||||
Songs that are playable because they are available (enabled &
|
||||
published) and they have not been played within the default wait time
|
||||
(or at all).
|
||||
"""
|
||||
'''
|
||||
return self.available_songs().filter(
|
||||
models.Q(next_play__lt=timezone.now()) |
|
||||
models.Q(next_play__isnull=True)
|
||||
)
|
||||
|
||||
def requestable(self):
|
||||
"""
|
||||
'''
|
||||
Songs that can be placed in the request queue for playback.
|
||||
"""
|
||||
'''
|
||||
# Import SongRequest here to get rid of circular dependencies
|
||||
SongRequest = apps.get_model(app_label='profiles',
|
||||
song_request = apps.get_model(app_label='profiles',
|
||||
model_name='SongRequest')
|
||||
requests = SongRequest.music.unplayed().values_list('song__id',
|
||||
requests = song_request.music.unplayed().values_list('song__id',
|
||||
flat=True)
|
||||
return self.playable().exclude(id__in=requests)
|
||||
|
||||
def get_random_requestable_song(self):
|
||||
"""
|
||||
'''
|
||||
Pick a random requestable song and return it.
|
||||
"""
|
||||
'''
|
||||
return self.requestable()[randint(0, self.requestable().count() - 1)]
|
||||
|
||||
def get_random_jingle(self):
|
||||
"""
|
||||
'''
|
||||
Pick a random jingle and return it.
|
||||
"""
|
||||
'''
|
||||
random_index = randint(0, self.available_jingles().count() - 1)
|
||||
return self.available_jingles()[random_index]
|
||||
|
|
|
@ -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 decimal import getcontext, Decimal, ROUND_UP
|
||||
|
||||
from django.apps import apps
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.behaviors import Disableable, Publishable, Timestampable
|
||||
from core.utils import get_setting
|
||||
from .fields import RadioIRIField
|
||||
from .managers import RadioManager, SongManager
|
||||
|
||||
|
||||
|
@ -16,9 +22,9 @@ getcontext().prec = 16
|
|||
|
||||
|
||||
class Album(Disableable, Publishable, Timestampable, models.Model):
|
||||
"""
|
||||
'''
|
||||
A model for a music album.
|
||||
"""
|
||||
'''
|
||||
title = models.CharField(_('title'), max_length=255, unique=True)
|
||||
|
||||
sorted_title = models.CharField(_('naturalized title'),
|
||||
|
@ -37,9 +43,9 @@ class Album(Disableable, Publishable, Timestampable, models.Model):
|
|||
|
||||
|
||||
class Artist(Disableable, Publishable, Timestampable, models.Model):
|
||||
"""
|
||||
'''
|
||||
A model for a music artist.
|
||||
"""
|
||||
'''
|
||||
alias = models.CharField(_('alias'), max_length=127, blank=True)
|
||||
first_name = models.CharField(_('first name'), max_length=127, blank=True)
|
||||
last_name = models.CharField(_('last name'), max_length=127, blank=True)
|
||||
|
@ -57,10 +63,10 @@ class Artist(Disableable, Publishable, Timestampable, models.Model):
|
|||
|
||||
@property
|
||||
def full_name(self):
|
||||
"""
|
||||
'''
|
||||
String representing the artist's full name including an alias, if
|
||||
available.
|
||||
"""
|
||||
'''
|
||||
if self.alias:
|
||||
if self.first_name or self.last_name:
|
||||
return '{} "{}" {}'.format(self.first_name,
|
||||
|
@ -74,9 +80,9 @@ class Artist(Disableable, Publishable, Timestampable, models.Model):
|
|||
|
||||
|
||||
class Game(Disableable, Publishable, Timestampable, models.Model):
|
||||
"""
|
||||
'''
|
||||
A model for a game.
|
||||
"""
|
||||
'''
|
||||
title = models.CharField(_('title'), max_length=255, unique=True)
|
||||
|
||||
sorted_title = models.CharField(_('naturalized title'),
|
||||
|
@ -94,10 +100,53 @@ class Game(Disableable, Publishable, Timestampable, models.Model):
|
|||
return self.title
|
||||
|
||||
|
||||
class Store(Timestampable, models.Model):
|
||||
'''
|
||||
A model to represent various data locations (stores) for the song.
|
||||
'''
|
||||
iri = RadioIRIField(_('IRI path to song file'))
|
||||
mime_type = models.CharField(_('file MIME type'),
|
||||
max_length=64,
|
||||
blank=True)
|
||||
file_size = models.BigIntegerField(_('file size'),
|
||||
validators=[MinValueValidator(0)],
|
||||
blank=True,
|
||||
null=True)
|
||||
length = models.DecimalField(_('song length (in seconds)'),
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True)
|
||||
track_gain = models.DecimalField(_('recommended replaygain adjustment'),
|
||||
max_digits=6,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True)
|
||||
track_peak = models.DecimalField(_('highest volume level in the track'),
|
||||
max_digits=10,
|
||||
decimal_places=6,
|
||||
null=True,
|
||||
blank=True)
|
||||
|
||||
def _replaygain(self):
|
||||
'''
|
||||
String representation of the recommended amplitude adjustment.
|
||||
'''
|
||||
if self.track_gain is None:
|
||||
return '+0.00 dB'
|
||||
if self.track_gain > 0:
|
||||
return '+{} dB'.format(str(self.track_gain))
|
||||
return '{} dB'.format(str(self.track_gain))
|
||||
replaygain = property(_replaygain)
|
||||
|
||||
def __str__(self):
|
||||
return self.iri
|
||||
|
||||
|
||||
class Song(Disableable, Publishable, Timestampable, models.Model):
|
||||
"""
|
||||
'''
|
||||
A model for a song.
|
||||
"""
|
||||
'''
|
||||
JINGLE = 'J'
|
||||
SONG = 'S'
|
||||
TYPE_CHOICES = (
|
||||
|
@ -128,13 +177,12 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
|
|||
null=True,
|
||||
blank=True,
|
||||
editable=False)
|
||||
length = models.DecimalField(_('song length (in seconds)'),
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
stores = models.ManyToManyField(Store, blank=True, related_name='song')
|
||||
active_store = models.ForeignKey(Store,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True)
|
||||
path = models.TextField(_('absolute path to song file'))
|
||||
|
||||
blank=True,
|
||||
related_name='active_for')
|
||||
sorted_title = models.CharField(_('naturalized title'),
|
||||
db_index=True,
|
||||
editable=False,
|
||||
|
@ -147,34 +195,34 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
|
|||
ordering = ['sorted_title', ]
|
||||
|
||||
def _is_jingle(self):
|
||||
"""
|
||||
'''
|
||||
Is the object a jingle?
|
||||
"""
|
||||
'''
|
||||
return self.song_type == 'J'
|
||||
_is_jingle.boolean = True
|
||||
is_jingle = property(_is_jingle)
|
||||
|
||||
def _is_song(self):
|
||||
"""
|
||||
'''
|
||||
Is the object a song?
|
||||
"""
|
||||
'''
|
||||
return self.song_type == 'S'
|
||||
_is_song.boolean = True
|
||||
is_song = property(_is_song)
|
||||
|
||||
def _is_available(self):
|
||||
"""
|
||||
'''
|
||||
Is the object both enabled and published?
|
||||
"""
|
||||
'''
|
||||
return self._is_enabled() and self._is_published()
|
||||
_is_available.boolean = True
|
||||
is_available = property(_is_available)
|
||||
|
||||
def _full_title(self):
|
||||
"""
|
||||
'''
|
||||
String representing the entire song title, including the game and
|
||||
artists involved.
|
||||
"""
|
||||
'''
|
||||
if self._is_song():
|
||||
enabled_artists = self.artists.all().filter(disabled=False)
|
||||
all_artists = ', '.join([a.full_name for a in enabled_artists])
|
||||
|
@ -185,9 +233,9 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
|
|||
full_title = property(_full_title)
|
||||
|
||||
def _average_rating(self):
|
||||
"""
|
||||
'''
|
||||
Decimal number of the average rating of a song from 1 - 5.
|
||||
"""
|
||||
'''
|
||||
ratings = self.rating_set.all()
|
||||
if ratings:
|
||||
avg = Decimal(ratings.aggregate(avg=models.Avg('value'))['avg'])
|
||||
|
@ -196,9 +244,9 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
|
|||
average_rating = property(_average_rating)
|
||||
|
||||
def get_time_until_requestable(self):
|
||||
"""
|
||||
'''
|
||||
Length of time before a song can be requested again.
|
||||
"""
|
||||
'''
|
||||
if self._is_song() and self._is_available():
|
||||
if self.last_played:
|
||||
allowed_datetime = Song.music.datetime_from_wait()
|
||||
|
@ -209,9 +257,9 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
|
|||
return None
|
||||
|
||||
def get_date_when_requestable(self, last_play=None):
|
||||
"""
|
||||
'''
|
||||
Datetime when a song can be requested again.
|
||||
"""
|
||||
'''
|
||||
last = self.last_played if last_play is None else last_play
|
||||
|
||||
if self._is_song() and self._is_available():
|
||||
|
@ -232,10 +280,10 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
|
|||
return None
|
||||
|
||||
def _is_playable(self):
|
||||
"""
|
||||
'''
|
||||
Is the song available and not been played within the default waiting
|
||||
period (or at all)?
|
||||
"""
|
||||
'''
|
||||
if self._is_song() and self._is_available():
|
||||
return self.get_date_when_requestable() <= timezone.now()
|
||||
return False
|
||||
|
@ -243,13 +291,13 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
|
|||
is_playable = property(_is_playable)
|
||||
|
||||
def _is_requestable(self):
|
||||
"""
|
||||
'''
|
||||
Is the song playable and has it not already been requested?
|
||||
"""
|
||||
'''
|
||||
if self._is_playable():
|
||||
SongRequest = apps.get_model(app_label='profiles',
|
||||
song_request = apps.get_model(app_label='profiles',
|
||||
model_name='SongRequest')
|
||||
requests = SongRequest.music.unplayed().values_list('song__id',
|
||||
requests = song_request.music.unplayed().values_list('song__id',
|
||||
flat=True)
|
||||
return self.pk not in requests
|
||||
return False
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
'''
|
||||
Django settings file.
|
||||
'''
|
||||
|
||||
import os
|
||||
|
||||
from decouple import config
|
||||
from dj_database_url import parse as db_url
|
||||
|
||||
|
||||
SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
@ -8,6 +13,8 @@ CONFIG_DIR = os.path.dirname(SETTINGS_DIR)
|
|||
PROJECT_DIR = os.path.dirname(CONFIG_DIR)
|
||||
BASE_DIR = os.path.dirname(PROJECT_DIR)
|
||||
|
||||
DEBUG = config('DEBUG', default=False, cast=bool)
|
||||
|
||||
#
|
||||
# Django-specific settings
|
||||
#
|
||||
|
@ -29,6 +36,17 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||
|
||||
AUTH_USER_MODEL = 'core.RadioUser'
|
||||
|
||||
DATABASES = {
|
||||
'default': config(
|
||||
'DATABASE_URL',
|
||||
default='sqlite:///' + os.path.join(PROJECT_DIR, 'spradio.sqlite3'),
|
||||
cast=db_url
|
||||
)
|
||||
}
|
||||
|
||||
if DEBUG:
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
|
@ -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