add API v2

This commit is contained in:
Kyle Robbertze 2020-01-30 15:47:36 +02:00
parent f809c3a8ff
commit 2df0189a90
71 changed files with 2740 additions and 315 deletions

1
.gitignore vendored
View File

@ -20,3 +20,4 @@ airtime_mvc/tests/log/*.log
/docs/.jekyll-cache/*
/docs/.gems/*
Gemfile.lock
api.log

4
Vagrantfile vendored
View File

@ -10,6 +10,10 @@ Vagrant.configure("2") do |config|
# liquidsoap input harbors for instreaming (ie. /master)
config.vm.network "forwarded_port", guest: 8001, host:8001
config.vm.network "forwarded_port", guest: 8002, host:8002
# database
config.vm.network "forwarded_port", guest: 5432, host:5432
# api
config.vm.network "forwarded_port", guest: 8081, host:8081
# make sure we are using nfs (doesn't work out of the box with debian)
nfsPath = "."

View File

@ -18,6 +18,7 @@ $pypo = $externalServices["pypo"];
$liquidsoap = $externalServices["liquidsoap"];
$analyzer = $externalServices["analyzer"];
$celery = $externalServices['celery'];
$api = $externalServices['api'];
$r1 = array_reduce($phpDependencies, "booleanReduce", true);
$r2 = array_reduce($externalServices, "booleanReduce", true);
@ -243,6 +244,26 @@ $result = $r1 && $r2;
?>
</td>
</tr>
<tr class="<?=$api ? 'success' : 'danger';?>">
<td class="component">
API
</td>
<td class="description">
<?php echo _("LibreTime API service") ?>
</td>
<td class="solution <?php if ($api) {echo 'check';?>" >
<?php
} else {
?>">
<?php echo _("Check that the libretime-api service is installed correctly in ") ?><code>/etc/init.d/</code>,
<?php echo _(" and ensure that it's running with ") ?>
<br/><code>systemctl status libretime-api</code><br/>
<?php echo _("If not, try ") ?><br/><code>sudo systemctl restart libretime-api</code>
<?php
}
?>
</td>
</tr>
</tbody>
</table>
</div>

View File

@ -14,6 +14,7 @@
$liquidsoap = $externalServices["liquidsoap"];
$analyzer = $externalServices["analyzer"];
$celery = $externalServices['celery'];
$api = $externalServices['api'];
$r1 = array_reduce($phpDependencies, "booleanReduce", true);
$r2 = array_reduce($externalServices, "booleanReduce", true);
@ -170,6 +171,26 @@
?>
</td>
</tr>
<tr>
<td class="component">
API
</td>
<td class="description">
<?php echo _("LibreTime API service") ?>
</td>
<td class="solution <?php if ($api) {echo 'check';?>" >
<?php
} else {
?>">
<?php echo _("Check that the libretime-api service is installed correctly in ") ?><code>/etc/systemd/system/</code>,
<?php echo _(" and ensure that it's running with ") ?>
<br/><code>systemctl status libretime-api</code><br/>
<?php echo _("If not, try ") ?><br/><code>sudo systemctl restart libretime-api</code>
<?php
}
?>
</td>
</tr>
</tbody>
<tr id="partitions" class="even">
<th colspan="5"><?php echo _("Disk Space") ?></th>

View File

@ -7,10 +7,11 @@
<p>
Looks like you're almost done! As a final step, please run the following commands from the terminal:
</p>
<pre style="text-align: left">sudo systemctl start libretime-playout
<pre style="text-align: left">sudo systemctl start libretime-analyzer
sudo systemctl start libretime-api
sudo systemctl start libretime-celery
sudo systemctl start libretime-liquidsoap
sudo systemctl start libretime-analyzer
sudo systemctl start libretime-celery</pre>
sudo systemctl start libretime-playout</pre
<p>
Click "Done!" to bring up the Libretime configuration checklist; if your configuration is all green,
you're ready to get started with your personal Libretime station!

View File

@ -56,6 +56,7 @@ function checkExternalServices() {
"liquidsoap" => checkLiquidsoapService(),
"rabbitmq" => checkRMQConnection(),
"celery" => checkCeleryService(),
"api" => checkApiService(),
);
}
@ -158,3 +159,16 @@ function checkCeleryService() {
}
return $status == 0;
}
/**
* Check if libretime-api is currently running
*
* @return boolean true if libretime-api is running
*/
function checkApiService() {
exec("pgrep -f -u www-data uwsgi", $out, $status);
if (array_key_exists(0, $out) && $status == 0) {
return 1;
}
return $status == 0;
}

View File

@ -80,6 +80,7 @@ class DatabaseSetup extends Setup {
$this->checkSchemaExists();
$this->createDatabaseTables();
$this->updateIcecastPassword();
$this->updateDjangoTables();
}
/**
@ -254,4 +255,10 @@ class DatabaseSetup extends Setup {
}
}
/**
* Updates the Django related tables for the API
*/
private function updateDjangoTables() {
shell_exec('LIBRETIME_CONF_FILE=/etc/airtime/airtime.conf.temp libretime-api migrate');
}
}

45
api/README.md Normal file
View File

