Last sync with Google: {{ last_sync|default:'never / unknown' }}
14 |
15 | Only showing shows for calendar events in the last {{ SYNC_RANGE_MIN_DAYS }} days
16 | and next {{ SYNC_RANGE_MAX_DAYS }} days,
17 | i.e. from {{ sync_range_start|date:"SHORT_DATE_FORMAT" }} to {{ sync_range_end|date:"SHORT_DATE_FORMAT" }}
18 |
There are currently no users banned on the harbor.
29 | {% endif %}
30 | {% endblock %}
31 |
--------------------------------------------------------------------------------
/sftp/image/etc/sftp_home_readme.txt:
--------------------------------------------------------------------------------
1 | # Crazy Arms SFTP/SCP Uploads
2 |
3 | You can upload audio assets via SFTP or SCP to be imported into Crazy Arms into
4 | the following directories,
5 |
6 | Audio Assets:
7 | - /audio-assets/
8 | - / (the root directory)
9 |
10 | Rotator Assets:
11 | - /rotator-assets/
12 |
13 | Scheduled Broadcast Assets:
14 | - /scheduled-broadcast-assets/
15 |
16 | Notes:
17 | * After processing, assets will be imported and deleted.
18 |
19 | * If you don't permission to edit a type of asset above (or the feature is
20 | disabled), then the above directory won't show up.
21 | - If you don't have AutoDJ programming permission, you won't have permission
22 | to upload files into the the root directory.
23 |
24 | * If you've enabled the "put uploads into playlists by folder" feature in your
25 | user profile, any subfolder you put an audio asset in will be the name of the
26 | playlist that it's put in. If one doesn't exist with that name, it'll be
27 | created for you.
28 |
--------------------------------------------------------------------------------
/nginx/image/usr/share/nginx/html/test_sse.html:
--------------------------------------------------------------------------------
1 |
2 |
13 |
14 | Crazy Arms Server-Sent Event Testing
15 |
16 |
17 |
Crazy Arms Server-Sent Event Testing
18 |
Messages: 0 received
19 |
20 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/app/app/broadcast/tasks.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from huey.contrib import djhuey
4 |
5 | from services.liquidsoap import harbor
6 |
7 | from .models import Broadcast
8 |
9 | logger = logging.getLogger(f"crazyarms.{__name__}")
10 |
11 |
12 | @djhuey.db_task(priority=5, context=True, retries=10, retry_delay=2)
13 | def play_broadcast(broadcast, task=None):
14 | try:
15 | broadcast.refresh_from_db()
16 |
17 | uri = broadcast.asset.liquidsoap_uri()
18 | if uri:
19 | harbor.prerecord__push(uri)
20 | else:
21 | raise Exception("Broadcast asset doesn't have a URI. Not sending to liquidsoap.")
22 |
23 | Broadcast.objects.filter(id=broadcast.id).update(status=Broadcast.Status.PLAYED)
24 | logger.info(f"Sent broadcast asset to harbor: {broadcast.asset}")
25 | except Exception:
26 | if task is None or task.retries == 0:
27 | logger.exception(f"Failed to broadcast {broadcast}")
28 | Broadcast.objects.filter(id=broadcast.id).update(status=Broadcast.Status.FAILED)
29 | raise
30 |
--------------------------------------------------------------------------------
/app/app/crazyarms/templates/admin/base_site.html:
--------------------------------------------------------------------------------
1 | {% extends 'admin/base_site.html' %}
2 |
3 | {% load static %}
4 |
5 | {% block welcome-msg %}
6 | Welcome,
7 | {{ user.get_full_name }}.
8 | {% endblock %}
9 |
10 | {% block userlinks %}
11 | Back to Status Page /
12 | Help Docs /
13 | {{ block.super }}
14 | {% endblock %}
15 |
16 | {% block extrahead %}
17 |
18 |
19 | {{ block.super}}
20 |
23 | {% endblock %}
24 |
25 | {% block footer %}
26 |
36 | {% endblock %}
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020-2022, David Cooper
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app/app/crazyarms/settings_test.py:
--------------------------------------------------------------------------------
1 | # flake8: noqa
2 |
3 | import fakeredis
4 |
5 | from django_redis.pool import ConnectionFactory
6 |
7 | from .settings import *
8 |
9 |
10 | # We use django-redis's features like `cache.key(...)` and `cache.ttl(...)`
11 | # at times, so we need to stub it out with fakeredis. See,
12 | # https://github.com/jamesls/fakeredis/issues/234#issuecomment-465131855
13 | class FakeDjangoRedisConnectionFactory(ConnectionFactory):
14 | def get_connection(self, params):
15 | return self.redis_client_cls(**self.redis_client_cls_kwargs)
16 |
17 |
18 | DJANGO_REDIS_CONNECTION_FACTORY = "crazyarms.settings_test.FakeDjangoRedisConnectionFactory"
19 | CACHES = {
20 | "default": {
21 | "BACKEND": "django_redis.cache.RedisCache",
22 | "LOCATION": "redis://localhost",
23 | "OPTIONS": {
24 | "CLIENT_CLASS": "django_redis.client.DefaultClient",
25 | "REDIS_CLIENT_CLASS": "fakeredis.FakeStrictRedis",
26 | },
27 | }
28 | }
29 |
30 | DATABASES = {
31 | "default": {
32 | "ENGINE": "django.db.backends.sqlite3",
33 | "NAME": ":memory:",
34 | }
35 | }
36 |
37 |
38 | HUEY["immediate"] = True
39 | del HUEY["connection"]
40 |
--------------------------------------------------------------------------------
/sftp/image/usr/local/bin/auth.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | . /.env
4 |
5 | URL='http://app:8000/api/sftp-auth/'
6 | JSON_IN="$(jq -nc --arg u "$SFTPGO_AUTHD_USERNAME" --arg p "$SFTPGO_AUTHD_PASSWORD" --arg k "$SFTPGO_AUTHD_PUBLIC_KEY" '{"username": $u, "password": $p, "key": $k}')"
7 | JSON_OUT="$(curl -d "$JSON_IN" -H "X-Crazyarms-Secret-Key: $SECRET_KEY" "$URL")"
8 | STATUS="$(echo "$JSON_OUT" | jq -r .status)"
9 |
10 | if [ "$STATUS" = 1 ]; then
11 | HOME_DIR="/sftp_root/$(echo "$JSON_OUT" | jq -r .username)"
12 |
13 | # Create user's home directories with subdirectories based on permissions
14 | mkdir -p "$HOME_DIR"
15 | cp /etc/sftp_home_readme.txt "$HOME_DIR/README.txt"
16 |
17 | # These should match api/tasks.py:SFTP_PATH_ASSET_CLASSES
18 | for PERM_DIR in '/audio-assets/' '/scheduled-broadcast-assets/' '/rotator-assets/'; do
19 | if [ "$(echo "$JSON_OUT" | jq -r --arg p "$PERM_DIR" '(.permissions | keys)[] | select(. == $p)')" ]; then
20 | mkdir -p "${HOME_DIR}${PERM_DIR}"
21 | else
22 | rm -rf "${HOME_DIR}${PERM_DIR}"
23 | fi
24 | done
25 |
26 | echo "$JSON_OUT"
27 | exit 0
28 | else
29 | echo '{"status": 0}'
30 | exit 1
31 | fi
32 |
--------------------------------------------------------------------------------
/app/app/gcal/tasks.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from huey import crontab
4 |
5 | from django.core.cache import cache
6 | from django.utils import timezone
7 |
8 | from constance import config
9 | from huey.contrib import djhuey
10 |
11 | from common.tasks import once_at_startup
12 | from crazyarms import constants
13 |
14 | from .models import GCalShow
15 |
16 | logger = logging.getLogger(f"crazyarms.{__name__}")
17 |
18 |
19 | @djhuey.db_periodic_task(priority=2, validate_datetime=once_at_startup(crontab(minute="*/5")))
20 | @djhuey.lock_task("sync-google-calendar-api-lock")
21 | def sync_gcal_api():
22 | if config.GOOGLE_CALENDAR_ENABLED:
23 | logger.info("Synchronizing with Google Calendar API")
24 | try:
25 | GCalShow.sync_api()
26 | except Exception:
27 | cache.set(
28 | constants.CACHE_KEY_GCAL_LAST_SYNC,
29 | "Failed, please check your settings and try again.",
30 | timeout=None,
31 | )
32 | raise
33 | else:
34 | cache.set(constants.CACHE_KEY_GCAL_LAST_SYNC, timezone.now(), timeout=None)
35 | else:
36 | logger.info("Synchronization with Google Calendar API disabled by config")
37 |
--------------------------------------------------------------------------------
/rtmp/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM nginx:alpine AS builder
2 |
3 | # TODO: Pin version of nginx-rtmp
4 |
5 | # Adapted from https://gist.github.com/hermanbanken/96f0ff298c162a522ddbba44cad31081#gistcomment-3555604
6 | RUN apk add --no-cache \
7 | curl \
8 | gcc \
9 | gd-dev \
10 | geoip-dev \
11 | gnupg \
12 | libc-dev \
13 | libxslt-dev \
14 | linux-headers \
15 | make \
16 | openssl-dev \
17 | pcre-dev \
18 | zlib-dev
19 |
20 | WORKDIR /tmp
21 |
22 | RUN curl -sL "http://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz" | tar xzf - \
23 | && curl -sL "https://github.com/arut/nginx-rtmp-module/archive/master.tar.gz" | tar xzf -
24 |
25 | RUN CONFARGS=$(nginx -V 2>&1 | sed -n -e 's/^.*arguments: //p') \
26 | CONFARGS=${CONFARGS/-Os -fomit-frame-pointer -g/-Os} \
27 | && cd "nginx-${NGINX_VERSION}" \
28 | && ./configure --with-compat $CONFARGS "--add-dynamic-module=../nginx-rtmp-module-master" \
29 | && make && make install
30 |
31 |
32 | FROM nginx:alpine
33 |
34 | COPY --from=builder /usr/lib/nginx/modules/ngx_rtmp_module.so /usr/local/nginx/modules/ngx_rtmp_module.so
35 |
36 | RUN apk add --no-cache ffmpeg
37 |
38 | COPY image/ /
39 |
--------------------------------------------------------------------------------
/liquidsoap/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM ubuntu:20.04
2 |
3 | ENV LIQUIDSOAP_VERSION "1.4.4"
4 |
5 | ARG DEBIAN_FRONTEND=noninteractive
6 | RUN apt-get update \
7 | && apt-get install -y --no-install-recommends \
8 | ca-certificates \
9 | festival \
10 | festvox-kallpc16k \
11 | ffmpeg \
12 | jq \
13 | libsox-fmt-all \
14 | pulseaudio \
15 | redis-tools \
16 | sox \
17 | supervisor \
18 | wget \
19 | && rm -rf /var/lib/apt/lists/*
20 |
21 | RUN ARCH="$(dpkg --print-architecture)" \
22 | && wget -qO /tmp/liquidsoap.deb "https://github.com/savonet/liquidsoap/releases/download/v${LIQUIDSOAP_VERSION}/liquidsoap-v${LIQUIDSOAP_VERSION}_${LIQUIDSOAP_VERSION}-ubuntu-focal-${ARCH}-1_${ARCH}.deb" \
23 | && apt-get update \
24 | && apt-get install -y --no-install-recommends \
25 | /tmp/liquidsoap.deb \
26 | && rm -rf /var/lib/apt/lists/* /tmp/*.deb
27 |
28 | RUN wget -qO /usr/local/bin/wait-for-it https://raw.githubusercontent.com/vishnubob/wait-for-it/81b1373f/wait-for-it.sh \
29 | && chmod +x /usr/local/bin/wait-for-it
30 |
31 | RUN rmdir /etc/supervisor/conf.d
32 |
33 | COPY image/ /
34 |
35 | ENTRYPOINT ["/entrypoint.sh"]
36 | CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
37 |
--------------------------------------------------------------------------------
/nginx/image/docker-entrypoint.d/30-jinja2-on-templates.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -e
4 |
5 | ME=$(basename $0)
6 |
7 | # Adapted from 20-envsubst-on-templates.sh
8 | auto_j2() {
9 | local template_dir="${NGINX_J2_TEMPLATE_DIR:-/etc/nginx/templates}"
10 | local suffix="${NGINX_J2_TEMPLATE_SUFFIX:-.j2}"
11 | local output_dir="${NGINX_J2_OUTPUT_DIR:-/etc/nginx/conf.d}"
12 | local env_file="${NGINX_J2_ENV_FILE:-/.env}"
13 |
14 | local template relative_path output_path subdir
15 | [ -d "$template_dir" ] || return 0
16 | if [ ! -w "$output_dir" ]; then
17 | echo >&3 "$ME: ERROR: $template_dir exists, but $output_dir is not writable"
18 | return 0
19 | fi
20 | find "$template_dir" -follow -type f -name "*$suffix" -print | while read -r template; do
21 | relative_path="${template#$template_dir/}"
22 | output_path="$output_dir/${relative_path%$suffix}"
23 | subdir=$(dirname "$relative_path")
24 | mkdir -p "$output_dir/$subdir"
25 | echo >&3 "$ME: Running j2 on $template to $output_path"
26 | export SSL_OPTIONS_PATH="$(python3 -c 'import site; print(site.getsitepackages()[0])')/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf"
27 | j2 --format=env "$template" "$env_file" > "$output_path"
28 | done
29 | }
30 |
31 | auto_j2
32 |
33 | exit 0
34 |
--------------------------------------------------------------------------------
/app/app/gcal/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2b1 on 2021-03-22 16:59
2 |
3 | import common.models
4 | from django.conf import settings
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | initial = True
11 |
12 | dependencies = [
13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14 | ]
15 |
16 | operations = [
17 | migrations.CreateModel(
18 | name='GCalShow',
19 | fields=[
20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21 | ('gcal_id', common.models.TruncatingCharField(max_length=256, unique=True)),
22 | ('title', common.models.TruncatingCharField(max_length=1024, verbose_name='title')),
23 | ('start', models.DateTimeField(verbose_name='start time')),
24 | ('end', models.DateTimeField(verbose_name='end time')),
25 | ('users', models.ManyToManyField(related_name='gcal_shows', to=settings.AUTH_USER_MODEL, verbose_name='users')),
26 | ],
27 | options={
28 | 'verbose_name': 'Google Calendar show',
29 | 'verbose_name_plural': 'Google Calendar shows',
30 | 'ordering': ('start', 'id'),
31 | },
32 | ),
33 | ]
34 |
--------------------------------------------------------------------------------
/docs/glossary.md:
--------------------------------------------------------------------------------
1 | # Glossary of Terms
2 |
3 | ## AutoDJ related
4 |
5 | Audio Asset
6 | : Audio files, music or other short programming for regular playout. An
7 | example would be the track _Ray Price - Crazy Arms._
8 |
9 | Playlist
10 | : A collection or group of audio assets for regular playout. An example would
11 | be a playlist called _"Ray Price - Country Classics"_ containing several
12 | songs (audio assets).
13 |
14 | Rotator
15 | : A collection of stations IDs, advertisements, PSAs, etc of the same category.
16 | Examples would be a rotator named _"Advertisements"_ or _"Station IDs."_
17 |
18 | Rotator Asset
19 | : A single short audio asset inside of a rotator, ie an individual station ID,
20 | advertisement, PSA, etc. Examples would be short clips called
21 | _"David's Steel Guitar Ad, 30 seconds."_ (in the _"Advertisements"_
22 | rotator) or _"Evening Station ID #1"_ (in the _"Station IDs"_ rotator).
23 |
24 | Stop Set
25 | : A _block_ of stations IDs, PSAs, etc which in turn is really just a block
26 | one or more rotators. An example would be a stop set called _"Stop Set #1"_
27 | containing three rotators as entries: (1) _"Station IDs,"_ (2) _"Advertisements,"_
28 | and (3) _"Station IDs."_ **NOTE:** duplicate rotators _is_ allowed, as in this
29 | example.
30 |
31 | Weight
32 | : Random weight. TODO document better.
33 |
--------------------------------------------------------------------------------
/app/app/autodj/migrations/0002_auto_20210611_1445.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2rc1 on 2021-06-11 21:45
2 |
3 | import django.core.validators
4 | from django.db import migrations, models
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = [
10 | ('autodj', '0001_initial'),
11 | ]
12 |
13 | operations = [
14 | migrations.AlterField(
15 | model_name='playlist',
16 | name='weight',
17 | field=models.FloatField(default=1.0, help_text="The weight (ie selection bias) for how likely random selection from this playlist/stopset occurs, eg '1.0' is just as likely as all others, '2.0' is 2x as likely, '3.0' is 3x as likely, '0.5' half as likely, and so on. If unsure, leave as '1.0'.", validators=[django.core.validators.MinValueValidator(1e-05)], verbose_name='random weight'),
18 | ),
19 | migrations.AlterField(
20 | model_name='stopset',
21 | name='weight',
22 | field=models.FloatField(default=1.0, help_text="The weight (ie selection bias) for how likely random selection from this playlist/stopset occurs, eg '1.0' is just as likely as all others, '2.0' is 2x as likely, '3.0' is 3x as likely, '0.5' half as likely, and so on. If unsure, leave as '1.0'.", validators=[django.core.validators.MinValueValidator(1e-05)], verbose_name='random weight'),
23 | ),
24 | ]
25 |
--------------------------------------------------------------------------------
/nginx/image/usr/local/sbin/certbot-daemon.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | check_self_signed() {
4 | openssl x509 -noout -subject -nameopt multiline -in "$CERT_PATH/fullchain.pem" \
5 | | grep -q 'organizationName *= *Crazy Arms Self-Signed'
6 | }
7 |
8 | log() {
9 | echo "$(date) - $1"
10 | }
11 |
12 | run_certbot() {
13 | . /.env
14 |
15 | certbot_args=
16 | if [ "$HTTPS_CERTBOT_STAGING_CERT" -a "$HTTPS_CERTBOT_STAGING_CERT" != '0' ]; then
17 | log 'Using staging certificate (for testing)'
18 | certbot_args='--server https://acme-staging-v02.api.letsencrypt.org/directory'
19 | fi
20 |
21 | # undocumented, for testing
22 | if [ "$HTTPS_CERTBOT_FORCE_RENEW" -a "$HTTPS_CERTBOT_FORCE_RENEW" != '0' ]; then
23 | log "Forcing certificate renwal"
24 | certbot_args="$certbot_args --force-renewal"
25 | fi
26 |
27 | certbot certonly --agree-tos --keep -n --text --email "$HTTPS_CERTBOT_EMAIL" \
28 | -d "$DOMAIN_NAME" --http-01-port 8080 --standalone --preferred-challenges http-01 \
29 | --deploy-hook 'nginx -s reload' $certbot_args
30 | }
31 |
32 | wait-for localhost:80
33 | . /.env
34 |
35 | CERT_PATH="/etc/letsencrypt/live/${DOMAIN_NAME}"
36 | if check_self_signed; then
37 | log 'Found self-signed certificate, removing'
38 | rm -r "$CERT_PATH"
39 | fi
40 |
41 | while true; do
42 | log 'Running cerbot'
43 | run_certbot
44 | log 'Sleeping for 7 days'
45 | sleep 604800 # 7 days
46 | done
47 |
--------------------------------------------------------------------------------
/app/app/services/templates/admin/services/harbor_custom_config.html:
--------------------------------------------------------------------------------
1 | {% extends 'admin/base_site_extra.html' %}
2 |
3 | {% load static %}
4 |
5 | {% block extrastyle %}
6 | {{ block.super }}
7 |
8 |
9 | {{ media.css }}
10 |
19 | {% endblock %}
20 |
21 | {% block content %}
22 |
23 |
24 | Please make any additions to the Liquidsoap harbor source code below. After
25 | you are done the harbor will be restarted. (Read more about Liquidsoap
26 | here.)
27 |
28 |
29 | WARNING: This is for advanced use cases ONLY. A mistake
30 | while configuring the harbor could take down the whole station.
31 |
32 |
33 |
34 |
46 |
47 |
48 | {% endblock %}
49 |
--------------------------------------------------------------------------------
/zoom/Dockerfile:
--------------------------------------------------------------------------------
1 | # Seems like Zoom works properly under QEMU for testing/development with `liunux/amd64`
2 | # emulation on on Apple M1 (linux/arm64/v8). Chrome doesn't, but that's fine.
3 | FROM --platform=linux/amd64 ubuntu:20.04
4 |
5 | ENV DISPLAY=:0
6 |
7 | ARG DEBIAN_FRONTEND=noninteractive
8 | RUN apt-get update \
9 | && apt-get install -y --no-install-recommends \
10 | ca-certificates \
11 | gnupg \
12 | icewm \
13 | libxkbcommon-x11-0 \
14 | netcat \
15 | psmisc \
16 | pulseaudio \
17 | redis-tools \
18 | sudo \
19 | supervisor \
20 | websockify \
21 | wget \
22 | xdotool \
23 | x11vnc \
24 | xvfb \
25 | && rm -rf /var/lib/apt/lists/*
26 |
27 | RUN wget -qO /tmp/zoom.deb https://zoom.us/client/latest/zoom_amd64.deb \
28 | && wget -qO - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
29 | && echo 'deb http://dl.google.com/linux/chrome/deb/ stable main' > /etc/apt/sources.list.d/google-chrome.list \
30 | && apt-get update \
31 | && apt-get install -y --no-install-recommends \
32 | /tmp/zoom.deb \
33 | google-chrome-stable \
34 | && rm -rf /var/lib/apt/lists/* /tmp/*.deb \
35 | && adduser --gecos '' --disabled-password user
36 |
37 | RUN rmdir /etc/supervisor/conf.d && ln -s /config/zoom/supervisor /etc/supervisor/conf.d
38 |
39 | COPY image/ /
40 |
41 | ENTRYPOINT ["/entrypoint.sh"]
42 | CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
43 |
--------------------------------------------------------------------------------
/app/image/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | if [ "$RUN_HUEY" ]; then
4 | __RUN_HUEY=1
5 | fi
6 |
7 | if [ -f /.env ]; then
8 | source /.env
9 | fi
10 |
11 | if [ "$#" = 0 ]; then
12 | if [ "${__RUN_HUEY}" ]; then
13 | if [ -z "$HUEY_WORKERS" ]; then
14 | HUEY_WORKERS="$(python -c 'import multiprocessing as m; print(max(m.cpu_count() * 3, 6))')"
15 | fi
16 | CMD="./manage.py run_huey --workers $HUEY_WORKERS --flush-locks"
17 | if [ "$DEBUG" -a "$DEBUG" != '0' ]; then
18 | exec watchmedo auto-restart --directory=./ --pattern=*.py --recursive -- $CMD
19 | else
20 | exec $CMD
21 | fi
22 | else
23 | echo "Starting up Crazy Arms Radio Backend version $CRAZYARMS_VERSION"
24 |
25 | wait-for-it -t 0 db:5432
26 |
27 | # TODO: detect version change and clear redis
28 |
29 | ./manage.py migrate
30 | ./manage.py init_services
31 |
32 | if [ "$DEBUG" -a "$DEBUG" != '0' ]; then
33 | exec ./manage.py runserver
34 | else
35 | ./manage.py collectstatic --noinput
36 |
37 | if [ -z "$GUNICORN_WORKERS" ]; then
38 | GUNICORN_WORKERS="$(python -c 'import multiprocessing as m; print(max(round(m.cpu_count() * 1.5 + 1), 3))')"
39 | fi
40 |
41 | exec gunicorn $GUNICORN_ARGS --forwarded-allow-ips '*' -b 0.0.0.0:8000 -w $GUNICORN_WORKERS --capture-output --access-logfile - crazyarms.wsgi
42 | fi
43 | fi
44 | else
45 | exec "$@"
46 | fi
47 |
--------------------------------------------------------------------------------
/docs/server-setup.md:
--------------------------------------------------------------------------------
1 | # Server Setup
2 |
3 | This guide is intended for systems administrators only.
4 |
5 | ## Installation
6 |
7 | ### Quickstart
8 |
9 | !!! note "Prerequisites"
10 | * Operating System: Linux or macOS
11 | * [Docker](https://www.docker.com/) **and**
12 | [docker-compose](https://docs.docker.com/compose/) installed.
13 |
14 | In your terminal clone the repo and start the code. Building the containers may
15 | take several minutes.
16 |
17 | ```bash
18 | git clone git@github.com:dtcooper/crazyarms.git
19 | cd crazyarms
20 |
21 | ./compose pull # or to build from source: ./compose.sh build
22 | ./compose.sh up
23 | ```
24 |
25 | You'll be asked a few questions about setting up Crazy Arms in your terminal.
26 | When building is done, then in your browser go to
27 | [`http://localhost/`](http://localhost/). You'll be prompted in the application
28 | about setting up your station.
29 |
30 | If you want a fairly preview of what the AutoDJ has to offer, be sure to select
31 | _"Preload the AutoDJ"_ when setting up your station.
32 |
33 | To stop, in your terminal press ++ctrl+"C"++.
34 |
35 | To run Crazy Arms in the background which is useful for deployment do the
36 | following,
37 |
38 | ```bash
39 | ./compose.sh up -d
40 | ./compose.sh down
41 | ```
42 |
43 | ### Unit Tests
44 |
45 | ```
46 | ./compose.sh test
47 |
48 | # Tear down test dependent containers (postgres and redis)
49 | ./compose --test down
50 | ```
51 |
52 | ## Upgrading
53 |
54 | ## Development
55 |
56 | ### Helpful Practices
57 |
58 | * In `.env` set `DEBUG = True`
59 | * `overrides.yml`
60 | * `./compose.sh` development commands
61 |
62 | ## TODOs
63 |
64 | * Setting up email, mention `./manage.py sendtestemail user@example.com`
65 |
--------------------------------------------------------------------------------
/app/app/services/management/commands/init_services.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 |
3 | from common.models import User
4 | from services import init_services
5 |
6 |
7 | class Command(BaseCommand):
8 | help = "Initialize Crazy Arms Services"
9 |
10 | def add_arguments(self, parser):
11 | parser.add_argument(
12 | "services",
13 | nargs="*",
14 | type=str,
15 | help="list of services (default: all)",
16 | default=None,
17 | )
18 | parser.add_argument(
19 | "-r",
20 | "--restart",
21 | action="store_true",
22 | help="force a restart of the services",
23 | )
24 | parser.add_argument(
25 | "-f",
26 | "--force",
27 | action="store_true",
28 | help="always run (even if no users exist)",
29 | )
30 | parser.add_argument(
31 | "--render-only",
32 | action="store_true",
33 | help="render config files only, don't (re-start services)",
34 | )
35 |
36 | def handle(self, *args, **options):
37 | if User.objects.exists() or options["force"]:
38 | if options["services"]:
39 | self.stdout.write(f'Initializing services ({", ".join(options["services"])})')
40 | else:
41 | self.stdout.write("Initializing services (all)")
42 |
43 | init_services(
44 | services=options["services"],
45 | restart_services=options["restart"],
46 | render_only=options["render_only"],
47 | )
48 | else:
49 | self.stdout.write("No users exist, assuming this is the first run and not starting services.")
50 |
--------------------------------------------------------------------------------
/app/app/webui/templates/webui/profile.html:
--------------------------------------------------------------------------------
1 | {% extends 'webui/form.html' %}
2 |
3 | {% block content %}
4 |
Update your user profile below. Click here to change your password.
5 | {{ block.super }}
6 | {% endblock %}
7 |
8 | {% block form_table %}
9 | {# hack from https://stackoverflow.com/a/9491141 to make sure pressing enter submits (not generate stream key) #}
10 |
11 |
12 | {% if user.is_superuser or groups %}
13 |
14 |
Permissions:
15 |
16 | {% if user.is_superuser %}
17 | All permissions. You're an administrator.
18 | (Regular users can only edit parts of this form like timezone, first name, last name, default playlist, and SSH
19 | {% if settings.RTMP_ENABLED %}and RTMP{% endif %} keys. They can update their email only by verification.)
20 | {% else %}
21 |
22 | {% for group in groups %}
23 |
{{ group }}{% if forloop.last %}.{% else %};{% if forloop.revcounter == 2 %} and {% endif %}{% endif %}
43 | {% endif %}
44 | {% endblock %}
45 |
--------------------------------------------------------------------------------
/docs/users-guide/dj/zoom.md:
--------------------------------------------------------------------------------
1 | # Zoom
2 |
3 | Here are the instructions to start [Zoom](https://zoom.us/) broadcasting via a room.
4 |
5 | ## Prerequisites
6 |
7 | 1. The systems administrator must have enabled Zoom (In the `.env` file, set
8 | `ZOOM_ENABLED=1`).
9 | 2. You must have Zoom installed ().
10 |
11 | !!! warning "Zoom Time Limits"
12 | If you're _not_ using a paid Zoom account, or your systems administrator has
13 | not set up a paid account, there are room time limits for group meetings
14 | held in Zoom rooms. (40 minutes at the time of this writing.) Your show will
15 | be capped at that length.
16 |
17 |
18 | ## Walkthrough
19 |
20 | 1. Create and start a Zoom room.
21 | 2. Click on the _Meeting Information_ icon, shown below.
22 |
23 | 
24 |
25 | 1. In the Meeting Information pop-up, copy the link to the Zoom by clicking
26 | _Copy Link_, shown below.
27 |
28 | 
29 |
30 | 1. Paste the link into the _Room Link_ input, choose a show length, and
31 | click _Start Zoom Broadcast Now._
32 | 1. As soon as the **Broadcast Bot** has entered your Zoom room, your show
33 | starts, however you must produce noise for the stream to transition
34 | to your show. This way, you can set up your show a little bit early. Just
35 | be quiet until you're ready!
36 |
37 | !!! danger "Problems with Zoom?"
38 | If a **Broadcast Bot** attendee does not enter your Zoom room roughly
39 | 15 seconds after you click _Start Zoom Broadcast Now_, please contact
40 | the systems administrator or
41 | [report a bug with Crazy Arms](https://github.com/dtcooper/crazyarms/issues).
42 |
43 | TODO: Share your desktop music
--------------------------------------------------------------------------------
/app/app/webui/static/webui/js/base.js:
--------------------------------------------------------------------------------
1 | function updateMessageContainer() {
2 | if ($('.message-list li').length > 0) {
3 | $('.message-container').show()
4 | } else {
5 | $('.message-container').hide()
6 | }
7 | }
8 |
9 | function addMessage(level, message) {
10 | var $message = $('')
11 | $message.text(message)
12 | $('.message-list').append('
71 | {% endblock %}
72 |
--------------------------------------------------------------------------------
/docs/admin-guide/configuration.md:
--------------------------------------------------------------------------------
1 | # Configuration
2 |
3 | There are three major ways to configure Crazy Arms,
4 |
5 | 1. [Station Settings in the station admin site](#station-settings-dynamic-settings)
6 | 2. [The startup environment file (`.env`)](#the-environment-file-env-static-settings)
7 | 3. [The Harbor source code, using Liquidsoap](#liquidsoap-source-advanced)
8 |
9 | ## Station Settings (Dynamic Settings)
10 |
11 | In the station admin site, the following configuration options are available, and are
12 | _dynamically configured,_ which is to say they can be changed at any time without
13 | having to restart (and rebuild) the Crazy Arms server. Anyone with administrator
14 | privileges can edit these in the web app.
15 |
16 | !!! warning "Some Settings May Not Be Available"
17 | If Zoom or local Icecast server is disabled in the `.env` file, those
18 | sections will not be available. See
19 | [more information on the `.env` file below](#the-environment-file-env-static-settings).
20 |
21 | Below are a list of configuration options, organized by section.
22 |
23 | {% for section, config_names in DJANGO_SETTINGS.CONSTANCE_CONFIG_FIELDSETS.items() %}
24 | ### {{ section }}
25 |
26 | !!! info ""
27 | {% for name in config_names %}
28 | {% with config=DJANGO_SETTINGS.CONSTANCE_CONFIG[name] %}
29 | {% with default=config[0], description=config[1], type=config[2] %}
30 | `{{ name }}` --- **Type: {{ get_constance_config_type(default, type) }}**
31 | : {{ description }}
32 |
33 | Default: `{{ get_constance_config_default(name, default) }}`
34 | {% endwith %}
35 | {% endwith %}
36 | {% endfor %}
37 | {% endfor %}
38 |
39 | ## The Environment File -- `.env` (Static Settings)
40 |
41 | When Crazy Arms starts for the first time with `./compose.sh`, you'll get asked
42 | a series of simple questions. In your root directory, all that's happening is
43 | the file `.default.env` is being modified and copied to `.env`.
44 |
45 | !!! danger "Advanced Users Only"
46 | Editing the `.env` file is meant to be done once at installation (or rarely),
47 | and is intended for _systems administrators_ only, ie the nerds who set up
48 | Crazy Arms for your station. If your just a regular admin user, you'll
49 | probably be more interested in the [station settings](#station-settings-dynamic-settings)
50 | above.
51 |
52 | !!! warning "Which Services Start"
53 | If you edit `ZOOM_ENABLED`, `ICECAST_ENABLED`, `EMAIL_ENABLED`,
54 | `HARBOR_TELNET_WEB_ENABLED`, `RTMP_ENABLED` you actually are controlling
55 | which services (or Docker containers) Crazy Arms starts up. This is the
56 | reason why these settings are _static,_ since the list of containers
57 | we choose to run is determined at start time.
58 |
59 | Below is a copy of the `.default.env` file that shipped with Crazy Arms which
60 | does a decent enough job of explaining what each option does.
61 |
62 | ```
63 | {{ DEFAULT_ENV }}
64 | ```
65 |
66 |
67 | ## Liquidsoap Source (Advanced)
68 |
69 |
70 | TODO
71 |
72 | * [Liquidsoap](https://www.liquidsoap.info/)
73 | * It's complicated but lots of fun to use.
74 |
--------------------------------------------------------------------------------
/app/app/services/templates/services/upstream.liq:
--------------------------------------------------------------------------------
1 | {% load services %}
2 |
3 | name = {{ upstream.name|liqval }}
4 | SCRIPT_NAME = 'Upstream "#{name}"'
5 | HEALTHCHECK_PORT = {{ upstream.healthcheck_port|liqval }}
6 |
7 | %include "library.liq"
8 |
9 | set('server.telnet', true)
10 | set('server.telnet.port', {{ upstream.telnet_port|liqval }})
11 |
12 | url = 'http://harbor:4000/live'
13 |
14 | input = input.http(
15 | id='input',
16 | buffer=BUFFER,
17 | max=MAX,
18 | poll_delay=0.5,
19 | on_connect=fun(_) -> log_event('#{SCRIPT_NAME} connected to harbor'),
20 | on_disconnect=fun() -> log_event('#{SCRIPT_NAME} disconnected from harbor'),
21 | url,
22 | )
23 | output.dummy(input, fallible=true)
24 |
25 | failsafe = single(id='failsafe',
26 | {% if config.UPSTREAM_FAILSAFE_AUDIO_FILE %}
27 | {{ settings.MEDIA_ROOT|add:'/'|add:config.UPSTREAM_FAILSAFE_AUDIO_FILE|liqval }}
28 | {% else %}
29 | '/assets/failsafe.mp3'
30 | {% endif %}
31 | )
32 | broadcast = fallback(track_sensitive=false, [input, failsafe])
33 |
34 | host = {{ upstream.hostname|liqval }}
35 | user = {{ upstream.username|liqval }}
36 | password = {{ upstream.password|liqval }}
37 | mount = {{ '/'|add:upstream.mount|liqval }}
38 | {% if upstream.mime %}
39 | format = {{ upstream.mime|liqval }}
40 | {% endif %}
41 | log('Starting upstream "#{name}"')
42 |
43 | connection_str = {{ upstream|liqval }}
44 |
45 | UNKNOWN_ERROR_STR = 'Unknown error occurred (see upstream logs)'
46 | connected = ref false
47 | error = ref UNKNOWN_ERROR_STR
48 | start_time = ref -1.
49 |
50 | output.icecast(
51 | %{{ upstream.encoding }}(
52 | {% if upstream.bitrate %}
53 | bitrate={{ upstream.bitrate }}{% if upstream.encoding_args %},{% endif %}
54 | {% endif %}
55 | {% if upstream.encoding_args %}
56 | {% for arg, value in upstream.encoding_args.items %}
57 | {{ arg }}={{ value|liqval:False }}{% if not forloop.last %},{% endif %}
58 | {% endfor %}
59 | {% endif %}
60 | ),
61 | id='broadcast',
62 | icy_metadata='true',
63 | protocol='http{% if upstream.protocol == 'https' %}s{% endif %}',
64 | {% if upstream.mime %}
65 | format=format,
66 | {% endif %}
67 | host=host,
68 | port={{ upstream.port|liqval }},
69 | user=user,
70 | password=password,
71 | mount=mount,
72 | broadcast,
73 | on_connect=fun() -> begin
74 | connected := true
75 | log_event('#{SCRIPT_NAME} successfully connected to #{connection_str}')
76 | start_time := time()
77 | error := UNKNOWN_ERROR_STR
78 | end,
79 | on_disconnect=fun() -> begin
80 | connected := false
81 | log_event('#{SCRIPT_NAME} disconnected from #{connection_str}')
82 | error := UNKNOWN_ERROR_STR
83 | end,
84 | on_error=fun(e) -> begin
85 | if !error != e then
86 | log_event('#{SCRIPT_NAME} connection error to #{connection_str}: #{e}')
87 | end
88 | error := e
89 | 1. # Re-try connection every second
90 | end,
91 | )
92 |
93 | server.register(usage='status', description='Status of upstream connection', 'status', fun(_) -> begin
94 | error = if !error == "" then "null" else json_of(!error) end
95 | start_time = if !connected then json_of(!start_time) else 'null' end
96 | '{"online":#{json_of(!connected)},"error":#{error},"start_time":#{start_time}}'
97 | end)
98 |
--------------------------------------------------------------------------------
/app/app/common/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig, apps
2 | from django.db.models.signals import post_migrate
3 |
4 |
5 | def create_user_perm_group(codename, description):
6 | from django.contrib.auth.models import Group, Permission
7 | from django.contrib.contenttypes.models import ContentType
8 |
9 | from .models import User
10 |
11 | user = ContentType.objects.get_for_model(User)
12 | group, _ = Group.objects.get_or_create(name=description)
13 | group.permissions.add(
14 | Permission.objects.get_or_create(content_type=user, codename=codename, defaults={"name": description})[0]
15 | )
16 | return group
17 |
18 |
19 | def create_perm_group_for_models(models, description):
20 | from django.contrib.auth.models import Group, Permission
21 | from django.contrib.contenttypes.models import ContentType
22 |
23 | if isinstance(models, str):
24 | models = [m for m in apps.get_models() if m._meta.app_label == models]
25 | elif not isinstance(models, (tuple, list)):
26 | models = (models,)
27 |
28 | content_types = [ContentType.objects.get_for_model(model) for model in models]
29 | group, _ = Group.objects.get_or_create(name=description)
30 | group.permissions.add(*Permission.objects.filter(content_type__in=content_types))
31 | return group
32 |
33 |
34 | def create_groups(sender, **kwargs):
35 | from django.contrib.auth.models import Group, Permission
36 |
37 | from services.models import PlayoutLogEntry
38 |
39 | # Consult common/admin.py:UserAdmin.formfield_for_manytomany to remove these from displayed groups
40 | groups_created = [
41 | create_perm_group_for_models(PlayoutLogEntry, "Advanced view of the playout log in admin site"),
42 | create_perm_group_for_models("autodj", "Program the AutoDJ"),
43 | create_perm_group_for_models("broadcast", "Program and schedule pre-recorded broadcasts"),
44 | create_user_perm_group("view_telnet", "Access Liquidsoap harbor over telnet (experimental)"),
45 | create_user_perm_group("view_websockify", "Can configure and administrate Zoom over VNC"),
46 | create_user_perm_group("view_logs", "Can view server logs"),
47 | create_user_perm_group("change_liquidsoap", "Edit Liquidsoap harbor source code"),
48 | create_user_perm_group("can_boot", "Can kick DJs off of harbor"),
49 | ]
50 |
51 | # Constance is a weird one, no actual model exists
52 | constance = apps.get_app_config("constance")
53 | constance.create_perm()
54 | group, _ = Group.objects.get_or_create(name="Modify settings and configuration")
55 | group.permissions.add(Permission.objects.get(codename="change_config"))
56 | groups_created.append(group)
57 |
58 | # Remove groups not created/updated here
59 | Group.objects.exclude(id__in=[g.id for g in groups_created]).delete()
60 |
61 |
62 | class CommonConfig(AppConfig):
63 | name = "common"
64 | verbose_name = "Authentication and Authorization"
65 |
66 | def ready(self):
67 | from constance.admin import Config
68 | from constance.apps import ConstanceConfig
69 |
70 | ConstanceConfig.verbose_name = "Station Settings"
71 | Config._meta.verbose_name = "Configuration"
72 | Config._meta.verbose_name_plural = "Configuration"
73 |
74 | post_migrate.connect(create_groups, sender=self)
75 |
--------------------------------------------------------------------------------
/app/app/services/liquidsoap.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 | from telnetlib import Telnet
4 | from threading import Lock
5 |
6 | END_PREFIX = b"\r\nEND\r\n"
7 |
8 | logger = logging.getLogger(f"crazyarms.{__name__}")
9 |
10 |
11 | class LiquidsoapTelnetException(Exception):
12 | pass
13 |
14 |
15 | class _Liquidsoap:
16 | MAX_TRIES = 3
17 |
18 | def __init__(self, host="harbor", port=1234):
19 | self._telnet = None
20 | # Since huey runs as threaded, let's make sure only one thread actually talks to
21 | # the telnet connection at a given time.
22 | self._access_lock = Lock()
23 | self._version = None
24 | self.host = host
25 | self.port = port
26 |
27 | @property
28 | def version(self):
29 | if self._version is None:
30 | try:
31 | self._version = self.execute("version").removeprefix("Liquidsoap ")
32 | except LiquidsoapTelnetException:
33 | return "unknown"
34 | return self._version
35 |
36 | def execute(self, command, arg=None, splitlines=False, safe=False, as_dict=False):
37 | if arg is not None:
38 | command += f" {arg}"
39 | command = f"{command}\n".encode("utf-8")
40 |
41 | with self._access_lock:
42 | for try_number in range(self.MAX_TRIES): # Try three times before giving up
43 | try:
44 | if self._telnet is None:
45 | self._telnet = Telnet(host=self.host, port=self.port, timeout=5)
46 |
47 | self._telnet.write(command)
48 | response = self._telnet.read_until(END_PREFIX)
49 | if response.endswith(b"Connection timed out.. Bye!\r\n"):
50 | raise Exception("Connection timed out error")
51 | break
52 | except Exception as e:
53 | self._telnet = None
54 | if try_number >= self.MAX_TRIES - 1:
55 | if safe:
56 | return None
57 | else:
58 | raise LiquidsoapTelnetException(str(e))
59 |
60 | response = response.removesuffix(END_PREFIX).decode("utf-8").splitlines()
61 | if as_dict or not splitlines:
62 | response = "\n".join(response)
63 | if as_dict:
64 | try:
65 | return json.loads(response)
66 | except json.JSONDecodeError:
67 | logger.warning(f"Error decoding liquidsoap json: {response}")
68 | if safe:
69 | return None
70 | else:
71 | raise
72 |
73 | return response
74 |
75 | def __getattr__(self, command):
76 | command = command.replace("__", ".")
77 | return lambda arg=None, **kwargs: self.execute(command=command, arg=arg, **kwargs)
78 |
79 |
80 | class _UpstreamGetter:
81 | def __init__(self):
82 | self.upstream_liquidsoaps = {}
83 |
84 | def __call__(self, upstream):
85 | port = upstream.telnet_port
86 | liquidsoap = self.upstream_liquidsoaps.get(port)
87 | if not liquidsoap:
88 | liquidsoap = self.upstream_liquidsoaps[port] = _Liquidsoap(host="upstream", port=port)
89 | return liquidsoap
90 |
91 |
92 | harbor = _Liquidsoap()
93 | upstream = _UpstreamGetter()
94 |
--------------------------------------------------------------------------------
/app/app/api/tasks.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | import re
4 |
5 | from django.conf import settings
6 | from django.core.exceptions import ValidationError
7 | from django.core.files import File
8 |
9 | from huey.contrib import djhuey
10 |
11 | from autodj.models import AudioAsset, Playlist, RotatorAsset
12 | from broadcast.models import BroadcastAsset
13 | from common.models import User
14 |
15 | logger = logging.getLogger(f"crazyarms.{__name__}")
16 |
17 | SFTP_PATH_ASSET_CLASSES = {
18 | "audio-assets": AudioAsset,
19 | "scheduled-broadcast-assets": BroadcastAsset,
20 | "rotator-assets": RotatorAsset,
21 | }
22 | SFTP_PATH_RE = re.compile(
23 | fr"^{re.escape(settings.SFTP_UPLOADS_ROOT)}(?P[^/]+)/"
24 | fr'(?:(?P{"|".join(re.escape(p) for p in SFTP_PATH_ASSET_CLASSES.keys())})/)?'
25 | r"(?:(?P[^/]+)/)?.+$"
26 | )
27 |
28 |
29 | @djhuey.task(priority=1)
30 | def process_sftp_upload(sftp_path):
31 | logger.info(f"processing sftp upload: {sftp_path}")
32 |
33 | if os.path.isfile(sftp_path) and not os.path.islink(sftp_path):
34 | try:
35 | match = SFTP_PATH_RE.search(sftp_path)
36 | if match:
37 | match = match.groupdict()
38 |
39 | uploader = User.objects.get(id=match["user_id"])
40 | asset_cls = SFTP_PATH_ASSET_CLASSES.get(match["asset_type"]) or AudioAsset
41 | asset = asset_cls(uploader=uploader, file_basename=os.path.basename(sftp_path))
42 | asset.file.save(
43 | f"uploads/{asset.file_basename}",
44 | File(open(sftp_path, "rb")),
45 | save=False,
46 | )
47 |
48 | type_name = asset_cls._meta.verbose_name
49 | try:
50 | asset.clean()
51 | except ValidationError as e:
52 | logger.warning(f"sftp upload skipped {type_name} by {uploader} {sftp_path}: validation error: {e}")
53 | else:
54 | asset.save()
55 | logger.info(f"sftp upload of {type_name} by {uploader} successfully processed: {sftp_path}")
56 |
57 | # Create a playlist if that's what a user wants (only for AudioAssets)
58 | if isinstance(asset, AudioAsset) and uploader.sftp_playlists_by_folder:
59 | folder = match["first_folder_name"]
60 | if folder:
61 | playlist_name = " ".join(folder.strip().split()) # normalize whitespace
62 | playlist = Playlist.objects.filter(name__iexact=playlist_name).first()
63 | if playlist:
64 | logger.info(f"sftp upload by {uploader} used playlist {playlist.name}")
65 | else:
66 | logger.info(
67 | f"sftp upload by {uploader} used playlist folder, creating playlist {playlist_name}"
68 | )
69 | playlist = Playlist.objects.create(name=playlist_name)
70 |
71 | asset.playlists.add(playlist)
72 | else:
73 | logger.warning(f"sftp upload can't process, regular expression failed to match: {sftp_path}")
74 |
75 | finally:
76 | os.remove(sftp_path)
77 | else:
78 | logger.error(f"sftp update can't process, path doesn't exist / isn't a file: {sftp_path}")
79 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Crazy Arms Radio Backend — :zany_face: :mechanical_arm: :radio: :woman_technologist:
2 |
3 | Crazy Arms Radio Backend is a flexible and fully featured Internet radio back-end
4 | written from the ground up.
5 |
6 | It's specifically written after its author built a few _fully decentralized_
7 | online radio stations with varying types of scheduling and finding no existing
8 | product fit some common needs out of the box.
9 |
10 | Read the [documentation for Crazy Arms here](https://dtcooper.github.io/crazyarms).
11 |
12 |
13 | ## Quickstart
14 |
15 | 1. Install [Docker](https://www.docker.com/) and
16 | [docker-compose](https://docs.docker.com/compose/). On macOS, install
17 | [Docker for Mac](https://docs.docker.com/docker-for-mac/install/).
18 | On Debian/Ubuntu do the following:
19 |
20 | ```
21 | # Install Docker (Linux instructions only)
22 | curl -fsSL https://get.docker.com | sh
23 |
24 | # and docker-compose
25 | sudo curl \
26 | -L "https://github.com/docker/compose/releases/download/1.29.0/docker-compose-$(uname -s)-$(uname -m)" \
27 | -o /usr/local/bin/docker-compose
28 | sudo chmod +x /usr/local/bin/docker-compose
29 |
30 | # (If you're a non-root user)
31 | sudo adduser "$USER" docker
32 | # Log back in and out, to make sure you're in the docker group
33 | ```
34 |
35 | 2. Clone Crazy Arms,
36 |
37 | ```
38 | git clone https://github.com/dtcooper/crazyarms.git
39 | ```
40 |
41 | 3. Run the thing docker-compose wrapper script `./compose.sh` to configure and
42 | pull Crazy Arms.
43 |
44 | ```
45 | cd crazyarms
46 |
47 | # This will ask you some basic questions. For local development domain
48 | # name should be the default of "localhost"
49 | ./compose.sh pull
50 | ```
51 |
52 | If you want to change any of these settings, edit the `.env` file in the
53 | project directory. (NOTE: A later release will have these containers built
54 | and downloadable from [Docker Hub](https://hub.docker.com/).)
55 |
56 | (For development only, to build containers from source, run `./compose.sh build`
57 | instead of `pull` above. This may take a while.)
58 |
59 | 4. Start Crazy Arms
60 |
61 | ```
62 | ./compose.sh up -d
63 | ```
64 |
65 | 5. In your web browser, go to the domain name you chose, ie .
66 |
67 | #### [Digital Ocean](https://www.digitalocean.com/) Notes
68 |
69 | * A 2gb + 2 CPU droplet or better is recommended if you're using Zoom, otherwise
70 | the cheapest one will do.
71 | * [haveged](http://www.issihosts.com/haveged/) makes docker-compose run
72 | significantly faster:
73 |
74 | ```
75 | sudo apt-get install -y haveged
76 | ```
77 |
78 | #### Apple M1 Chipset Notes (aarch64)
79 |
80 | It works with the [Docker for Mac Apple M1
81 | preview](https://docs.docker.com/docker-for-mac/apple-m1/)!
82 | However, native `aarch64` containers are not provided, so you'll need to build
83 | them from source (`./compose.sh build` instead of `pull`). The Zoom container
84 | will need to emulate amd64.
85 |
86 | ## Liquidshop 1.0 Slide Deck
87 |
88 | Here's [a slide deck](https://docs.google.com/presentation/d/18K1RagpDW79u086r2EV_ysAzFR9gkGJiZTk1cOZCUTg/edit?usp=sharing)
89 | about Crazy Arms from the [Liquidshop 1.0](https://liquidsoap.info/liquidshop) conference
90 | giving a high level overview and some of the technical approaches taken.
91 |
92 |
93 | ## License
94 |
95 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
96 | for details.
97 |
--------------------------------------------------------------------------------
/app/app/webui/templates/webui/zoom.html:
--------------------------------------------------------------------------------
1 | {% extends 'webui/form.html' %}
2 |
3 | {% load static %}
4 |
5 | {% block content %}
6 |
7 | Status:
8 | {% if zoom_is_running %}
9 | {% if zoom_belongs_to_current_user %}
10 | Zoom is currently running in your room.
11 | {% else%}
12 | Zoom is currently in use by {% if zoom_user %}{{ zoom_user.get_full_name }}{% else %}unknown{% endif %}.
13 | You will NOT be able to broadcast until after the current show ends.
14 | {% endif %}
15 | {% if zoom_ttl %}
16 |
17 | The current show is scheduled to end at {{ zoom_ttl }}.
18 | {% endif %}
19 | {% if user.is_superuser or zoom_belongs_to_current_user %}
20 |
21 |
29 | {% endif %}
30 | {% else %}
31 | Zoom is ready to run.
32 | {% if currently_authorized %}
33 | You can start show now by filling out the form below.
34 | {% else %}
35 | You are currently not authorized to broadcast on the harbor.
36 | {% endif %}
37 |
38 | {% if user.upcoming_show_times %}
39 | Your upcoming show: {{ user.upcoming_show_times|first|first }}
40 | to {{ user.upcoming_show_times|first|last }} ({{ user.timezone }})
41 | {% if user.gcal_entry_grace_minutes > 0 or user.gcal_exit_grace_minutes > 0 %}
42 |
43 | {% if user.gcal_entry_grace_minutes > 0 %}
44 | You can start your show up to {{ user.gcal_entry_grace_minutes }} minute{{ user.gcal_entry_grace_minutes|pluralize }} early
45 | {% endif %}
46 | {% if user.gcal_exit_grace_minutes > 0 %}
47 | {% if user.gcal_entry_grace_minutes > 0 %}and{% else %}You can{% endif %}
48 | keep broadcasting up to {{ user.gcal_exit_grace_minutes }} minute{{ user.gcal_exit_grace_minutes|pluralize }} afterwards.
49 | {% endif %}
50 | {% endif %}
51 | {% if not currently_authorized and user.harbor_auth_actual == user.HarborAuth.GOOGLE_CALENDAR %}
52 | Come back to this page then.
53 | {% endif %}
54 | {% else %}
55 |
56 | You have no upcoming scheduled shows.
57 | {% if user.harbor_auth_actual == user.HarborAuth.GOOGLE_CALENDAR %}
58 | Please contact the station administration if you believe this in error.
59 | {% endif %}
60 |
61 | {% endif %}
62 | {% endif %}
63 |
64 |
65 |
For more information on how to use Zoom broadcasting, consult the
66 | Help Docs.
67 |
68 |
69 | {% if currently_authorized and not zoom_is_running %}
70 | {# Show form if the user is authorized and Zoom isn't currently running #}
71 | {{ block.super}}
72 | {% else %}
73 | {# Otherwise we'll want to see errors if we have them #}
74 | {% if form.errors %}
75 |
6 | You are currently NOT authorized on the harbor.
7 | Please contact the station administration if you believe this in error.
8 |
9 | {% else %}
10 |
11 | {% if user.harbor_auth_actual == user.HarborAuth.ALWAYS %}
12 | Note: you are ALWAYS authorized to broadcast on the harbor.
13 | {% else %} {# user.HarborAuth.GOOGLE_CALENDAR #}
14 | You are only authorized to broadcast during your scheduled times.
15 | {% if user.gcal_entry_grace_minutes > 0 or user.gcal_exit_grace_minutes > 0 %}
16 | Your login grace period:
17 |
18 | {% if user.gcal_entry_grace_minutes > 0 %}
19 |
You can start broadcasting up to
20 | {{ user.gcal_entry_grace_minutes }} minute{{ user.gcal_entry_grace_minutes|pluralize }}
21 | before your scheduled time.
You can keep broadcasting up to
25 | {{ user.gcal_exit_grace_minutes }} minute{{ user.gcal_exit_grace_minutes|pluralize }}
26 | after your scheduled time.
42 | You have {{ user.current_show_times|pluralize:"a show that is,shows that are" }}
43 | scheduled for right now!
44 | {% if user.harbor_auth_actual != user.HarborAuth.NEVER %}
45 | If you haven't already done so, please start your broadcast.
46 | {% endif %}
47 |
48 |
49 |
50 |
51 | My Current Scheduled Shows
52 | {% if user.harbor_auth_actual == user.HarborAuth.GOOGLE_CALENDAR %}
53 | {% if user.gcal_entry_grace_minutes > 0 or user.gcal_exit_grace_minutes > 0 %}
54 | (Not Including Grace Period)
55 | {% endif %}
56 | {% endif %}
57 |
58 |
59 |
60 |
#
61 |
Start Time ({{ user.timezone }})
62 |
End Time ({{ user.timezone }})
63 |
Title
64 |
65 |
66 |
67 | {% for title, start, end in user.current_show_times %}
68 |
69 |
{{ forloop.counter }}
70 |
{{ start }}
71 |
{{ end }}
72 |
{% if title %}{{ title }}{% else %}Untitled Show{% endif %}