@ -0,0 +1,45 @@
# LibreTime API
This API provides access to LibreTime's database via a Django application. This
API supersedes the [PHP API](../airtime_mvc/application/controllers/ApiController.php).
## Deploying
Deploying in a production environment is done in the [`install`](../install)
script which installs LibreTime. This is how the API is installed in the Vagrant
development images too. This method does not automatically reflect changes to
this API. After any changes, the `libretime-api` systemd service needs
restarting:
sudo systemctl restart libretime-api
Connections to the API are proxied through the Apache web server by default.
Endpoint exploration and documentation is available from
`http://example.com/api/v2/`, where `example.com` is the URL for the LibreTime
instance.
### Development
For a live reloading version within Vagrant:
```
vagrant up debian-buster
# Run through the web setup http://localhost:8080
vagrant ssh debian-buster
sudo systemctl stop libretime-api
sudo systemctl restart libretime-analyzer libretime-celery libretime-liquidsoap libretime-playout
cd /vagrant/api
sudo pip3 install -e .
sudo -u www-data LIBRETIME_DEBUG=True python3 bin/libretime-api runserver 0.0.0.0:8081
```
Unit tests can be run in the vagrant environment using
```
sudo -u www-data LIBRETIME_DEBUG=True python3 bin/libretime-api test libretimeapi
```
## 3rd Party Licences
`libretimeapi/tests/resources/song.mp3`: Steps - Tears On The Dancefloor (Album
Teaser) by mceyedol. Downloaded from
https://soundcloud.com/mceyedol/steps-tears-on-the-dancefloor-album-teaser
released under a Creative Commons Licence
([cc-by-sa-nc-sa](https://creativecommons.org/licenses/by-nc-sa/3.0/))

21
api/bin/libretime-api Normal file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'libretimeapi.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,11 @@
[Unit]
Description=LibreTime API Service
[Service]
ExecStart=/usr/bin/uwsgi /etc/airtime/libretime-api.ini
User=libretime-api
Group=libretime-api
Restart=always
[Install]
WantedBy=multi-user.target

View File

7
api/libretimeapi/apps.py Normal file
View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
from django.db.models.signals import pre_save
class LibreTimeAPIConfig(AppConfig):
name = 'libretimeapi'
verbose_name = 'LibreTime API'
default_auto_field = 'django.db.models.AutoField'

View File

@ -0,0 +1,20 @@
from django.contrib.auth.models import BaseUserManager
class UserManager(BaseUserManager):
def create_user(self, username, type, email, first_name, last_name, password):
user = self.model(username=username,
type=type,
email=email,
first_name=first_name,
last_name=last_name)
user.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, username, email, first_name, last_name, password):
user = self.create_user(username, 'A', email, first_name, last_name, password)
return user
def get_by_natural_key(self, username):
return self.get(username=username)

View File

@ -0,0 +1,14 @@
from .authentication import *
from .celery import *
from .countries import *
from .files import *
from .playlists import *
from .playout import *
from .podcasts import *
from .preferences import *
from .schedule import *
from .services import *
from .shows import *
from .smart_blocks import *
from .tracks import *
from .webstreams import *

View File

@ -0,0 +1,141 @@
import hashlib
from django.contrib import auth
from django.contrib.auth.models import AbstractBaseUser, Permission
from django.core.exceptions import PermissionDenied
from django.db import models
from libretimeapi.managers import UserManager
from libretimeapi.permission_constants import GROUPS
from .user_constants import USER_TYPES, ADMIN
class LoginAttempt(models.Model):
ip = models.CharField(primary_key=True, max_length=32)
attempts = models.IntegerField(blank=True, null=True)
class Meta:
managed = False
db_table = 'cc_login_attempts'
class Session(models.Model):
sessid = models.CharField(primary_key=True, max_length=32)
userid = models.ForeignKey('User', models.DO_NOTHING, db_column='userid', blank=True, null=True)
login = models.CharField(max_length=255, blank=True, null=True)
ts = models.DateTimeField(blank=True, null=True)
class Meta:
managed = False
db_table = 'cc_sess'
USER_TYPE_CHOICES = ()
for item in USER_TYPES.items():
USER_TYPE_CHOICES = USER_TYPE_CHOICES + (item,)
class User(AbstractBaseUser):
username = models.CharField(db_column='login', unique=True, max_length=255)
password = models.CharField(db_column='pass', max_length=255) # Field renamed because it was a Python reserved word.
type = models.CharField(max_length=1, choices=USER_TYPE_CHOICES)
first_name = models.CharField(max_length=255)
last_name = models.CharField(max_length=255)
last_login = models.DateTimeField(db_column='lastlogin', blank=True, null=True)
lastfail = models.DateTimeField(blank=True, null=True)
skype_contact = models.CharField(max_length=1024, blank=True, null=True)
jabber_contact = models.CharField(max_length=1024, blank=True, null=True)
email = models.CharField(max_length=1024, blank=True, null=True)
cell_phone = models.CharField(max_length=1024, blank=True, null=True)
login_attempts = models.IntegerField(blank=True, null=True)
USERNAME_FIELD = 'username'
EMAIL_FIELD = 'email'
REQUIRED_FIELDS = ['type', 'email', 'first_name', 'last_name']
objects = UserManager()
def get_full_name(self):
return '{} {}'.format(self.first_name, self.last_name)
def get_short_name(self):
return self.first_name
def set_password(self, password):
if not password:
self.set_unusable_password()
else:
self.password = hashlib.md5(password.encode()).hexdigest()
def is_staff(self):
print('is_staff')
return self.type == ADMIN
def check_password(self, password):
if self.has_usable_password():
test_password = hashlib.md5(password.encode()).hexdigest()
return test_password == self.password
return False
"""
The following methods have to be re-implemented here, as PermissionsMixin
assumes that the User class has a 'group' attribute, which LibreTime does
not currently provide. Once Django starts managing the Database
(managed = True), then this can be replaced with
django.contrib.auth.models.PermissionMixin.
"""
def is_superuser(self):
return self.type == ADMIN
def get_user_permissions(self, obj=None):
"""
Users do not have permissions directly, only through groups
"""
return []
def get_group_permissions(self, obj=None):
permissions = GROUPS[self.type]
if obj:
obj_name = obj.__class__.__name__.lower()
permissions = [perm for perm in permissions if obj_name in perm]
# get permissions objects
q = models.Q()
for perm in permissions:
q = q | models.Q(codename=perm)
return list(Permission.objects.filter(q))
def get_all_permissions(self, obj=None):
return self.get_user_permissions(obj) + self.get_group_permissions(obj)
def has_perm(self, perm, obj=None):
if self.is_superuser():
return True
if not perm:
return False
permissions = self.get_all_permissions(obj)
try:
permission = Permission.objects.get(codename=perm)
return permission in permissions
except Permission.DoesNotExist:
return False
def has_perms(self, perm_list, obj=None):
result = True
for permission in perm_list:
result = result and self.has_perm(permission, obj)
return result
class Meta:
managed = False
db_table = 'cc_subjs'
class UserToken(models.Model):
user = models.ForeignKey(User, models.DO_NOTHING)
action = models.CharField(max_length=255)
token = models.CharField(unique=True, max_length=40)
created = models.DateTimeField()
def get_owner(self):
return self.user
class Meta:
managed = False
db_table = 'cc_subjs_token'

View File

@ -0,0 +1,13 @@
from django.db import models
class CeleryTask(models.Model):
task_id = models.CharField(max_length=256)
track_reference = models.ForeignKey('ThirdPartyTrackReference', models.DO_NOTHING, db_column='track_reference')
name = models.CharField(max_length=256, blank=True, null=True)
dispatch_time = models.DateTimeField(blank=True, null=True)
status = models.CharField(max_length=256)
class Meta:
managed = False
db_table = 'celery_tasks'

View File

@ -0,0 +1,11 @@
from django.db import models
class Country(models.Model):
isocode = models.CharField(primary_key=True, max_length=3)
name = models.CharField(max_length=255)
class Meta:
managed = False
db_table = 'cc_country'

View File

@ -0,0 +1,105 @@
from django.db import models
class File(models.Model):
name = models.CharField(max_length=255)
mime = models.CharField(max_length=255)
ftype = models.CharField(max_length=128)
directory = models.ForeignKey('MusicDir', models.DO_NOTHING, db_column='directory', blank=True, null=True)
filepath = models.TextField(blank=True, null=True)
import_status = models.IntegerField()
currently_accessing = models.IntegerField(db_column='currentlyaccessing')
edited_by = models.ForeignKey('User', models.DO_NOTHING, db_column='editedby', blank=True, null=True, related_name='edited_files')
mtime = models.DateTimeField(blank=True, null=True)
utime = models.DateTimeField(blank=True, null=True)
lptime = models.DateTimeField(blank=True, null=True)
md5 = models.CharField(max_length=32, blank=True, null=True)
track_title = models.CharField(max_length=512, blank=True, null=True)
artist_name = models.CharField(max_length=512, blank=True, null=True)
bit_rate = models.IntegerField(blank=True, null=True)
sample_rate = models.IntegerField(blank=True, null=True)
format = models.CharField(max_length=128, blank=True, null=True)
length = models.DurationField(blank=True, null=True)
album_title = models.CharField(max_length=512, blank=True, null=True)
genre = models.CharField(max_length=64, blank=True, null=True)
comments = models.TextField(blank=True, null=True)
year = models.CharField(max_length=16, blank=True, null=True)
track_number = models.IntegerField(blank=True, null=True)
channels = models.IntegerField(blank=True, null=True)
url = models.CharField(max_length=1024, blank=True, null=True)
bpm = models.IntegerField(blank=True, null=True)
rating = models.CharField(max_length=8, blank=True, null=True)
encoded_by = models.CharField(max_length=255, blank=True, null=True)
disc_number = models.CharField(max_length=8, blank=True, null=True)
mood = models.CharField(max_length=64, blank=True, null=True)
label = models.CharField(max_length=512, blank=True, null=True)
composer = models.CharField(max_length=512, blank=True, null=True)
encoder = models.CharField(max_length=64, blank=True, null=True)
checksum = models.CharField(max_length=256, blank=True, null=True)
lyrics = models.TextField(blank=True, null=True)
orchestra = models.CharField(max_length=512, blank=True, null=True)
conductor = models.CharField(max_length=512, blank=True, null=True)
lyricist = models.CharField(max_length=512, blank=True, null=True)
original_lyricist = models.CharField(max_length=512, blank=True, null=True)
radio_station_name = models.CharField(max_length=512, blank=True, null=True)
info_url = models.CharField(max_length=512, blank=True, null=True)
artist_url = models.CharField(max_length=512, blank=True, null=True)
audio_source_url = models.CharField(max_length=512, blank=True, null=True)
radio_station_url = models.CharField(max_length=512, blank=True, null=True)
buy_this_url = models.CharField(max_length=512, blank=True, null=True)
isrc_number = models.CharField(max_length=512, blank=True, null=True)
catalog_number = models.CharField(max_length=512, blank=True, null=True)
original_artist = models.CharField(max_length=512, blank=True, null=True)
copyright = models.CharField(max_length=512, blank=True, null=True)
report_datetime = models.CharField(max_length=32, blank=True, null=True)
report_location = models.CharField(max_length=512, blank=True, null=True)
report_organization = models.CharField(max_length=512, blank=True, null=True)
subject = models.CharField(max_length=512, blank=True, null=True)
contributor = models.CharField(max_length=512, blank=True, null=True)
language = models.CharField(max_length=512, blank=True, null=True)
file_exists = models.BooleanField(blank=True, null=True)
replay_gain = models.DecimalField(max_digits=8, decimal_places=2, blank=True, null=True)
owner = models.ForeignKey('User', models.DO_NOTHING, blank=True, null=True)
cuein = models.DurationField(blank=True, null=True)
cueout = models.DurationField(blank=True, null=True)
silan_check = models.BooleanField(blank=True, null=True)
hidden = models.BooleanField(blank=True, null=True)
is_scheduled = models.BooleanField(blank=True, null=True)
is_playlist = models.BooleanField(blank=True, null=True)
filesize = models.IntegerField()
description = models.CharField(max_length=512, blank=True, null=True)
artwork = models.CharField(max_length=512, blank=True, null=True)
track_type = models.CharField(max_length=16, blank=True, null=True)
def get_owner(self):
return self.owner
class Meta:
managed = False
db_table = 'cc_files'
permissions = [
('change_own_file', 'Change the files where they are the owner'),
('delete_own_file', 'Delete the files where they are the owner'),
]
class MusicDir(models.Model):
directory = models.TextField(unique=True, blank=True, null=True)
type = models.CharField(max_length=255, blank=True, null=True)
exists = models.BooleanField(blank=True, null=True)
watched = models.BooleanField(blank=True, null=True)
class Meta:
managed = False
db_table = 'cc_music_dirs'
class CloudFile(models.Model):
storage_backend = models.CharField(max_length=512)
resource_id = models.TextField()
filename = models.ForeignKey(File, models.DO_NOTHING, blank=True, null=True,
db_column='cc_file_id')
class Meta:
managed = False
db_table = 'cloud_file'

View File

@ -0,0 +1,41 @@
from django.db import models
from .files import File
from .smart_blocks import SmartBlock
class Playlist(models.Model):
name = models.CharField(max_length=255)
mtime = models.DateTimeField(blank=True, null=True)
utime = models.DateTimeField(blank=True, null=True)
creator = models.ForeignKey('User', models.DO_NOTHING, blank=True, null=True)
description = models.CharField(max_length=512, blank=True, null=True)
length = models.DurationField(blank=True, null=True)
def get_owner(self):
return self.creator
class Meta:
managed = False
db_table = 'cc_playlist'
class PlaylistContent(models.Model):
playlist = models.ForeignKey(Playlist, models.DO_NOTHING, blank=True, null=True)
file = models.ForeignKey(File, models.DO_NOTHING, blank=True, null=True)
block = models.ForeignKey(SmartBlock, models.DO_NOTHING, blank=True, null=True)
stream_id = models.IntegerField(blank=True, null=True)
type = models.SmallIntegerField()
position = models.IntegerField(blank=True, null=True)
trackoffset = models.FloatField()
cliplength = models.DurationField(blank=True, null=True)
cuein = models.DurationField(blank=True, null=True)
cueout = models.DurationField(blank=True, null=True)
fadein = models.TimeField(blank=True, null=True)
fadeout = models.TimeField(blank=True, null=True)
def get_owner(self):
return self.playlist.owner
class Meta:
managed = False
db_table = 'cc_playlistcontents'

View File

@ -0,0 +1,73 @@
from django.db import models
from .files import File
class ListenerCount(models.Model):
timestamp = models.ForeignKey('Timestamp', models.DO_NOTHING)
mount_name = models.ForeignKey('MountName', models.DO_NOTHING)
listener_count = models.IntegerField()
class Meta:
managed = False
db_table = 'cc_listener_count'
class LiveLog(models.Model):
state = models.CharField(max_length=32)
start_time = models.DateTimeField()
end_time = models.DateTimeField(blank=True, null=True)
class Meta:
managed = False
db_table = 'cc_live_log'
class PlayoutHistory(models.Model):
file = models.ForeignKey(File, models.DO_NOTHING, blank=True, null=True)
starts = models.DateTimeField()
ends = models.DateTimeField(blank=True, null=True)
instance = models.ForeignKey('ShowInstance', models.DO_NOTHING, blank=True, null=True)
class Meta:
managed = False
db_table = 'cc_playout_history'
class PlayoutHistoryMetadata(models.Model):
history = models.ForeignKey(PlayoutHistory, models.DO_NOTHING)
key = models.CharField(max_length=128)
value = models.CharField(max_length=128)
class Meta:
managed = False
db_table = 'cc_playout_history_metadata'
class PlayoutHistoryTemplate(models.Model):
name = models.CharField(max_length=128)
type = models.CharField(max_length=35)
class Meta:
managed = False
db_table = 'cc_playout_history_template'
class PlayoutHistoryTemplateField(models.Model):
template = models.ForeignKey(PlayoutHistoryTemplate, models.DO_NOTHING)
name = models.CharField(max_length=128)
label = models.CharField(max_length=128)
type = models.CharField(max_length=128)
is_file_md = models.BooleanField()
position = models.IntegerField()
class Meta:
managed = False
db_table = 'cc_playout_history_template_field'
class Timestamp(models.Model):
timestamp = models.DateTimeField()
class Meta:
managed = False
db_table = 'cc_timestamp'

View File

@ -0,0 +1,77 @@
from django.db import models
from .authentication import User
from .files import File
class ImportedPodcast(models.Model):
auto_ingest = models.BooleanField()
auto_ingest_timestamp = models.DateTimeField(blank=True, null=True)
album_override = models.BooleanField()
podcast = models.ForeignKey('Podcast', models.DO_NOTHING)
def get_owner(self):
return self.podcast.owner
class Meta:
managed = False
db_table = 'imported_podcast'
class Podcast(models.Model):
url = models.CharField(max_length=4096)
title = models.CharField(max_length=4096)
creator = models.CharField(max_length=4096, blank=True, null=True)
description = models.CharField(max_length=4096, blank=True, null=True)
language = models.CharField(max_length=4096, blank=True, null=True)
copyright = models.CharField(max_length=4096, blank=True, null=True)
link = models.CharField(max_length=4096, blank=True, null=True)
itunes_author = models.CharField(max_length=4096, blank=True, null=True)
itunes_keywords = models.CharField(max_length=4096, blank=True, null=True)
itunes_summary = models.CharField(max_length=4096, blank=True, null=True)
itunes_subtitle = models.CharField(max_length=4096, blank=True, null=True)
itunes_category = models.CharField(max_length=4096, blank=True, null=True)
itunes_explicit = models.CharField(max_length=4096, blank=True, null=True)
owner = models.ForeignKey(User, models.DO_NOTHING, db_column='owner', blank=True, null=True)
def get_owner(self):
return self.owner
class Meta:
managed = False
db_table = 'podcast'
permissions = [
('change_own_podcast', 'Change the podcasts where they are the owner'),
('delete_own_podcast', 'Delete the podcasts where they are the owner'),
]
class PodcastEpisode(models.Model):
file = models.ForeignKey(File, models.DO_NOTHING, blank=True, null=True)
podcast = models.ForeignKey(Podcast, models.DO_NOTHING)
publication_date = models.DateTimeField()
download_url = models.CharField(max_length=4096)
episode_guid = models.CharField(max_length=4096)
episode_title = models.CharField(max_length=4096)
episode_description = models.TextField()
def get_owner(self):
return self.podcast.owner
class Meta:
managed = False
db_table = 'podcast_episodes'
permissions = [
('change_own_podcastepisode', 'Change the episodes of podcasts where they are the owner'),
('delete_own_podcastepisode', 'Delete the episodes of podcasts where they are the owner'),
]
class StationPodcast(models.Model):
podcast = models.ForeignKey(Podcast, models.DO_NOTHING)
def get_owner(self):
return self.podcast.owner
class Meta:
managed = False
db_table = 'station_podcast'

View File

@ -0,0 +1,30 @@
from django.db import models
class Preference(models.Model):
subjid = models.ForeignKey('User', models.DO_NOTHING, db_column='subjid', blank=True, null=True)
keystr = models.CharField(unique=True, max_length=255, blank=True, null=True)
valstr = models.TextField(blank=True, null=True)
class Meta:
managed = False
db_table = 'cc_pref'
unique_together = (('subjid', 'keystr'),)
class MountName(models.Model):
mount_name = models.CharField(max_length=1024)
class Meta:
managed = False
db_table = 'cc_mount_name'
class StreamSetting(models.Model):
keyname = models.CharField(primary_key=True, max_length=64)
value = models.CharField(max_length=255, blank=True, null=True)
type = models.CharField(max_length=16)
class Meta:
managed = False
db_table = 'cc_stream_setting'

View File

@ -0,0 +1,30 @@
from django.db import models
from .files import File
class Schedule(models.Model):
starts = models.DateTimeField()
ends = models.DateTimeField()
file = models.ForeignKey(File, models.DO_NOTHING, blank=True, null=True)
stream = models.ForeignKey('Webstream', models.DO_NOTHING, blank=True, null=True)
clip_length = models.DurationField(blank=True, null=True)
fade_in = models.TimeField(blank=True, null=True)
fade_out = models.TimeField(blank=True, null=True)
cue_in = models.DurationField()
cue_out = models.DurationField()
media_item_played = models.BooleanField(blank=True, null=True)
instance = models.ForeignKey('ShowInstance', models.DO_NOTHING)
playout_status = models.SmallIntegerField()
broadcasted = models.SmallIntegerField()
position = models.IntegerField()
def get_owner(self):
return self.instance.get_owner()
class Meta:
managed = False
db_table = 'cc_schedule'
permissions = [
('change_own_schedule', 'Change the content on their shows'),
('delete_own_schedule', 'Delete the content on their shows'),
]

View File

@ -0,0 +1,11 @@
from django.db import models
class ServiceRegister(models.Model):
name = models.CharField(primary_key=True, max_length=32)
ip = models.CharField(max_length=45)
class Meta:
managed = False
db_table = 'cc_service_register'

View File

@ -0,0 +1,94 @@
from django.db import models
from .playlists import Playlist
from .files import File
class Show(models.Model):
name = models.CharField(max_length=255)
url = models.CharField(max_length=255, blank=True, null=True)
genre = models.CharField(max_length=255, blank=True, null=True)
description = models.CharField(max_length=8192, blank=True, null=True)
color = models.CharField(max_length=6, blank=True, null=True)
background_color = models.CharField(max_length=6, blank=True, null=True)
live_stream_using_airtime_auth = models.BooleanField(blank=True, null=True)
live_stream_using_custom_auth = models.BooleanField(blank=True, null=True)
live_stream_user = models.CharField(max_length=255, blank=True, null=True)
live_stream_pass = models.CharField(max_length=255, blank=True, null=True)
linked = models.BooleanField()
is_linkable = models.BooleanField()
image_path = models.CharField(max_length=255, blank=True, null=True)
has_autoplaylist = models.BooleanField()
autoplaylist = models.ForeignKey(Playlist, models.DO_NOTHING, blank=True, null=True)
autoplaylist_repeat = models.BooleanField()
def get_owner(self):
return self.showhost_set.all()
class Meta:
managed = False
db_table = 'cc_show'
class ShowDays(models.Model):
first_show = models.DateField()
last_show = models.DateField(blank=True, null=True)
start_time = models.TimeField()
timezone = models.CharField(max_length=1024)
duration = models.CharField(max_length=1024)
day = models.SmallIntegerField(blank=True, null=True)
repeat_type = models.SmallIntegerField()
next_pop_date = models.DateField(blank=True, null=True)
show = models.ForeignKey(Show, models.DO_NOTHING)
record = models.SmallIntegerField(blank=True, null=True)
def get_owner(self):
return self.show.get_owner()
class Meta:
managed = False
db_table = 'cc_show_days'
class ShowHost(models.Model):
show = models.ForeignKey(Show, models.DO_NOTHING)
subjs = models.ForeignKey('User', models.DO_NOTHING)
class Meta:
managed = False
db_table = 'cc_show_hosts'
class ShowInstance(models.Model):
description = models.CharField(max_length=8192, blank=True, null=True)
starts = models.DateTimeField()
ends = models.DateTimeField()
show = models.ForeignKey(Show, models.DO_NOTHING)
record = models.SmallIntegerField(blank=True, null=True)
rebroadcast = models.SmallIntegerField(blank=True, null=True)
instance = models.ForeignKey('self', models.DO_NOTHING, blank=True, null=True)
file = models.ForeignKey(File, models.DO_NOTHING, blank=True, null=True)
time_filled = models.DurationField(blank=True, null=True)
created = models.DateTimeField()
last_scheduled = models.DateTimeField(blank=True, null=True)
modified_instance = models.BooleanField()
autoplaylist_built = models.BooleanField()
def get_owner(self):
return show.get_owner()
class Meta:
managed = False
db_table = 'cc_show_instances'
class ShowRebroadcast(models.Model):
day_offset = models.CharField(max_length=1024)
start_time = models.TimeField()
show = models.ForeignKey(Show, models.DO_NOTHING)
def get_owner(self):
return show.get_owner()
class Meta:
managed = False
db_table = 'cc_show_rebroadcast'

View File

@ -0,0 +1,66 @@
from django.db import models
class SmartBlock(models.Model):
name = models.CharField(max_length=255)
mtime = models.DateTimeField(blank=True, null=True)
utime = models.DateTimeField(blank=True, null=True)
creator = models.ForeignKey('User', models.DO_NOTHING, blank=True, null=True)
description = models.CharField(max_length=512, blank=True, null=True)
length = models.DurationField(blank=True, null=True)
type = models.CharField(max_length=7, blank=True, null=True)
def get_owner(self):
return self.creator
class Meta:
managed = False
db_table = 'cc_block'
permissions = [
('change_own_smartblock', 'Change the smartblocks where they are the owner'),
('delete_own_smartblock', 'Delete the smartblocks where they are the owner'),
]
class SmartBlockContent(models.Model):
block = models.ForeignKey(SmartBlock, models.DO_NOTHING, blank=True, null=True)
file = models.ForeignKey('File', models.DO_NOTHING, blank=True, null=True)
position = models.IntegerField(blank=True, null=True)
trackoffset = models.FloatField()
cliplength = models.DurationField(blank=True, null=True)
cuein = models.DurationField(blank=True, null=True)
cueout = models.DurationField(blank=True, null=True)
fadein = models.TimeField(blank=True, null=True)
fadeout = models.TimeField(blank=True, null=True)
def get_owner(self):
return self.block.get_owner()
class Meta:
managed = False
db_table = 'cc_blockcontents'
permissions = [
('change_own_smartblockcontent', 'Change the content of smartblocks where they are the owner'),
('delete_own_smartblockcontent', 'Delete the content of smartblocks where they are the owner'),
]
class SmartBlockCriteria(models.Model):
criteria = models.CharField(max_length=32)
modifier = models.CharField(max_length=16)
value = models.CharField(max_length=512)
extra = models.CharField(max_length=512, blank=True, null=True)
criteriagroup = models.IntegerField(blank=True, null=True)
block = models.ForeignKey(SmartBlock, models.DO_NOTHING)
def get_owner(self):
return self.block.get_owner()
class Meta:
managed = False
db_table = 'cc_blockcriteria'
permissions = [
('change_own_smartblockcriteria', 'Change the criteria of smartblocks where they are the owner'),
('delete_own_smartblockcriteria', 'Delete the criteria of smartblocks where they are the owner'),
]

View File

@ -0,0 +1,25 @@
from django.db import models
from .files import File
class ThirdPartyTrackReference(models.Model):
service = models.CharField(max_length=256)
foreign_id = models.CharField(unique=True, max_length=256, blank=True, null=True)
file = models.ForeignKey(File, models.DO_NOTHING, blank=True, null=True)
upload_time = models.DateTimeField(blank=True, null=True)
status = models.CharField(max_length=256, blank=True, null=True)
class Meta:
managed = False
db_table = 'third_party_track_references'
class TrackType(models.Model):
code = models.CharField(max_length=16, unique=True)
type_name = models.CharField(max_length=255, blank=True, null=True)
description = models.CharField(max_length=255, blank=True, null=True)
visibility = models.BooleanField(blank=True, default=True)
class Meta:
managed = False
db_table = 'cc_track_types'

View File

@ -0,0 +1,11 @@
GUEST = 'G'
DJ = 'H'
PROGRAM_MANAGER = 'P'
ADMIN = 'A'
USER_TYPES = {
GUEST: 'Guest',
DJ: 'DJ',
PROGRAM_MANAGER: 'Program Manager',
ADMIN: 'Admin',
}

View File

@ -0,0 +1,40 @@
from django.db import models
from django.contrib.auth import get_user_model
from .schedule import Schedule
class Webstream(models.Model):
name = models.CharField(max_length=255)
description = models.CharField(max_length=255)
url = models.CharField(max_length=512)
length = models.DurationField()
creator_id = models.IntegerField()
mtime = models.DateTimeField()
utime = models.DateTimeField()
lptime = models.DateTimeField(blank=True, null=True)
mime = models.CharField(max_length=1024, blank=True, null=True)
def get_owner(self):
User = get_user_model()
return User.objects.get(pk=self.creator_id)
class Meta:
managed = False
db_table = 'cc_webstream'
permissions = [
('change_own_webstream', 'Change the webstreams where they are the owner'),
('delete_own_webstream', 'Delete the webstreams where they are the owner'),
]
class WebstreamMetadata(models.Model):
instance = models.ForeignKey(Schedule, models.DO_NOTHING)
start_time = models.DateTimeField()
liquidsoap_data = models.CharField(max_length=1024)
def get_owner(self):
return self.instance.get_owner()
class Meta:
managed = False
db_table = 'cc_webstream_metadata'

View File

@ -0,0 +1,104 @@
import logging
from django.contrib.auth.models import Group, Permission
from .models.user_constants import GUEST, DJ, PROGRAM_MANAGER, USER_TYPES
logger = logging.getLogger(__name__)
GUEST_PERMISSIONS = ['view_schedule',
'view_show',
'view_showdays',
'view_showhost',
'view_showinstance',
'view_showrebroadcast',
'view_file',
'view_podcast',
'view_podcastepisode',
'view_playlist',
'view_playlistcontent',
'view_smartblock',
'view_smartblockcontent',
'view_smartblockcriteria',
'view_webstream',
'view_apiroot',
]
DJ_PERMISSIONS = GUEST_PERMISSIONS + ['add_file',
'add_podcast',
'add_podcastepisode',
'add_playlist',
'add_playlistcontent',
'add_smartblock',
'add_smartblockcontent',
'add_smartblockcriteria',
'add_webstream',
'change_own_schedule',
'change_own_file',
'change_own_podcast',
'change_own_podcastepisode',
'change_own_playlist',
'change_own_playlistcontent',
'change_own_smartblock',
'change_own_smartblockcontent',
'change_own_smartblockcriteria',
'change_own_webstream',
'delete_own_schedule',
'delete_own_file',
'delete_own_podcast',
'delete_own_podcastepisode',
'delete_own_playlist',
'delete_own_playlistcontent',
'delete_own_smartblock',
'delete_own_smartblockcontent',
'delete_own_smartblockcriteria',
'delete_own_webstream',
]
PROGRAM_MANAGER_PERMISSIONS = GUEST_PERMISSIONS + ['add_show',
'add_showdays',
'add_showhost',
'add_showinstance',
'add_showrebroadcast',
'add_file',
'add_podcast',
'add_podcastepisode',
'add_playlist',
'add_playlistcontent',
'add_smartblock',
'add_smartblockcontent',
'add_smartblockcriteria',
'add_webstream',
'change_schedule',
'change_show',
'change_showdays',
'change_showhost',
'change_showinstance',
'change_showrebroadcast',
'change_file',
'change_podcast',
'change_podcastepisode',
'change_playlist',
'change_playlistcontent',
'change_smartblock',
'change_smartblockcontent',
'change_smartblockcriteria',
'change_webstream',
'delete_schedule',
'delete_show',
'delete_showdays',
'delete_showhost',
'delete_showinstance',
'delete_showrebroadcast',
'delete_file',
'delete_podcast',
'delete_podcastepisode',
'delete_playlist',
'delete_playlistcontent',
'delete_smartblock',
'delete_smartblockcontent',
'delete_smartblockcriteria',
'delete_webstream',
]
GROUPS = {
GUEST: GUEST_PERMISSIONS,
DJ: DJ_PERMISSIONS,
PROGRAM_MANAGER: PROGRAM_MANAGER,
}

View File

@ -0,0 +1,102 @@
from rest_framework.permissions import BasePermission
from django.conf import settings
from .models.user_constants import DJ
REQUEST_PERMISSION_TYPE_MAP = {
'GET': 'view',
'HEAD': 'view',
'OPTIONS': 'view',
'POST': 'change',
'PUT': 'change',
'DELETE': 'delete',
'PATCH': 'change',
}
def get_own_obj(request, view):
user = request.user
if user is None or user.type != DJ:
return ''
if request.method == 'GET':
return ''
qs = view.queryset.all()
try:
model_owners = []
for model in qs:
owner = model.get_owner()
if owner not in model_owners:
model_owners.append(owner)
if len(model_owners) == 1 and user in model_owners:
return 'own_'
except AttributeError:
return ''
return ''
def get_permission_for_view(request, view):
try:
permission_type = REQUEST_PERMISSION_TYPE_MAP[request.method]
if view.__class__.__name__ == 'APIRootView':
return '{}_apiroot'.format(permission_type)
model = view.model_permission_name
own_obj = get_own_obj(request, view)
return '{permission_type}_{own_obj}{model}'.format(permission_type=permission_type,
own_obj=own_obj,
model=model)
except AttributeError:
return None
def check_authorization_header(request):
auth_header = request.META.get('Authorization')
if not auth_header:
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
if auth_header.startswith('Api-Key'):
token = auth_header.split()[1]
if token == settings.CONFIG.get('general', 'api_key'):
return True
return False
class IsAdminOrOwnUser(BasePermission):
"""
Implements Django Rest Framework permissions. This is separate from
Django's standard permission system. For details see
https://www.django-rest-framework.org/api-guide/permissions/#custom-permissions
"""
def has_permission(self, request, view):
if request.user.is_superuser():
return True
return False
def has_object_permission(self, request, view, obj):
if request.user.is_superuser():
return True
return obj.username == request.user
class IsSystemTokenOrUser(BasePermission):
"""
Implements Django Rest Framework permissions. This is separate from
Django's standard permission system. For details see
https://www.django-rest-framework.org/api-guide/permissions/#custom-permissions
This permission allows services (liquidsoap, 3rd-party, etc) to connect with
an API-Key header. All standard-users (i.e. not using the API-Key) have their
permissions checked against Django's standard permission system.
"""
def has_permission(self, request, view):
if request.user and request.user.is_authenticated:
perm = get_permission_for_view(request, view)
# Required as view_apiroot is a permission not linked to a specific
# model. This use-case allows users to view the base of the API
# explorer. Their assigned group permissions determine further access
# into the explorer.
if perm == 'view_apiroot':
return True
return request.user.has_perm(perm)
return check_authorization_header(request)
def has_object_permission(self, request, view, obj):
if request.user and request.user.is_authenticated:
perm = get_permission_for_view(request, view)
return request.user.has_perm(perm, obj)
return check_authorization_header(request)

View File

@ -0,0 +1,265 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from .models import *
class UserSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = get_user_model()
fields = [
'item_url',
'username',
'type',
'first_name',
'last_name',
'lastfail',
'skype_contact',
'jabber_contact',
'email',
'cell_phone',
'login_attempts',
]
class SmartBlockSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = SmartBlock
fields = '__all__'
class SmartBlockContentSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = SmartBlockContent
fields = '__all__'
class SmartBlockCriteriaSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = SmartBlockCriteria
fields = '__all__'
class CountrySerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Country
fields = '__all__'
class FileSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = File
fields = '__all__'
class ListenerCountSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = ListenerCount
fields = '__all__'
class LiveLogSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = LiveLog
fields = '__all__'
class LoginAttemptSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = LoginAttempt
fields = '__all__'
class MountNameSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = MountName
fields = '__all__'
class MusicDirSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = MusicDir
fields = '__all__'
class PlaylistSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Playlist
fields = '__all__'
class PlaylistContentSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = PlaylistContent
fields = '__all__'
class PlayoutHistorySerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = PlayoutHistory
fields = '__all__'
class PlayoutHistoryMetadataSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = PlayoutHistoryMetadata
fields = '__all__'
class PlayoutHistoryTemplateSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = PlayoutHistoryTemplate
fields = '__all__'
class PlayoutHistoryTemplateFieldSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = PlayoutHistoryTemplateField
fields = '__all__'
class PreferenceSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Preference
fields = '__all__'
class ScheduleSerializer(serializers.HyperlinkedModelSerializer):
file_id = serializers.IntegerField(source='file.id', read_only=True)
stream_id = serializers.IntegerField(source='stream.id', read_only=True)
instance_id = serializers.IntegerField(source='instance.id', read_only=True)
class Meta:
model = Schedule
fields = [
'item_url',
'id',
'starts',
'ends',
'clip_length',
'fade_in',
'fade_out',
'cue_in',
'cue_out',
'media_item_played',
'file',
'file_id',
'stream',
'stream_id',
'instance',
'instance_id',
]
class ServiceRegisterSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = ServiceRegister
fields = '__all__'
class SessionSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Session
fields = '__all__'
class ShowSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Show
fields = [
'item_url',
'id',
'name',
'url',
'genre',
'description',
'color',
'background_color',
'linked',
'is_linkable',
'image_path',
'has_autoplaylist',
'autoplaylist_repeat',
'autoplaylist',
]
class ShowDaysSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = ShowDays
fields = '__all__'
class ShowHostSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = ShowHost
fields = '__all__'
class ShowInstanceSerializer(serializers.HyperlinkedModelSerializer):
show_id = serializers.IntegerField(source='show.id', read_only=True)
file_id = serializers.IntegerField(source='file.id', read_only=True)
class Meta:
model = ShowInstance
fields = [
'item_url',
'id',
'description',
'starts',
'ends',
'record',
'rebroadcast',
'time_filled',
'created',
'last_scheduled',
'modified_instance',
'autoplaylist_built',
'show',
'show_id',
'instance',
'file',
'file_id',
]
class ShowRebroadcastSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = ShowRebroadcast
fields = '__all__'
class StreamSettingSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = StreamSetting
fields = '__all__'
class UserTokenSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = UserToken
fields = '__all__'
class TimestampSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Timestamp
fields = '__all__'
class WebstreamSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Webstream
fields = '__all__'
class WebstreamMetadataSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = WebstreamMetadata
fields = '__all__'
class CeleryTaskSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = CeleryTask
fields = '__all__'
class CloudFileSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = CloudFile
fields = '__all__'
class ImportedPodcastSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = ImportedPodcast
fields = '__all__'
class PodcastSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Podcast
fields = '__all__'
class PodcastEpisodeSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = PodcastEpisode
fields = '__all__'
class StationPodcastSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = StationPodcast
fields = '__all__'
class ThirdPartyTrackReferenceSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = ThirdPartyTrackReference
fields = '__all__'
class TrackTypeSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = TrackType
fields = '__all__'

View File

@ -0,0 +1,184 @@
import configparser
import os
from .utils import read_config_file, get_random_string
LIBRETIME_CONF_DIR = os.getenv('LIBRETIME_CONF_DIR', '/etc/airtime')
DEFAULT_CONFIG_PATH = os.getenv('LIBRETIME_CONF_FILE',
os.path.join(LIBRETIME_CONF_DIR, 'airtime.conf'))
API_VERSION = '2.0.0'
try:
CONFIG = read_config_file(DEFAULT_CONFIG_PATH)
except IOError:
CONFIG = configparser.ConfigParser()
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = get_random_string(CONFIG.get('general', 'api_key', fallback=''))
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv('LIBRETIME_DEBUG', False)
ALLOWED_HOSTS = ['*']
# Application definition
INSTALLED_APPS = [
'libretimeapi.apps.LibreTimeAPIConfig',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'url_filter',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'libretimeapi.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'libretimeapi.wsgi.application'
# Database
# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': CONFIG.get('database', 'dbname', fallback=''),
'USER': CONFIG.get('database', 'dbuser', fallback=''),
'PASSWORD': CONFIG.get('database', 'dbpass', fallback=''),
'HOST': CONFIG.get('database', 'host', fallback=''),
'PORT': '5432',
}
}
# Password validation
# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.BasicAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': [
'libretimeapi.permissions.IsSystemTokenOrUser',
],
'DEFAULT_FILTER_BACKENDS': [
'url_filter.integrations.drf.DjangoFilterBackend',
],
'URL_FIELD_NAME': 'item_url',
}
# Internationalization
# https://docs.djangoproject.com/en/3.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.0/howto/static-files/
STATIC_URL = '/api/static/'
if not DEBUG:
STATIC_ROOT = os.getenv('LIBRETIME_STATIC_ROOT', '/usr/share/airtime/api')
AUTH_USER_MODEL = 'libretimeapi.User'
TEST_RUNNER = 'libretimeapi.tests.runners.ManagedModelTestRunner'
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'simple': {
'format': '{levelname} {message}',
'style': '{',
},
'verbose': {
'format': '{asctime} {module} {levelname} {message}',
'style': '{',
}
},
'handlers': {
'file': {
'level': 'DEBUG',
'class': 'logging.FileHandler',
'filename': os.path.join(CONFIG.get('pypo', 'log_base_dir', fallback='.').replace('\'',''), 'api.log'),
'formatter': 'verbose',
},
'console': {
'level': 'INFO',
'class': 'logging.StreamHandler',
'formatter': 'simple',
},
},
'loggers': {
'django': {
'handlers': ['file', 'console'],
'level': 'INFO',
'propogate': True,
},
'libretimeapi': {
'handlers': ['file', 'console'],
'level': 'INFO',
'propogate': True,
},
},
}

View File

Binary file not shown.

View File

@ -0,0 +1,24 @@
from django.test.runner import DiscoverRunner
class ManagedModelTestRunner(DiscoverRunner):
"""
Test runner that automatically makes all unmanaged models in your Django
project managed for the duration of the test run, so that one doesn't need
to execute the SQL manually to create them.
"""
def setup_test_environment(self, *args, **kwargs):
from django.apps import apps
self.unmanaged_models = [m for m in apps.get_models()
if not m._meta.managed]
for m in self.unmanaged_models:
m._meta.managed = True
super(ManagedModelTestRunner, self).setup_test_environment(*args,
**kwargs)
def teardown_test_environment(self, *args, **kwargs):
super(ManagedModelTestRunner, self).teardown_test_environment(*args,
**kwargs)
# reset unmanaged models
for m in self.unmanaged_models:
m._meta.managed = False

View File

@ -0,0 +1,40 @@
from rest_framework.test import APITestCase
from django.contrib.auth.models import Group
from django.apps import apps
from libretimeapi.models import User
from libretimeapi.models.user_constants import GUEST, DJ
from libretimeapi.permission_constants import GROUPS
class TestUserManager(APITestCase):
def test_create_user(self):
user = User.objects.create_user('test',
email='test@example.com',
password='test',
type=DJ,
first_name='test',
last_name='user')
db_user = User.objects.get(pk=user.pk)
self.assertEqual(db_user.username, user.username)
def test_create_superuser(self):
user = User.objects.create_superuser('test',
email='test@example.com',
password='test',
first_name='test',
last_name='user')
db_user = User.objects.get(pk=user.pk)
self.assertEqual(db_user.username, user.username)
class TestUser(APITestCase):
def test_guest_get_group_perms(self):
user = User.objects.create_user('test',
email='test@example.com',
password='test',
type=GUEST,
first_name='test',
last_name='user')
permissions = user.get_group_permissions()
# APIRoot permission hardcoded in the check as it isn't a Permission object
str_perms = [p.codename for p in permissions] + ['view_apiroot']
self.assertCountEqual(str_perms, GROUPS[GUEST])

View File

@ -0,0 +1,119 @@
import os
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.conf import settings
from rest_framework.test import APITestCase, APIRequestFactory
from model_bakery import baker
from libretimeapi.permissions import IsSystemTokenOrUser
from libretimeapi.permission_constants import GUEST_PERMISSIONS, DJ_PERMISSIONS, PROGRAM_MANAGER_PERMISSIONS
from libretimeapi.models.user_constants import GUEST, DJ, PROGRAM_MANAGER, ADMIN
class TestIsSystemTokenOrUser(APITestCase):
@classmethod
def setUpTestData(cls):
cls.path = "/api/v2/files/"
def test_unauthorized(self):
response = self.client.get(self.path.format('files'))
self.assertEqual(response.status_code, 403)
def test_token_incorrect(self):
token = 'doesnotexist'
request = APIRequestFactory().get(self.path)
request.user = AnonymousUser()
request.META['Authorization'] = 'Api-Key {token}'.format(token=token)
allowed = IsSystemTokenOrUser().has_permission(request, None)
self.assertFalse(allowed)
def test_token_correct(self):
token = settings.CONFIG.get('general', 'api_key')
request = APIRequestFactory().get(self.path)
request.user = AnonymousUser()
request.META['Authorization'] = 'Api-Key {token}'.format(token=token)
allowed = IsSystemTokenOrUser().has_permission(request, None)
self.assertTrue(allowed)
class TestPermissions(APITestCase):
URLS = [
'schedule',
'shows',
'show-days',
'show-hosts',
'show-instances',
'show-rebroadcasts',
'files',
'playlists',
'playlist-contents',
'smart-blocks',
'smart-block-contents',
'smart-block-criteria',
'webstreams',
]
def logged_in_test_model(self, model, name, user_type, fn):
path = self.path.format(model)
user_created = get_user_model().objects.filter(username=name)
if not user_created:
user = get_user_model().objects.create_user(name,
email='test@example.com',
password='test',
type=user_type,
first_name='test',
last_name='user')
self.client.login(username=name, password='test')
return fn(path)
@classmethod
def setUpTestData(cls):
cls.path = "/api/v2/{}/"
def test_guest_permissions_success(self):
for model in self.URLS:
response = self.logged_in_test_model(model, 'guest', GUEST, self.client.get)
self.assertEqual(response.status_code, 200,
msg='Invalid for model {}'.format(model))
def test_guest_permissions_failure(self):
for model in self.URLS:
response = self.logged_in_test_model(model, 'guest', GUEST, self.client.post)
self.assertEqual(response.status_code, 403,
msg='Invalid for model {}'.format(model))
response = self.logged_in_test_model('users', 'guest', GUEST, self.client.get)
self.assertEqual(response.status_code, 403, msg='Invalid for model users')
def test_dj_get_permissions(self):
for model in self.URLS:
response = self.logged_in_test_model(model, 'dj', DJ, self.client.get)
self.assertEqual(response.status_code, 200,
msg='Invalid for model {}'.format(model))
def test_dj_post_permissions(self):
user = get_user_model().objects.create_user('test-dj',
email='test@example.com',
password='test',
type=DJ,
first_name='test',
last_name='user')
f = baker.make('libretimeapi.File',
owner=user)
model = 'files/{}'.format(f.id)
path = self.path.format(model)
self.client.login(username='test-dj', password='test')
response = self.client.patch(path, {'name': 'newFilename'})
self.assertEqual(response.status_code, 200)
def test_dj_post_permissions_failure(self):
user = get_user_model().objects.create_user('test-dj',
email='test@example.com',
password='test',
type=DJ,
first_name='test',
last_name='user')
f = baker.make('libretimeapi.File')
model = 'files/{}'.format(f.id)
path = self.path.format(model)
self.client.login(username='test-dj', password='test')
response = self.client.patch(path, {'name': 'newFilename'})
self.assertEqual(response.status_code, 403)

View File

@ -0,0 +1,38 @@
import os
from django.contrib.auth.models import AnonymousUser
from django.conf import settings
from rest_framework.test import APITestCase, APIRequestFactory
from model_bakery import baker
from libretimeapi.views import FileViewSet
class TestFileViewSet(APITestCase):
@classmethod
def setUpTestData(cls):
cls.path = "/api/v2/files/{id}/download/"
cls.token = settings.CONFIG.get('general', 'api_key')
def test_invalid(self):
path = self.path.format(id='a')
self.client.credentials(HTTP_AUTHORIZATION='Api-Key {}'.format(self.token))
response = self.client.get(path)
self.assertEqual(response.status_code, 400)
def test_does_not_exist(self):
path = self.path.format(id='1')
self.client.credentials(HTTP_AUTHORIZATION='Api-Key {}'.format(self.token))
response = self.client.get(path)
self.assertEqual(response.status_code, 404)
def test_exists(self):
music_dir = baker.make('libretimeapi.MusicDir',
directory=os.path.join(os.path.dirname(__file__),
'resources'))
f = baker.make('libretimeapi.File',
directory=music_dir,
mime='audio/mp3',
filepath='song.mp3')
path = self.path.format(id=str(f.pk))
self.client.credentials(HTTP_AUTHORIZATION='Api-Key {}'.format(self.token))
response = self.client.get(path)
self.assertEqual(response.status_code, 200)

51
api/libretimeapi/urls.py Normal file
View File

@ -0,0 +1,51 @@
from django.urls import include, path
from rest_framework import routers
from .views import *
router = routers.DefaultRouter()
router.register('smart-blocks', SmartBlockViewSet)
router.register('smart-block-contents', SmartBlockContentViewSet)
router.register('smart-block-criteria', SmartBlockCriteriaViewSet)
router.register('countries', CountryViewSet)
router.register('files', FileViewSet)
router.register('listener-counts', ListenerCountViewSet)
router.register('live-logs', LiveLogViewSet)
router.register('login-attempts', LoginAttemptViewSet)
router.register('mount-names', MountNameViewSet)
router.register('music-dirs', MusicDirViewSet)
router.register('playlists', PlaylistViewSet)
router.register('playlist-contents', PlaylistContentViewSet)
router.register('playout-history', PlayoutHistoryViewSet)
router.register('playout-history-metadata', PlayoutHistoryMetadataViewSet)
router.register('playout-history-templates', PlayoutHistoryTemplateViewSet)
router.register('playout-history-template-fields', PlayoutHistoryTemplateFieldViewSet)
router.register('preferences', PreferenceViewSet)
router.register('schedule', ScheduleViewSet)
router.register('service-registers', ServiceRegisterViewSet)
router.register('sessions', SessionViewSet)
router.register('shows', ShowViewSet)
router.register('show-days', ShowDaysViewSet)
router.register('show-hosts', ShowHostViewSet)
router.register('show-instances', ShowInstanceViewSet)
router.register('show-rebroadcasts', ShowRebroadcastViewSet)
router.register('stream-settings', StreamSettingViewSet)
router.register('users', UserViewSet)
router.register('user-tokens', UserTokenViewSet)
router.register('timestamps', TimestampViewSet)
router.register('webstreams', WebstreamViewSet)
router.register('webstream-metadata', WebstreamMetadataViewSet)
router.register('celery-tasks', CeleryTaskViewSet)
router.register('cloud-files', CloudFileViewSet)
router.register('imported-podcasts', ImportedPodcastViewSet)
router.register('podcasts', PodcastViewSet)
router.register('podcast-episodes', PodcastEpisodeViewSet)
router.register('station-podcasts', StationPodcastViewSet)
router.register('third-party-track-references', ThirdPartyTrackReferenceViewSet)
router.register('track-types', TrackTypeViewSet)
urlpatterns = [
path('api/v2/', include(router.urls)),
path('api/v2/version/', version),
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
]

25
api/libretimeapi/utils.py Normal file
View File

@ -0,0 +1,25 @@
import configparser
import sys
import string
import random
def read_config_file(config_path):
"""Parse the application's config file located at config_path."""
config = configparser.ConfigParser()
try:
config.readfp(open(config_path))
except IOError as e:
print("Failed to open config file at {}: {}".format(config_path, e.strerror),
file=sys.stderr)
raise e
except Exception as e:
print(e.strerror, file=sys.stderr)
raise e
return config
def get_random_string(seed):
"""Generates a random string based on the given seed"""
choices = string.ascii_letters + string.digits + string.punctuation
seed = seed.encode('utf-8')
rand = random.Random(seed)
return [rand.choice(choices) for i in range(16)]

228
api/libretimeapi/views.py Normal file
View File

@ -0,0 +1,228 @@
import os
from django.conf import settings
from django.http import FileResponse
from django.shortcuts import get_object_or_404
from rest_framework import status, viewsets
from rest_framework.decorators import api_view, action, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from .serializers import *
from .permissions import IsAdminOrOwnUser
class UserViewSet(viewsets.ModelViewSet):
queryset = get_user_model().objects.all()
serializer_class = UserSerializer
permission_classes = [IsAdminOrOwnUser]
model_permission_name = 'user'
class SmartBlockViewSet(viewsets.ModelViewSet):
queryset = SmartBlock.objects.all()
serializer_class = SmartBlockSerializer
model_permission_name = 'smartblock'
class SmartBlockContentViewSet(viewsets.ModelViewSet):
queryset = SmartBlockContent.objects.all()
serializer_class = SmartBlockContentSerializer
model_permission_name = 'smartblockcontent'
class SmartBlockCriteriaViewSet(viewsets.ModelViewSet):
queryset = SmartBlockCriteria.objects.all()
serializer_class = SmartBlockCriteriaSerializer
model_permission_name = 'smartblockcriteria'
class CountryViewSet(viewsets.ModelViewSet):
queryset = Country.objects.all()
serializer_class = CountrySerializer
model_permission_name = 'country'
class FileViewSet(viewsets.ModelViewSet):
queryset = File.objects.all()
serializer_class = FileSerializer
model_permission_name = 'file'
@action(detail=True, methods=['GET'])
def download(self, request, pk=None):
if pk is None:
return Response('No file requested', status=status.HTTP_400_BAD_REQUEST)
try:
pk = int(pk)
except ValueError:
return Response('File ID should be an integer',
status=status.HTTP_400_BAD_REQUEST)
filename = get_object_or_404(File, pk=pk)
directory = filename.directory
path = os.path.join(directory.directory, filename.filepath)
response = FileResponse(open(path, 'rb'), content_type=filename.mime)
return response
class ListenerCountViewSet(viewsets.ModelViewSet):
queryset = ListenerCount.objects.all()
serializer_class = ListenerCountSerializer
model_permission_name = 'listenercount'
class LiveLogViewSet(viewsets.ModelViewSet):
queryset = LiveLog.objects.all()
serializer_class = LiveLogSerializer
model_permission_name = 'livelog'
class LoginAttemptViewSet(viewsets.ModelViewSet):
queryset = LoginAttempt.objects.all()
serializer_class = LoginAttemptSerializer
model_permission_name = 'loginattempt'
class MountNameViewSet(viewsets.ModelViewSet):
queryset = MountName.objects.all()
serializer_class = MountNameSerializer
model_permission_name = 'mountname'
class MusicDirViewSet(viewsets.ModelViewSet):
queryset = MusicDir.objects.all()
serializer_class = MusicDirSerializer
model_permission_name = 'musicdir'
class PlaylistViewSet(viewsets.ModelViewSet):
queryset = Playlist.objects.all()
serializer_class = PlaylistSerializer
model_permission_name = 'playlist'
class PlaylistContentViewSet(viewsets.ModelViewSet):
queryset = PlaylistContent.objects.all()
serializer_class = PlaylistContentSerializer
model_permission_name = 'playlistcontent'
class PlayoutHistoryViewSet(viewsets.ModelViewSet):
queryset = PlayoutHistory.objects.all()
serializer_class = PlayoutHistorySerializer
model_permission_name = 'playouthistory'
class PlayoutHistoryMetadataViewSet(viewsets.ModelViewSet):
queryset = PlayoutHistoryMetadata.objects.all()
serializer_class = PlayoutHistoryMetadataSerializer
model_permission_name = 'playouthistorymetadata'
class PlayoutHistoryTemplateViewSet(viewsets.ModelViewSet):
queryset = PlayoutHistoryTemplate.objects.all()
serializer_class = PlayoutHistoryTemplateSerializer
model_permission_name = 'playouthistorytemplate'
class PlayoutHistoryTemplateFieldViewSet(viewsets.ModelViewSet):
queryset = PlayoutHistoryTemplateField.objects.all()
serializer_class = PlayoutHistoryTemplateFieldSerializer
model_permission_name = 'playouthistorytemplatefield'
class PreferenceViewSet(viewsets.ModelViewSet):
queryset = Preference.objects.all()
serializer_class = PreferenceSerializer
model_permission_name = 'perference'
class ScheduleViewSet(viewsets.ModelViewSet):
queryset = Schedule.objects.all()
serializer_class = ScheduleSerializer
filter_fields = ('starts', 'ends', 'playout_status', 'broadcasted')
model_permission_name = 'schedule'
class ServiceRegisterViewSet(viewsets.ModelViewSet):
queryset = ServiceRegister.objects.all()
serializer_class = ServiceRegisterSerializer
model_permission_name = 'serviceregister'
class SessionViewSet(viewsets.ModelViewSet):
queryset = Session.objects.all()
serializer_class = SessionSerializer
model_permission_name = 'session'
class ShowViewSet(viewsets.ModelViewSet):
queryset = Show.objects.all()
serializer_class = ShowSerializer
model_permission_name = 'show'
class ShowDaysViewSet(viewsets.ModelViewSet):
queryset = ShowDays.objects.all()
serializer_class = ShowDaysSerializer
model_permission_name = 'showdays'
class ShowHostViewSet(viewsets.ModelViewSet):
queryset = ShowHost.objects.all()
serializer_class = ShowHostSerializer
model_permission_name = 'showhost'
class ShowInstanceViewSet(viewsets.ModelViewSet):
queryset = ShowInstance.objects.all()
serializer_class = ShowInstanceSerializer
model_permission_name = 'showinstance'
class ShowRebroadcastViewSet(viewsets.ModelViewSet):
queryset = ShowRebroadcast.objects.all()
serializer_class = ShowRebroadcastSerializer
model_permission_name = 'showrebroadcast'
class StreamSettingViewSet(viewsets.ModelViewSet):
queryset = StreamSetting.objects.all()
serializer_class = StreamSettingSerializer
model_permission_name = 'streamsetting'
class UserTokenViewSet(viewsets.ModelViewSet):
queryset = UserToken.objects.all()
serializer_class = UserTokenSerializer
model_permission_name = 'usertoken'
class TimestampViewSet(viewsets.ModelViewSet):
queryset = Timestamp.objects.all()
serializer_class = TimestampSerializer
model_permission_name = 'timestamp'
class WebstreamViewSet(viewsets.ModelViewSet):
queryset = Webstream.objects.all()
serializer_class = WebstreamSerializer
model_permission_name = 'webstream'
class WebstreamMetadataViewSet(viewsets.ModelViewSet):
queryset = WebstreamMetadata.objects.all()
serializer_class = WebstreamMetadataSerializer
model_permission_name = 'webstreametadata'
class CeleryTaskViewSet(viewsets.ModelViewSet):
queryset = CeleryTask.objects.all()
serializer_class = CeleryTaskSerializer
model_permission_name = 'celerytask'
class CloudFileViewSet(viewsets.ModelViewSet):
queryset = CloudFile.objects.all()
serializer_class = CloudFileSerializer
model_permission_name = 'cloudfile'
class ImportedPodcastViewSet(viewsets.ModelViewSet):
queryset = ImportedPodcast.objects.all()
serializer_class = ImportedPodcastSerializer
model_permission_name = 'importedpodcast'
class PodcastViewSet(viewsets.ModelViewSet):
queryset = Podcast.objects.all()
serializer_class = PodcastSerializer
model_permission_name = 'podcast'
class PodcastEpisodeViewSet(viewsets.ModelViewSet):
queryset = PodcastEpisode.objects.all()
serializer_class = PodcastEpisodeSerializer
model_permission_name = 'podcastepisode'
class StationPodcastViewSet(viewsets.ModelViewSet):
queryset = StationPodcast.objects.all()
serializer_class = StationPodcastSerializer
model_permission_name = 'station'
class ThirdPartyTrackReferenceViewSet(viewsets.ModelViewSet):
queryset = ThirdPartyTrackReference.objects.all()
serializer_class = ThirdPartyTrackReferenceSerializer
model_permission_name = 'thirdpartytrackreference'
class TrackTypeViewSet(viewsets.ModelViewSet):
queryset = TrackType.objects.all()
serializer_class = TrackTypeSerializer
model_permission_name = 'tracktype'
@api_view(['GET'])
@permission_classes((AllowAny, ))
def version(request, *args, **kwargs):
return Response({'api_version': settings.API_VERSION})

16
api/libretimeapi/wsgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
WSGI config for api project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'libretimeapi.settings')
application = get_wsgi_application()

32
api/setup.py Normal file
View File

@ -0,0 +1,32 @@
import os
import shutil
from setuptools import setup, find_packages
script_path = os.path.dirname(os.path.realpath(__file__))
print(script_path)
os.chdir(script_path)
setup(
name='libretime-api',
version='2.0.0a1',
packages=find_packages(),
include_package_data=True,
description='LibreTime API backend server',
url='https://github.com/LibreTime/libretime',
author='LibreTime Contributors',
scripts=['bin/libretime-api'],
install_requires=[
'coreapi',
'Django~=3.0',
'djangorestframework',
'django-url-filter',
'markdown',
'model_bakery',
'psycopg2',
],
project_urls={
'Bug Tracker': 'https://github.com/LibreTime/libretime/issues',
'Documentation': 'https://libretime.org',
'Source Code': 'https://github.com/LibreTime/libretime',
},
)

25
install
View File

@ -202,6 +202,11 @@ function systemInitInstall() {
target_path="/etc/systemd/system/${service_name}.service"
alt_path=$(echo $target_path | sed 's/libretime-/airtime-/')
;;
libretime-api)
source_path="${SCRIPT_DIR-$PWD}/api/install/systemd/${service_name}.service"
target_path="/etc/systemd/system/${service_name}.service"
alt_path=$(echo $target_path | sed 's/libretime-/airtime-/')
;;
esac
if [[ ! -e $source_path ]]; then
echo "$0:${FUNCNAME}(): ERROR: service \"$service_name\" with source path \"$source_path\" does not exist!" >&2
@ -1019,6 +1024,16 @@ loudCmd "$python_bin ${AIRTIMEROOT}/python_apps/airtime_analyzer/setup.py instal
systemInitInstall libretime-analyzer $web_user
verbose "...Done"
verbose "\n * Installing API..."
loudCmd "python3 ${AIRTIMEROOT}/api/setup.py install --install-scripts=/usr/bin"
systemInitInstall libretime-api $web_user
mkdir -p /etc/airtime
sed -e "s@WEB_USER@${web_user}@g" \
-e "s@WEB_ROOT@${web_root}@g" \
${AIRTIMEROOT}/installer/uwsgi/libretime-api.ini > /etc/airtime/libretime-api.ini
loudCmd "libretime-api collectstatic --clear --noinput"
verbose "...Done"
verbose "\n * Setting permissions on /var/log/airtime..."
# Make the airtime log directory group-writable
loudCmd "chmod -R 775 /var/log/airtime"
@ -1058,15 +1073,15 @@ fi
# Enable Apache modules
if $is_debian_buster; then
loudCmd "a2enmod rewrite php7.3"
loudCmd "a2enmod rewrite php7.3 proxy proxy_http"
elif $is_ubuntu_bionic; then
loudCmd "a2enmod rewrite php7.2"
loudCmd "a2enmod rewrite php7.2 proxy proxy_http"
elif $is_ubuntu_xenial || $is_debian_stretch; then
loudCmd "a2enmod rewrite php7.0"
loudCmd "a2enmod rewrite php7.0 proxy proxy_http"
elif $is_centos_dist; then
verbose "TODO: enable Apache modules mod_rewrite and mod_php manually"
verbose "TODO: enable Apache modules mod_rewrite, mod_php, mod_proxy and mod_proxy_http manually"
else
loudCmd "a2enmod rewrite php5"
loudCmd "a2enmod rewrite php5 proxy proxy_http"
fi
if [ $skip_postgres -eq 0 ]; then

View File

@ -21,9 +21,14 @@ WEB_PORT_LISTEN
AddOutputFilterByType DEFLATE application/x-javascript
AddOutputFilterByType DEFLATE application/json
ProxyPreserveHost On
ProxyPass /api/v2/ http://localhost:8081/api/v2/
ProxyPassReverse /api/v2/ http://localhost:8081/api/v2/
ProxyPass /api-auth/ http://localhost:8081/api-auth/
ProxyPassReverse /api-auth/ http://localhost:8081/api-auth/
Alias /api/static /usr/share/airtime/api/
<Directory WEB_ROOT>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} -s [OR]
RewriteCond %{REQUEST_FILENAME} -l [OR]
@ -34,6 +39,10 @@ WEB_PORT_LISTEN
Allow from all
Require all granted
</Directory>
<Directory /usr/share/airtime/api>
Require all granted
</Directory>
</VirtualHost>

View File

@ -21,6 +21,12 @@ WEB_PORT_LISTEN
AddOutputFilterByType DEFLATE application/x-javascript
AddOutputFilterByType DEFLATE application/json
ProxyPreserveHost On
ProxyPass /api/v2/ http://localhost:8081/api/v2/
ProxyPassReverse /api/v2/ http://localhost:8081/api/v2/
ProxyPass /api-auth/ http://localhost:8081/api-auth/
ProxyPassReverse /api-auth/ http://localhost:8081/api-auth/
Alias /api/static /usr/share/airtime/api/
<Directory WEB_ROOT>
RewriteEngine On
@ -37,4 +43,8 @@ WEB_PORT_LISTEN
Require all granted
</Directory>
<Directory /usr/share/airtime/api>
Require all granted
</Directory>
</VirtualHost>

View File

@ -22,6 +22,7 @@ libmad-ocaml
libopus0
libportaudio2
libpulse0
libpq-dev
libsamplerate0
libsoundtouch-ocaml
libtaglib-ocaml
@ -58,6 +59,8 @@ rabbitmq-server
silan
systemd-sysv
unzip
uwsgi
uwsgi-plugin-python3
vorbisgain
vorbis-tools
vorbis-tools

View File

@ -17,6 +17,7 @@ libmad-ocaml
libopus0
libportaudio2
libpulse0
libpq-dev
libsamplerate0
libsoundtouch-ocaml
libtaglib-ocaml
@ -49,6 +50,8 @@ python3-cairo
rabbitmq-server
systemd-sysv
unzip
uwsgi
uwsgi-plugin-python3
vorbisgain
vorbis-tools
vorbis-tools

View File

@ -23,6 +23,7 @@ libmad-ocaml
libopus0
libportaudio2
libpulse0
libpq-dev
libsamplerate0
libsoundtouch-ocaml
libssl-dev
@ -70,6 +71,8 @@ rabbitmq-server
silan
sysvinit-utils
unzip
uwsgi
uwsgi-plugin-python3
vorbisgain
vorbis-tools
xmlstarlet

View File

@ -23,6 +23,7 @@ libmad-ocaml
libopus0
libportaudio2
libpulse0
libpq-dev
libsamplerate0
libsoundtouch-ocaml
libssl-dev
@ -70,6 +71,8 @@ rabbitmq-server
silan
sysvinit-utils
unzip
uwsgi
uwsgi-plugin-python3
vorbisgain
vorbis-tools
vorbis-tools

View File

@ -17,6 +17,7 @@ libmad-ocaml
libopus0
libportaudio2
libpulse0
libpq-dev
libsamplerate0
libsoundtouch-ocaml
libssl-dev
@ -63,4 +64,6 @@ vorbisgain
vorbis-tools
vorbis-tools
xmlstarlet
uwsgi
uwsgi-plugin-python3
zip

View File

@ -0,0 +1,10 @@
[uwsgi]
module = libretimeapi.wsgi
master = true
plugin = python3
processes = 10
vacuum = true
http-socket = :8081
uid = WEB_USER
gid = WEB_USER

View File

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
__all__ = ["api_client"]
__all__ = ["version1"]

View File

@ -0,0 +1,166 @@
import json
import logging
import socket
import requests
from requests.auth import AuthBase
def get_protocol(config):
positive_values = ['Yes', 'yes', 'True', 'true', True]
port = config['general'].get('base_port', 80)
force_ssl = config['general'].get('force_ssl', False)
if force_ssl in positive_values:
protocol = 'https'
else:
protocol = config['general'].get('protocol')
if not protocol:
protocol = str(("http", "https")[int(port) == 443])
return protocol
class UrlParamDict(dict):
def __missing__(self, key):
return '{' + key + '}'
class UrlException(Exception): pass
class IncompleteUrl(UrlException):
def __init__(self, url):
self.url = url
def __str__(self):
return "Incomplete url: '{}'".format(self.url)
class UrlBadParam(UrlException):
def __init__(self, url, param):
self.url = url
self.param = param
def __str__(self):
return "Bad param '{}' passed into url: '{}'".format(self.param, self.url)
class KeyAuth(AuthBase):
def __init__(self, key):
self.key = key
def __call__(self, r):
r.headers['Authorization'] = "Api-Key {}".format(self.key)
return r
class ApcUrl:
""" A safe abstraction and testable for filling in parameters in
api_client.cfg"""
def __init__(self, base_url):
self.base_url = base_url
def params(self, **params):
temp_url = self.base_url
for k, v in params.items():
wrapped_param = "{" + k + "}"
if not wrapped_param in temp_url:
raise UrlBadParam(self.base_url, k)
temp_url = temp_url.format_map(UrlParamDict(**params))
return ApcUrl(temp_url)
def url(self):
if '{' in self.base_url:
raise IncompleteUrl(self.base_url)
else:
return self.base_url
class ApiRequest:
API_HTTP_REQUEST_TIMEOUT = 30 # 30 second HTTP request timeout
def __init__(self, name, url, logger=None, api_key=None):
self.name = name
self.url = url
self.__req = None
if logger is None:
self.logger = logging
else:
self.logger = logger
self.auth = KeyAuth(api_key)
def __call__(self, _post_data=None, params=None, **kwargs):
final_url = self.url.params(**kwargs).url()
self.logger.debug(final_url)
try:
if _post_data:
response = requests.post(final_url,
data=_post_data, auth=self.auth,
timeout=ApiRequest.API_HTTP_REQUEST_TIMEOUT)
else:
response = requests.get(final_url, params=params, auth=self.auth,
timeout=ApiRequest.API_HTTP_REQUEST_TIMEOUT)
if 'application/json' in response.headers['content-type']:
return response.json()
return response
except requests.exceptions.Timeout:
self.logger.error('HTTP request to %s timed out', final_url)
raise
def req(self, *args, **kwargs):
self.__req = lambda : self(*args, **kwargs)
return self
def retry(self, n, delay=5):
"""Try to send request n times. If after n times it fails then
we finally raise exception"""
for i in range(0,n-1):
try:
return self.__req()
except Exception:
time.sleep(delay)
return self.__req()
class RequestProvider:
""" Creates the available ApiRequest instance that can be read from
a config file """
def __init__(self, cfg, endpoints):
self.config = cfg
self.requests = {}
if self.config["general"]["base_dir"].startswith("/"):
self.config["general"]["base_dir"] = self.config["general"]["base_dir"][1:]
protocol = get_protocol(self.config)
base_port = self.config['general']['base_port']
base_url = self.config['general']['base_url']
base_dir = self.config['general']['base_dir']
api_base = self.config['api_base']
api_url = "{protocol}://{base_url}:{base_port}/{base_dir}{api_base}/{action}".format_map(
UrlParamDict(protocol=protocol,
base_url=base_url,
base_port=base_port,
base_dir=base_dir,
api_base=api_base
))
self.url = ApcUrl(api_url)
# Now we must discover the possible actions
for action_name, action_value in endpoints.items():
new_url = self.url.params(action=action_value)
if '{api_key}' in action_value:
new_url = new_url.params(api_key=self.config["general"]['api_key'])
self.requests[action_name] = ApiRequest(action_name,
new_url,
api_key=self.config['general']['api_key'])
def available_requests(self):
return list(self.requests.keys())
def __contains__(self, request):
return request in self.requests
def __getattr__(self, attr):
if attr in self:
return self.requests[attr]
else:
return super(RequestProvider, self).__getattribute__(attr)
def time_in_seconds(time):
return time.hour * 60 * 60 + \
time.minute * 60 + \
time.second + \
time.microsecond / 1000000.0
def time_in_milliseconds(time):
return time_in_seconds(time) * 1000

View File

@ -8,202 +8,71 @@
###############################################################################
import sys
import time
import urllib.request, urllib.error, urllib.parse
import urllib.parse
import requests
import socket
import logging
import json
import base64
import traceback
from configobj import ConfigObj
from .utils import RequestProvider, ApiRequest, get_protocol
AIRTIME_API_VERSION = "1.1"
api_config = {}
api_endpoints = {}
# URL to get the version number of the server API
api_config['version_url'] = 'version/api_key/%%api_key%%'
api_endpoints['version_url'] = 'version/api_key/{api_key}'
#URL to register a components IP Address with the central web server
api_config['register_component'] = 'register-component/format/json/api_key/%%api_key%%/component/%%component%%'
api_endpoints['register_component'] = 'register-component/format/json/api_key/{api_key}/component/{component}'
#media-monitor
api_config['media_setup_url'] = 'media-monitor-setup/format/json/api_key/%%api_key%%'
api_config['upload_recorded'] = 'upload-recorded/format/json/api_key/%%api_key%%/fileid/%%fileid%%/showinstanceid/%%showinstanceid%%'
api_config['update_media_url'] = 'reload-metadata/format/json/api_key/%%api_key%%/mode/%%mode%%'
api_config['list_all_db_files'] = 'list-all-files/format/json/api_key/%%api_key%%/dir_id/%%dir_id%%/all/%%all%%'
api_config['list_all_watched_dirs'] = 'list-all-watched-dirs/format/json/api_key/%%api_key%%'
api_config['add_watched_dir'] = 'add-watched-dir/format/json/api_key/%%api_key%%/path/%%path%%'
api_config['remove_watched_dir'] = 'remove-watched-dir/format/json/api_key/%%api_key%%/path/%%path%%'
api_config['set_storage_dir'] = 'set-storage-dir/format/json/api_key/%%api_key%%/path/%%path%%'
api_config['update_fs_mount'] = 'update-file-system-mount/format/json/api_key/%%api_key%%'
api_config['reload_metadata_group'] = 'reload-metadata-group/format/json/api_key/%%api_key%%'
api_config['handle_watched_dir_missing'] = 'handle-watched-dir-missing/format/json/api_key/%%api_key%%/dir/%%dir%%'
api_endpoints['media_setup_url'] = 'media-monitor-setup/format/json/api_key/{api_key}'
api_endpoints['upload_recorded'] = 'upload-recorded/format/json/api_key/{api_key}/fileid/{fileid}/showinstanceid/{showinstanceid}'
api_endpoints['update_media_url'] = 'reload-metadata/format/json/api_key/{api_key}/mode/{mode}'
api_endpoints['list_all_db_files'] = 'list-all-files/format/json/api_key/{api_key}/dir_id/{dir_id}/all/{all}'
api_endpoints['list_all_watched_dirs'] = 'list-all-watched-dirs/format/json/api_key/{api_key}'
api_endpoints['add_watched_dir'] = 'add-watched-dir/format/json/api_key/{api_key}/path/{path}'
api_endpoints['remove_watched_dir'] = 'remove-watched-dir/format/json/api_key/{api_key}/path/{path}'
api_endpoints['set_storage_dir'] = 'set-storage-dir/format/json/api_key/{api_key}/path/{path}'
api_endpoints['update_fs_mount'] = 'update-file-system-mount/format/json/api_key/{api_key}'
api_endpoints['reload_metadata_group'] = 'reload-metadata-group/format/json/api_key/{api_key}'
api_endpoints['handle_watched_dir_missing'] = 'handle-watched-dir-missing/format/json/api_key/{api_key}/dir/{dir}'
#show-recorder
api_config['show_schedule_url'] = 'recorded-shows/format/json/api_key/%%api_key%%'
api_config['upload_file_url'] = 'rest/media'
api_config['upload_retries'] = '3'
api_config['upload_wait'] = '60'
api_endpoints['show_schedule_url'] = 'recorded-shows/format/json/api_key/{api_key}'
api_endpoints['upload_file_url'] = 'rest/media'
api_endpoints['upload_retries'] = '3'
api_endpoints['upload_wait'] = '60'
#pypo
api_config['export_url'] = 'schedule/api_key/%%api_key%%'
api_config['get_media_url'] = 'get-media/file/%%file%%/api_key/%%api_key%%'
api_config['update_item_url'] = 'notify-schedule-group-play/api_key/%%api_key%%/schedule_id/%%schedule_id%%'
api_config['update_start_playing_url'] = 'notify-media-item-start-play/api_key/%%api_key%%/media_id/%%media_id%%/'
api_config['get_stream_setting'] = 'get-stream-setting/format/json/api_key/%%api_key%%/'
api_config['update_liquidsoap_status'] = 'update-liquidsoap-status/format/json/api_key/%%api_key%%/msg/%%msg%%/stream_id/%%stream_id%%/boot_time/%%boot_time%%'
api_config['update_source_status'] = 'update-source-status/format/json/api_key/%%api_key%%/sourcename/%%sourcename%%/status/%%status%%'
api_config['check_live_stream_auth'] = 'check-live-stream-auth/format/json/api_key/%%api_key%%/username/%%username%%/password/%%password%%/djtype/%%djtype%%'
api_config['get_bootstrap_info'] = 'get-bootstrap-info/format/json/api_key/%%api_key%%'
api_config['get_files_without_replay_gain'] = 'get-files-without-replay-gain/api_key/%%api_key%%/dir_id/%%dir_id%%'
api_config['update_replay_gain_value'] = 'update-replay-gain-value/format/json/api_key/%%api_key%%'
api_config['notify_webstream_data'] = 'notify-webstream-data/api_key/%%api_key%%/media_id/%%media_id%%/format/json'
api_config['notify_liquidsoap_started'] = 'rabbitmq-do-push/api_key/%%api_key%%/format/json'
api_config['get_stream_parameters'] = 'get-stream-parameters/api_key/%%api_key%%/format/json'
api_config['push_stream_stats'] = 'push-stream-stats/api_key/%%api_key%%/format/json'
api_config['update_stream_setting_table'] = 'update-stream-setting-table/api_key/%%api_key%%/format/json'
api_config['get_files_without_silan_value'] = 'get-files-without-silan-value/api_key/%%api_key%%'
api_config['update_cue_values_by_silan'] = 'update-cue-values-by-silan/api_key/%%api_key%%'
api_endpoints['export_url'] = 'schedule/api_key/{api_key}'
api_endpoints['get_media_url'] = 'get-media/file/{file}/api_key/{api_key}'
api_endpoints['update_item_url'] = 'notify-schedule-group-play/api_key/{api_key}/schedule_id/{schedule_id}'
api_endpoints['update_start_playing_url'] = 'notify-media-item-start-play/api_key/{api_key}/media_id/{media_id}/'
api_endpoints['get_stream_setting'] = 'get-stream-setting/format/json/api_key/{api_key}/'
api_endpoints['update_liquidsoap_status'] = 'update-liquidsoap-status/format/json/api_key/{api_key}/msg/{msg}/stream_id/{stream_id}/boot_time/{boot_time}'
api_endpoints['update_source_status'] = 'update-source-status/format/json/api_key/{api_key}/sourcename/{sourcename}/status/{status}'
api_endpoints['check_live_stream_auth'] = 'check-live-stream-auth/format/json/api_key/{api_key}/username/{username}/password/{password}/djtype/{djtype}'
api_endpoints['get_bootstrap_info'] = 'get-bootstrap-info/format/json/api_key/{api_key}'
api_endpoints['get_files_without_replay_gain'] = 'get-files-without-replay-gain/api_key/{api_key}/dir_id/{dir_id}'
api_endpoints['update_replay_gain_value'] = 'update-replay-gain-value/format/json/api_key/{api_key}'
api_endpoints['notify_webstream_data'] = 'notify-webstream-data/api_key/{api_key}/media_id/{media_id}/format/json'
api_endpoints['notify_liquidsoap_started'] = 'rabbitmq-do-push/api_key/{api_key}/format/json'
api_endpoints['get_stream_parameters'] = 'get-stream-parameters/api_key/{api_key}/format/json'
api_endpoints['push_stream_stats'] = 'push-stream-stats/api_key/{api_key}/format/json'
api_endpoints['update_stream_setting_table'] = 'update-stream-setting-table/api_key/{api_key}/format/json'
api_endpoints['get_files_without_silan_value'] = 'get-files-without-silan-value/api_key/{api_key}'
api_endpoints['update_cue_values_by_silan'] = 'update-cue-values-by-silan/api_key/{api_key}'
api_endpoints['update_metadata_on_tunein'] = 'update-metadata-on-tunein/api_key/{api_key}'
api_config['api_base'] = 'api'
api_config['bin_dir'] = '/usr/lib/airtime/api_clients/'
api_config['update_metadata_on_tunein'] = 'update-metadata-on-tunein/api_key/%%api_key%%'
def get_protocol(config):
positive_values = ['Yes', 'yes', 'True', 'true', True]
port = config['general'].get('base_port', 80)
force_ssl = config['general'].get('force_ssl', False)
if force_ssl in positive_values:
protocol = 'https'
else:
protocol = config['general'].get('protocol')
if not protocol:
protocol = str(("http", "https")[int(port) == 443])
return protocol
################################################################################
# Airtime API Client
# Airtime API Version 1 Client
################################################################################
class UrlException(Exception): pass
class IncompleteUrl(UrlException):
def __init__(self, url): self.url = url
def __str__(self): return "Incomplete url: '%s'" % self.url
class UrlBadParam(UrlException):
def __init__(self, url, param):
self.url = url
self.param = param
def __str__(self):
return "Bad param '%s' passed into url: '%s'" % (self.param, self.url)
class ApcUrl(object):
""" A safe abstraction and testable for filling in parameters in
api_client.cfg"""
def __init__(self, base_url): self.base_url = base_url
def params(self, **params):
temp_url = self.base_url
for k, v in params.items():
wrapped_param = "%%" + k + "%%"
if wrapped_param in temp_url:
temp_url = temp_url.replace(wrapped_param, str(v))
else: raise UrlBadParam(self.base_url, k)
return ApcUrl(temp_url)
def url(self):
if '%%' in self.base_url: raise IncompleteUrl(self.base_url)
else: return self.base_url
class ApiRequest(object):
API_HTTP_REQUEST_TIMEOUT = 30 # 30 second HTTP request timeout
def __init__(self, name, url, logger=None):
self.name = name
self.url = url
self.__req = None
if logger is None: self.logger = logging
else: self.logger = logger
def __call__(self,_post_data=None, **kwargs):
final_url = self.url.params(**kwargs).url()
if _post_data is not None:
_post_data = urllib.parse.urlencode(_post_data).encode('utf-8')
self.logger.debug(final_url)
try:
req = urllib.request.Request(final_url, _post_data)
f = urllib.request.urlopen(req, timeout=ApiRequest.API_HTTP_REQUEST_TIMEOUT)
content_type = f.info().get_content_type()
response = f.read()
#Everything that calls an ApiRequest should be catching URLError explicitly
#(according to the other comments in this file and a cursory grep through the code)
#Note that URLError can occur for timeouts as well as socket.timeout
except socket.timeout:
self.logger.error('HTTP request to %s timed out', final_url)
raise
except Exception as e:
#self.logger.exception(e)
raise
try:
if content_type == 'application/json':
try:
response = response.decode()
except (UnicodeDecodeError, AttributeError):
pass
data = json.loads(response)
return data
else:
raise InvalidContentType()
except Exception:
#self.logger.exception(e)
raise
def req(self, *args, **kwargs):
self.__req = lambda : self(*args, **kwargs)
return self
def retry(self, n, delay=5):
"""Try to send request n times. If after n times it fails then
we finally raise exception"""
for i in range(0,n-1):
try: return self.__req()
except Exception: time.sleep(delay)
return self.__req()
class RequestProvider(object):
""" Creates the available ApiRequest instance that can be read from
a config file """
def __init__(self, cfg):
self.config = cfg
self.requests = {}
if self.config["general"]["base_dir"].startswith("/"):
self.config["general"]["base_dir"] = self.config["general"]["base_dir"][1:]
protocol = get_protocol(self.config)
self.url = ApcUrl("%s://%s:%s/%s%s/%s" \
% (protocol, self.config["general"]["base_url"],
str(self.config["general"]["base_port"]),
self.config["general"]["base_dir"], self.config["api_base"],
'%%action%%'))
# Now we must discover the possible actions
actions = dict( (k,v) for k,v in cfg.items() if '%%api_key%%' in v)
for action_name, action_value in actions.items():
new_url = self.url.params(action=action_value).params(
api_key=self.config["general"]['api_key'])
self.requests[action_name] = ApiRequest(action_name, new_url)
def available_requests(self) : return list(self.requests.keys())
def __contains__(self, request) : return request in self.requests
def __getattr__(self, attr):
if attr in self:
return self.requests[attr]
else:
return super(RequestProvider, self).__getattribute__(attr)
class AirtimeApiClient(object):
def __init__(self, logger=None,config_path='/etc/airtime/airtime.conf'):
if logger is None: self.logger = logging
@ -213,7 +82,7 @@ class AirtimeApiClient(object):
try:
self.config = ConfigObj(config_path)
self.config.update(api_config)
self.services = RequestProvider(self.config)
self.services = RequestProvider(self.config, api_endpoints)
except Exception as e:
self.logger.exception('Error loading config file: %s', config_path)
sys.exit(1)
@ -223,8 +92,11 @@ class AirtimeApiClient(object):
except Exception: return -1
def __get_api_version(self):
try: return self.services.version_url()['api_version']
except Exception: return -1
try:
return self.services.version_url()['api_version']
except Exception as e:
self.logger.exception(e)
return -1
def is_server_compatible(self, verbose=True):
logger = self.logger

View File

@ -0,0 +1,124 @@
###############################################################################
# This file holds the implementations for all the API clients.
#
# If you want to develop a new client, here are some suggestions: Get the fetch
# methods working first, then the push, then the liquidsoap notifier. You will
# probably want to create a script on your server side to automatically
# schedule a playlist one minute from the current time.
###############################################################################
import datetime
from dateutil.parser import isoparse
import logging
from configobj import ConfigObj
from .utils import RequestProvider, time_in_seconds, time_in_milliseconds
LIBRETIME_API_VERSION = "2.0"
api_config = {}
api_endpoints = {}
api_endpoints['version_url'] = 'version/'
api_endpoints['schedule_url'] = 'schedule/'
api_endpoints['webstream_url'] = 'webstreams/{id}/'
api_endpoints['show_instance_url'] = 'show-instances/{id}/'
api_endpoints['show_url'] = 'shows/{id}/'
api_endpoints['file_url'] = 'files/{id}/'
api_endpoints['file_download_url'] = 'files/{id}/download/'
api_config['api_base'] = 'api/v2'
class AirtimeApiClient:
def __init__(self, logger=None, config_path='/etc/airtime/airtime.conf'):
if logger is None:
self.logger = logging
else:
self.logger = logger
try:
self.config = ConfigObj(config_path)
self.config.update(api_config)
self.services = RequestProvider(self.config, api_endpoints)
except Exception as e:
self.logger.exception('Error loading config file: %s', config_path)
sys.exit(1)
def get_schedule(self):
current_time = datetime.datetime.utcnow()
end_time = current_time + datetime.timedelta(hours=1)
str_current = current_time.isoformat(timespec='seconds')
str_end = end_time.isoformat(timespec='seconds')
data = self.services.schedule_url(params={
'ends__range': (f'{str_current}Z,{str_end}Z'),
})
result = {'media': {} }
for item in data:
start = isoparse(item['starts'])
key = start.strftime('%YYYY-%mm-%dd-%HH-%MM-%SS')
end = isoparse(item['ends'])
show_instance = self.services.show_instance_url(id=item['instance_id'])
show = self.services.show_url(id=show_instance['show_id'])
result['media'][key] = {
'start': start.strftime('%Y-%m-%d-%H-%M-%S'),
'end': end.strftime('%Y-%m-%d-%H-%M-%S'),
'row_id': item['id']
}
current = result['media'][key]
if item['file']:
current['independent_event'] = False
current['type'] = 'file'
current['id'] = item['file_id']
fade_in = time_in_milliseconds(datetime.time.fromisoformat(item['fade_in']))
fade_out = time_in_milliseconds(datetime.time.fromisoformat(item['fade_out']))
cue_in = time_in_seconds(datetime.time.fromisoformat(item['cue_in']))
cue_out = time_in_seconds(datetime.time.fromisoformat(item['cue_out']))
current['fade_in'] = fade_in
current['fade_out'] = fade_out
current['cue_in'] = cue_in
current['cue_out'] = cue_out
info = self.services.file_url(id=item['file_id'])
current['metadata'] = info
current['uri'] = item['file']
current['filesize'] = info['filesize']
elif item['stream']:
current['independent_event'] = True
current['id'] = item['stream_id']
info = self.services.webstream_url(id=item['stream_id'])
current['uri'] = info['url']
current['type'] = 'stream_buffer_start'
# Stream events are instantaneous
current['end'] = current['start']
result[f'{key}_0'] = {
'id': current['id'],
'type': 'stream_output_start',
'start': current['start'],
'end': current['start'],
'uri': current['uri'],
'row_id': current['row_id'],
'independent_event': current['independent_event'],
}
result[end.isoformat()] = {
'type': 'stream_buffer_end',
'start': current['end'],
'end': current['end'],
'uri': current['uri'],
'row_id': current['row_id'],
'independent_event': current['independent_event'],
}
result[f'{end.isoformat()}_0'] = {
'type': 'stream_output_end',
'start': current['end'],
'end': current['end'],
'uri': current['uri'],
'row_id': current['row_id'],
'independent_event': current['independent_event'],
}
return result

View File

@ -9,15 +9,16 @@ print(script_path)
os.chdir(script_path)
setup(name='api_clients',
version='1.0',
description='Airtime API Client',
url='http://github.com/sourcefabric/Airtime',
author='sourcefabric',
version='2.0.0',
description='LibreTime API Client',
url='http://github.com/LibreTime/Libretime',
author='LibreTime Contributors',
license='AGPLv3',
packages=['api_clients'],
scripts=[],
install_requires=[
'configobj'
'configobj',
'python-dateutil',
],
zip_safe=False,
data_files=[])

View File

@ -1,5 +1,5 @@
import unittest
from api_clients.api_client import ApcUrl, UrlBadParam, IncompleteUrl
from api_clients.utils import ApcUrl, UrlBadParam, IncompleteUrl
class TestApcUrl(unittest.TestCase):
def test_init(self):
@ -8,16 +8,16 @@ class TestApcUrl(unittest.TestCase):
self.assertEqual(u.base_url, url)
def test_params_1(self):
u = ApcUrl("/testing/%%key%%")
u = ApcUrl("/testing/{key}")
self.assertEqual(u.params(key='val').url(), '/testing/val')
def test_params_2(self):
u = ApcUrl('/testing/%%key%%/%%api%%/more_testing')
u = ApcUrl('/testing/{key}/{api}/more_testing')
full_url = u.params(key="AAA",api="BBB").url()
self.assertEqual(full_url, '/testing/AAA/BBB/more_testing')
def test_params_ex(self):
u = ApcUrl("/testing/%%key%%")
u = ApcUrl("/testing/{key}")
with self.assertRaises(UrlBadParam):
u.params(bad_key='testing')
@ -26,5 +26,5 @@ class TestApcUrl(unittest.TestCase):
self.assertEqual( ApcUrl(u).url(), u )
def test_url_ex(self):
u = ApcUrl('/%%one%%/%%two%%/three').params(two='testing')
u = ApcUrl('/{one}/{two}/three').params(two='testing')
with self.assertRaises(IncompleteUrl): u.url()

View File

@ -1,26 +1,41 @@
import unittest
import json
from mock import MagicMock, patch
from api_clients.api_client import ApcUrl, ApiRequest
from api_clients.utils import ApcUrl, ApiRequest
class ResponseInfo:
def get_content_type(self):
return 'application/json'
@property
def headers(self):
return {'content-type': 'application/json'}
def json(self):
return {'ok', 'ok'}
class TestApiRequest(unittest.TestCase):
def test_init(self):
u = ApiRequest('request_name', ApcUrl('/test/ing'))
self.assertEqual(u.name, "request_name")
def test_call(self):
ret = json.dumps( {'ok':'ok'} )
def test_call_json(self):
ret = {'ok':'ok'}
read = MagicMock()
read.read = MagicMock(return_value=ret)
read.info = MagicMock(return_value=ResponseInfo())
read.headers = {'content-type': 'application/json'}
read.json = MagicMock(return_value=ret)
u = 'http://localhost/testing'
with patch('urllib.request.urlopen') as mock_method:
with patch('requests.get') as mock_method:
mock_method.return_value = read
request = ApiRequest('mm', ApcUrl(u))()
self.assertEqual(request, json.loads(ret))
self.assertEqual(request, ret)
def test_call_html(self):
ret = '<html><head></head><body></body></html>'
read = MagicMock()
read.headers = {'content-type': 'application/html'}
read.text = MagicMock(return_value=ret)
u = 'http://localhost/testing'
with patch('requests.get') as mock_method:
mock_method.return_value = read
request = ApiRequest('mm', ApcUrl(u))()
self.assertEqual(request.text(), ret)
if __name__ == '__main__': unittest.main()

View File

@ -2,7 +2,8 @@ import unittest
import json
from mock import patch, MagicMock
from configobj import ConfigObj
from api_clients.api_client import RequestProvider, api_config
from api_clients.version1 import api_config
from api_clients.utils import RequestProvider
class TestRequestProvider(unittest.TestCase):
def setUp(self):
@ -18,13 +19,17 @@ class TestRequestProvider(unittest.TestCase):
self.assertTrue('general' in self.cfg)
def test_init(self):
rp = RequestProvider(self.cfg)
self.assertTrue( len( rp.available_requests() ) > 0 )
rp = RequestProvider(self.cfg, {})
self.assertEqual(len(rp.available_requests()), 0)
def test_contains(self):
rp = RequestProvider(self.cfg)
methods = ['upload_recorded', 'update_media_url', 'list_all_db_files']
methods = {
'upload_recorded': '/1/',
'update_media_url': '/2/',
'list_all_db_files': '/3/',
}
rp = RequestProvider(self.cfg, methods)
for meth in methods:
self.assertTrue( meth in rp.requests )
self.assertTrue(meth in rp.requests)
if __name__ == '__main__': unittest.main()

View File

@ -1,6 +1,7 @@
import unittest
import datetime
import configparser
from api_clients.api_client import get_protocol
import unittest
from api_clients import utils
def get_force_ssl(value, useConfigParser):
config = {}
@ -10,12 +11,23 @@ def get_force_ssl(value, useConfigParser):
'base_port': 80,
'force_ssl': value,
}
return get_protocol(config)
return utils.get_protocol(config)
class TestTime(unittest.TestCase):
def test_time_in_seconds(self):
time = datetime.time(hour=0, minute=3, second=34, microsecond=649600)
self.assertTrue(abs(utils.time_in_seconds(time) - 214.65) < 0.009)
def test_time_in_milliseconds(self):
time = datetime.time(hour=0, minute=0, second=0, microsecond=500000)
self.assertEqual(utils.time_in_milliseconds(time), 500)
class TestGetProtocol(unittest.TestCase):
def test_dict_config_empty_http(self):
config = {'general': {}}
protocol = get_protocol(config)
protocol = utils.get_protocol(config)
self.assertEqual(protocol, 'http')
def test_dict_config_http(self):
@ -24,7 +36,7 @@ class TestGetProtocol(unittest.TestCase):
'base_port': 80,
},
}
protocol = get_protocol(config)
protocol = utils.get_protocol(config)
self.assertEqual(protocol, 'http')
def test_dict_config_https(self):
@ -33,7 +45,7 @@ class TestGetProtocol(unittest.TestCase):
'base_port': 443,
},
}
protocol = get_protocol(config)
protocol = utils.get_protocol(config)
self.assertEqual(protocol, 'https')
def test_dict_config_force_https(self):
@ -47,7 +59,7 @@ class TestGetProtocol(unittest.TestCase):
def test_configparser_config_empty_http(self):
config = configparser.ConfigParser()
config['general'] = {}
protocol = get_protocol(config)
protocol = utils.get_protocol(config)
self.assertEqual(protocol, 'http')
def test_configparser_config_http(self):
@ -55,7 +67,7 @@ class TestGetProtocol(unittest.TestCase):
config['general'] = {
'base_port': 80,
}
protocol = get_protocol(config)
protocol = utils.get_protocol(config)
self.assertEqual(protocol, 'http')
def test_configparser_config_https(self):
@ -63,7 +75,7 @@ class TestGetProtocol(unittest.TestCase):
config['general'] = {
'base_port': 443,
}
protocol = get_protocol(config)
protocol = utils.get_protocol(config)
self.assertEqual(protocol, 'https')
def test_configparser_config_force_https(self):
@ -73,3 +85,5 @@ class TestGetProtocol(unittest.TestCase):
self.assertEqual(get_force_ssl(value, True), 'https')
for value in negative_values:
self.assertEqual(get_force_ssl(value, True), 'http')
if __name__ == '__main__': unittest.main()

View File

@ -28,7 +28,7 @@ from configobj import ConfigObj
# custom imports
#from util import *
from api_clients import *
from api_clients import version1 as api_client
LOG_LEVEL = logging.INFO
LOG_PATH = '/var/log/airtime/pypo/notify.log'

View File

@ -4,7 +4,7 @@ import os
import sys
import time
import traceback
from api_clients.api_client import AirtimeApiClient
from api_clients.version1 import AirtimeApiClient
def generate_liquidsoap_config(ss):
data = ss['msg']

View File

@ -1,5 +1,5 @@
from api_clients import *
from api_clients import version1 as api_client
import sys
api_clients = api_client.AirtimeApiClient()

View File

@ -12,7 +12,7 @@ import sys
import telnetlib
import time
from api_clients import api_client
from api_clients import version1 as api_client
from configobj import ConfigObj
from datetime import datetime
from optparse import OptionParser

View File

@ -7,7 +7,7 @@ import traceback
import logging
import time
from api_clients import api_client
from api_clients import version1 as api_client
class ListenerStat(Thread):

View File

@ -16,7 +16,8 @@ from queue import Empty
from threading import Thread, Timer
from subprocess import Popen, PIPE
from api_clients import api_client
from api_clients import version1 as v1_api_client
from api_clients import version2 as api_client
from .timeout import ls_timeout
@ -28,7 +29,7 @@ signal.signal(signal.SIGINT, keyboardInterruptHandler)
logging.captureWarnings(True)
POLL_INTERVAL = 480
POLL_INTERVAL = 400
class PypoFetch(Thread):
@ -38,6 +39,7 @@ class PypoFetch(Thread):
#Hacky...
PypoFetch.ref = self
self.v1_api_client = v1_api_client.AirtimeApiClient()
self.api_client = api_client.AirtimeApiClient()
self.fetch_queue = pypoFetch_q
self.push_queue = pypoPush_q
@ -150,7 +152,7 @@ class PypoFetch(Thread):
def set_bootstrap_variables(self):
self.logger.debug('Getting information needed on bootstrap from Airtime')
try:
info = self.api_client.get_bootstrap_info()
info = self.v1_api_client.get_bootstrap_info()
except Exception as e:
self.logger.exception('Unable to get bootstrap info.. Exiting pypo...')
@ -255,7 +257,7 @@ class PypoFetch(Thread):
stream_id = info[0]
status = info[1]
if(status == "true"):
self.api_client.notify_liquidsoap_status("OK", stream_id, str(fake_time))
self.v1_api_client.notify_liquidsoap_status("OK", stream_id, str(fake_time))
@ls_timeout
@ -343,7 +345,7 @@ class PypoFetch(Thread):
media_item = media[key]
if (media_item['type'] == 'file'):
fileExt = self.sanity_check_media_item(media_item)
dst = os.path.join(download_dir, "{}{}".format(media_item['id'], fileExt))
dst = os.path.join(download_dir, f'{media_item["id"]}{fileExt}')
media_item['dst'] = dst
media_item['file_ready'] = False
media_filtered[key] = media_item
@ -434,10 +436,14 @@ class PypoFetch(Thread):
self.logger.exception("Problem removing file '%s'" % f)
def manual_schedule_fetch(self):
success, self.schedule_data = self.api_client.get_schedule()
if success:
try:
self.schedule_data = self.api_client.get_schedule()
self.process_schedule(self.schedule_data)
return success
return True
except Exception as e:
self.logger.error('Unable to fetch schedule')
self.logger.exception(e)
return False
def persistent_manual_schedule_fetch(self, max_attempts=1):
success = False
@ -452,7 +458,7 @@ class PypoFetch(Thread):
# push metadata to TuneIn. We have to do this because TuneIn turns
# off metadata if it does not receive a request every 5 minutes.
def update_metadata_on_tunein(self):
self.api_client.update_metadata_on_tunein()
self.v1_api_client.update_metadata_on_tunein()
Timer(120, self.update_metadata_on_tunein).start()
def main(self):

View File

@ -16,6 +16,7 @@ import configparser
import json
import hashlib
from requests.exceptions import ConnectionError, HTTPError, Timeout
from api_clients import version2 as api_client
CONFIG_PATH = '/etc/airtime/airtime.conf'
@ -31,6 +32,7 @@ class PypoFile(Thread):
self.media = None
self.cache_dir = os.path.join(config["cache_dir"], "scheduler")
self._config = self.read_config_file(CONFIG_PATH)
self.api_client = api_client.AirtimeApiClient()
def copy_file(self, media_item):
"""
@ -44,6 +46,8 @@ class PypoFile(Thread):
dst_exists = True
try:
dst_size = os.path.getsize(dst)
if dst_size == 0:
dst_exists = False
except Exception as e:
dst_exists = False
@ -63,41 +67,16 @@ class PypoFile(Thread):
if do_copy:
self.logger.info("copying from %s to local cache %s" % (src, dst))
CONFIG_SECTION = 'general'
username = self._config[CONFIG_SECTION].get('api_key')
baseurl = self._config[CONFIG_SECTION].get('base_url')
port = self._config[CONFIG_SECTION].get('base_port', 80)
positive_values = ['Yes', 'yes', 'True', 'true', True]
force_ssl = self._config[CONFIG_SECTION].get('force_ssl', False)
if force_ssl in positive_values:
protocol = 'https'
self.logger.debug('protocol set to https from force_ssl configuration setting')
else:
try:
protocol = self._config[CONFIG_SECTION]['protocol']
self.logger.debug('protocol set to %s from configuration setting' % (protocol))
except (NoOptionError, KeyError) as e:
protocol = str(("http", "https")[int(port) == 443])
self.logger.debug('guessing protocol as %s from port configuration' % (protocol))
try:
host = [protocol, baseurl, port]
url = "%s://%s:%s/rest/media/%s/download" % (host[0],
host[1],
host[2],
media_item["id"])
with open(dst, "wb") as handle:
response = requests.get(url, auth=requests.auth.HTTPBasicAuth(username, ''), stream=True, verify=False)
self.logger.info(media_item)
response = self.api_client.services.file_download_url(id=media_item['id'])
if not response.ok:
self.logger.error(response)
raise Exception("%s - Error occurred downloading file" % response.status_code)
for chunk in response.iter_content(1024):
if not chunk:
break
for chunk in response.iter_content(chunk_size=1024):
handle.write(chunk)
#make file world readable and owner writable

View File

@ -20,7 +20,7 @@ from queue import Empty, Queue
from threading import Thread
from api_clients import api_client
from api_clients import version1 as api_client
from .timeout import ls_timeout
logging.captureWarnings(True)

View File

@ -21,14 +21,15 @@ from threading import Thread
import mutagen
from api_clients import api_client as apc
from api_clients import version1 as v1_api_client
from api_clients import version2 as api_client
def api_client(logger):
"""
api_client returns the correct instance of AirtimeApiClient. Although there is only one
instance to choose from at the moment.
"""
return apc.AirtimeApiClient(logger)
return v1_api_client.AirtimeApiClient(logger)
# loading config file
try: