├── app ├── app │ ├── crazyarms │ │ ├── __init__.py │ │ ├── version.py │ │ ├── test_data │ │ │ └── ccmixter.json │ │ ├── apps.py │ │ ├── templates │ │ │ └── admin │ │ │ │ ├── app_index_extra.html │ │ │ │ ├── base_site_extra.html │ │ │ │ ├── base_site.html │ │ │ │ └── app_list.html │ │ ├── context_processors.py │ │ ├── urls.py │ │ ├── asgi.py │ │ ├── wsgi.py │ │ ├── constants.py │ │ ├── settings_test.py │ │ └── admin.py │ ├── autodj │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0002_auto_20210611_1445.py │ │ ├── __init__.py │ │ ├── apps.py │ │ ├── templates │ │ │ └── admin │ │ │ │ └── autodj │ │ │ │ └── change_list.html │ │ └── forms.py │ ├── common │ │ ├── migrations │ │ │ └── __init__.py │ │ ├── __init__.py │ │ ├── widgets.py │ │ ├── templates │ │ │ └── common │ │ │ │ └── forms │ │ │ │ └── widgets │ │ │ │ └── always_clearable_file_input.html │ │ ├── middleware.py │ │ ├── static │ │ │ └── common │ │ │ │ ├── favicon.svg │ │ │ │ └── admin │ │ │ │ ├── js │ │ │ │ ├── asset_source.js │ │ │ │ └── harbor_auth.js │ │ │ │ └── css │ │ │ │ └── custom.css │ │ ├── mail.py │ │ ├── apps.py │ │ ├── forms.py │ │ └── management │ │ │ └── commands │ │ │ └── import_assets.py │ ├── gcal │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0001_initial.py │ │ ├── __init__.py │ │ ├── apps.py │ │ ├── templates │ │ │ └── admin │ │ │ │ └── gcal │ │ │ │ └── gcalshow │ │ │ │ └── change_list.html │ │ ├── tasks.py │ │ ├── admin.py │ │ └── models.py │ ├── broadcast │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0001_initial.py │ │ ├── __init__.py │ │ ├── templates │ │ │ └── admin │ │ │ │ └── broadcast │ │ │ │ └── change_list.html │ │ ├── apps.py │ │ ├── forms.py │ │ ├── tasks.py │ │ ├── models.py │ │ └── admin.py │ ├── services │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0001_initial.py │ │ ├── templatetags │ │ │ ├── __init__.py │ │ │ └── services.py │ │ ├── apps.py │ │ ├── __init__.py │ │ ├── forms.py │ │ ├── templates │ │ │ ├── services │ │ │ │ ├── service.conf │ │ │ │ ├── icecast.xml │ │ │ │ ├── library.liq │ │ │ │ └── upstream.liq │ │ │ └── admin │ │ │ │ └── services │ │ │ │ └── harbor_custom_config.html │ │ ├── management │ │ │ └── commands │ │ │ │ ├── init_services.py │ │ │ │ └── run_log_subscriber.py │ │ ├── tasks.py │ │ ├── liquidsoap.py │ │ └── models.py │ ├── webui │ │ ├── static │ │ │ └── webui │ │ │ │ ├── css │ │ │ │ ├── new.css │ │ │ │ └── base.css │ │ │ │ └── js │ │ │ │ ├── jquery.min.js │ │ │ │ ├── handlebars.min.js │ │ │ │ └── base.js │ │ ├── __init__.py │ │ ├── apps.py │ │ ├── templates │ │ │ └── webui │ │ │ │ ├── login.html │ │ │ │ ├── form.html │ │ │ │ ├── password_set.html │ │ │ │ ├── ban_list.html │ │ │ │ ├── profile.html │ │ │ │ ├── playout_log.html │ │ │ │ ├── zoom.html │ │ │ │ ├── base.html │ │ │ │ ├── info.html │ │ │ │ └── gcal.html │ │ ├── tasks.py │ │ ├── urls.py │ │ └── tests.py │ ├── api │ │ ├── __init__.py │ │ ├── apps.py │ │ ├── urls.py │ │ └── tasks.py │ ├── .flake8 │ ├── requirements.txt │ ├── pyproject.toml │ └── manage.py ├── image │ └── entrypoint.sh └── Dockerfile ├── zoom ├── image │ ├── etc │ │ ├── skel │ │ │ ├── .config │ │ │ │ ├── google-chrome │ │ │ │ │ └── First Run │ │ │ │ └── zoomus.conf │ │ │ └── .icewm │ │ │ │ ├── theme │ │ │ │ ├── menu │ │ │ │ ├── toolbar │ │ │ │ └── preferences │ │ └── supervisor │ │ │ └── supervisord.conf │ ├── entrypoint.sh │ └── usr │ │ └── local │ │ └── sbin │ │ └── zoom-runner.sh └── Dockerfile ├── docs ├── admin-guide │ ├── autodj.md │ ├── permissions.md │ ├── google-calendar.md │ └── configuration.md ├── img │ ├── favicon.svg │ ├── david.jpg │ ├── tigwit.jpg │ ├── zoom-instructions-1.png │ └── zoom-instructions-2.png ├── users-guide │ ├── prerecorded-broadcasts.md │ ├── dj │ │ ├── rtmp.md │ │ ├── icecast.md │ │ └── zoom.md │ └── autodj.md ├── about │ ├── changelog.md │ ├── support.md │ ├── license.md │ └── author-miscellany.md ├── glossary.md └── server-setup.md ├── liquidsoap ├── image │ ├── assets │ │ ├── failsafe.mp3 │ │ ├── hold-music.mp3 │ │ └── transition.mp3 │ ├── entrypoint.sh │ └── etc │ │ └── supervisor │ │ └── supervisord.conf ├── Dockerfile.harbor-telnet └── Dockerfile ├── icecast ├── image │ ├── usr │ │ └── local │ │ │ └── bin │ │ │ └── reload_icecast.sh │ └── entrypoint.sh └── Dockerfile ├── .gitignore ├── docker-compose ├── https.yml ├── rtmp.yml ├── harbor-telnet-web.yml ├── icecast.yml ├── zoom.yml ├── test.yml └── base.yml ├── nginx ├── image │ ├── etc │ │ └── nginx │ │ │ ├── conf.d │ │ │ └── default.conf │ │ │ └── templates │ │ │ └── default.conf.j2 │ ├── docker-entrypoint.d │ │ ├── 40-certbot.sh │ │ └── 30-jinja2-on-templates.sh │ └── usr │ │ ├── share │ │ └── nginx │ │ │ └── html │ │ │ └── test_sse.html │ │ └── local │ │ └── sbin │ │ └── certbot-daemon.sh └── Dockerfile ├── sftp ├── Dockerfile └── image │ ├── entrypoint.sh │ ├── usr │ └── local │ │ └── bin │ │ ├── upload.sh │ │ └── auth.sh │ └── etc │ ├── sftpgo.json │ └── sftp_home_readme.txt ├── CHANGELOG.md ├── rtmp ├── image │ ├── usr │ │ └── local │ │ │ └── bin │ │ │ └── ffmpeg_rtmp_to_harbor.sh │ └── etc │ │ └── nginx │ │ └── nginx.conf └── Dockerfile ├── LICENSE ├── .default.env ├── mkdocs.yml ├── docs_macros.py └── README.md /app/app/crazyarms/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/app/autodj/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/app/common/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/app/gcal/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/app/broadcast/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/app/services/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/app/services/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/app/webui/static/webui/css/new.css: -------------------------------------------------------------------------------- 1 | new-1.1.3.css -------------------------------------------------------------------------------- /zoom/image/etc/skel/.config/google-chrome/First Run: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/admin-guide/autodj.md: -------------------------------------------------------------------------------- 1 | # Programming the AutoDJ 2 | -------------------------------------------------------------------------------- /app/app/webui/static/webui/js/jquery.min.js: -------------------------------------------------------------------------------- 1 | jquery-3.5.1.min.js -------------------------------------------------------------------------------- /docs/img/favicon.svg: -------------------------------------------------------------------------------- 1 | ../../app/app/common/static/common/favicon.svg -------------------------------------------------------------------------------- /zoom/image/etc/skel/.icewm/theme: -------------------------------------------------------------------------------- 1 | Theme="icedesert/default.theme" 2 | -------------------------------------------------------------------------------- /app/app/api/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "api.apps.APIConfig" 2 | -------------------------------------------------------------------------------- /app/app/gcal/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "gcal.apps.GCalConfig" 2 | -------------------------------------------------------------------------------- /app/app/webui/static/webui/js/handlebars.min.js: -------------------------------------------------------------------------------- 1 | handlebars-4.7.7.min.js -------------------------------------------------------------------------------- /docs/users-guide/prerecorded-broadcasts.md: -------------------------------------------------------------------------------- 1 | # Prerecorded Broadcasts 2 | -------------------------------------------------------------------------------- /app/app/autodj/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "autodj.apps.AutoDJConfig" 2 | -------------------------------------------------------------------------------- /app/app/common/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "common.apps.CommonConfig" 2 | -------------------------------------------------------------------------------- /app/app/webui/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "webui.apps.WebUIConfig" 2 | -------------------------------------------------------------------------------- /docs/users-guide/dj/rtmp.md: -------------------------------------------------------------------------------- 1 | # RTMP 2 | 3 | [OBS](https://obsproject.com/) 4 | -------------------------------------------------------------------------------- /app/app/broadcast/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "broadcast.apps.BroadcastConfig" 2 | -------------------------------------------------------------------------------- /docs/admin-guide/permissions.md: -------------------------------------------------------------------------------- 1 | # User Permissions 2 | 3 | ## Harbor Authorization 4 | -------------------------------------------------------------------------------- /docs/img/david.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dtcooper/crazyarms/HEAD/docs/img/david.jpg -------------------------------------------------------------------------------- /docs/img/tigwit.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dtcooper/crazyarms/HEAD/docs/img/tigwit.jpg -------------------------------------------------------------------------------- /docs/users-guide/autodj.md: -------------------------------------------------------------------------------- 1 | # AutoDJ 2 | 3 | TODO: two autodj articles, delete one, probably ADMIN -------------------------------------------------------------------------------- /app/app/crazyarms/version.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | __version__ = os.environ.get("CRAZYARMS_VERSION", "unknown") 4 | -------------------------------------------------------------------------------- /app/app/.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | extend-ignore = E203, W503 4 | exclude = */migrations/*.py 5 | -------------------------------------------------------------------------------- /app/app/broadcast/templates/admin/broadcast/change_list.html: -------------------------------------------------------------------------------- 1 | ../../../../autodj/templates/admin/autodj/change_list.html -------------------------------------------------------------------------------- /docs/img/zoom-instructions-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dtcooper/crazyarms/HEAD/docs/img/zoom-instructions-1.png -------------------------------------------------------------------------------- /docs/img/zoom-instructions-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dtcooper/crazyarms/HEAD/docs/img/zoom-instructions-2.png -------------------------------------------------------------------------------- /app/app/api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class APIConfig(AppConfig): 5 | name = "api" 6 | -------------------------------------------------------------------------------- /app/app/webui/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class WebUIConfig(AppConfig): 5 | name = "webui" 6 | -------------------------------------------------------------------------------- /docs/about/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Version 0.0.1 (YYYY-MM-DD) 4 | 5 | * Initial release. Everything is new! 6 | -------------------------------------------------------------------------------- /liquidsoap/image/assets/failsafe.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dtcooper/crazyarms/HEAD/liquidsoap/image/assets/failsafe.mp3 -------------------------------------------------------------------------------- /liquidsoap/image/assets/hold-music.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dtcooper/crazyarms/HEAD/liquidsoap/image/assets/hold-music.mp3 -------------------------------------------------------------------------------- /liquidsoap/image/assets/transition.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dtcooper/crazyarms/HEAD/liquidsoap/image/assets/transition.mp3 -------------------------------------------------------------------------------- /zoom/image/etc/skel/.config/zoomus.conf: -------------------------------------------------------------------------------- 1 | [General] 2 | AutoJoinVoip=true 3 | enableMiniWindow=false 4 | zdisablerecvvideo=true 5 | -------------------------------------------------------------------------------- /app/app/crazyarms/test_data/ccmixter.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dtcooper/crazyarms/HEAD/app/app/crazyarms/test_data/ccmixter.json -------------------------------------------------------------------------------- /app/app/services/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ServicesConfig(AppConfig): 5 | name = "services" 6 | -------------------------------------------------------------------------------- /zoom/image/etc/skel/.icewm/menu: -------------------------------------------------------------------------------- 1 | prog "Google Chrome" google-chrome /usr/bin/google-chrome-stable 2 | prog "Zoom" Zoom /usr/bin/zoom 3 | -------------------------------------------------------------------------------- /zoom/image/etc/skel/.icewm/toolbar: -------------------------------------------------------------------------------- 1 | prog "Google Chrome" google-chrome /usr/bin/google-chrome-stable 2 | prog "Zoom" Zoom /usr/bin/zoom 3 | -------------------------------------------------------------------------------- /app/app/autodj/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AutoDJConfig(AppConfig): 5 | name = "autodj" 6 | verbose_name = "AutoDJ" 7 | -------------------------------------------------------------------------------- /icecast/image/usr/local/bin/reload_icecast.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | PID="$(pgrep '^icecast$')" 4 | 5 | if [ "$PID" ]; then 6 | kill -SIGHUP "$PID" 7 | fi 8 | -------------------------------------------------------------------------------- /zoom/image/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Make sure /etc/skel is copied over to user's home 4 | sudo -u user cp -nr /etc/skel/. /home/user 5 | exec "$@" 6 | -------------------------------------------------------------------------------- /app/app/services/__init__.py: -------------------------------------------------------------------------------- 1 | from .services import init_services 2 | 3 | default_app_config = "services.apps.ServicesConfig" 4 | 5 | __all__ = ("init_services",) 6 | -------------------------------------------------------------------------------- /app/app/gcal/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class GCalConfig(AppConfig): 5 | name = "gcal" 6 | verbose_name = "Google Calendar Integration" 7 | -------------------------------------------------------------------------------- /docs/admin-guide/google-calendar.md: -------------------------------------------------------------------------------- 1 | # Google Calendar Scheduling 2 | 3 | TODO 4 | 5 | * Enabling 6 | * The credential file (could absolute URL link _here_ in CONSTANCE_CONFIG?) 7 | -------------------------------------------------------------------------------- /app/app/broadcast/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BroadcastConfig(AppConfig): 5 | name = "broadcast" 6 | verbose_name = "Prerecorded Broadcasts" 7 | -------------------------------------------------------------------------------- /app/app/crazyarms/apps.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin.apps import AdminConfig 2 | 3 | 4 | class CrazyArmsAdminConfig(AdminConfig): 5 | default_site = "crazyarms.admin.CrazyArmsAdminSite" 6 | -------------------------------------------------------------------------------- /app/app/common/widgets.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | 4 | class AlwaysClearableFileInput(forms.ClearableFileInput): 5 | template_name = "common/forms/widgets/always_clearable_file_input.html" 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | _docs/ 3 | .DS_Store 4 | .coverage 5 | .env 6 | .pytest_cache 7 | .vscode 8 | credentials.json 9 | docker-compose/overrides.yml 10 | htmlcov/ 11 | imports/ 12 | media/ 13 | -------------------------------------------------------------------------------- /zoom/image/etc/skel/.icewm/preferences: -------------------------------------------------------------------------------- 1 | WorkspaceNames=" 1 " 2 | TaskBarShowMailboxStatus = 0 3 | TaskBarShowWorkspaces = 0 4 | ShowProgramsMenu = 0 5 | ShowHelp = 0 6 | ShowLogoutMenu = 0 7 | ShowSettingsMenu = 0 8 | ShowFocusModeMenu = 0 9 | -------------------------------------------------------------------------------- /liquidsoap/image/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -z "$CONTAINER_NAME" ]; then 4 | echo 'Please run with CONTAINER_NAME set.' 5 | exit 1 6 | fi 7 | 8 | ln -s "/config/${CONTAINER_NAME}/supervisor" /etc/supervisor/conf.d 9 | 10 | exec "$@" 11 | -------------------------------------------------------------------------------- /app/app/broadcast/forms.py: -------------------------------------------------------------------------------- 1 | from common.forms import AudioAssetCreateFormBase 2 | 3 | from .models import BroadcastAsset 4 | 5 | 6 | class BroadcastAssetCreateForm(AudioAssetCreateFormBase): 7 | class Meta(AudioAssetCreateFormBase.Meta): 8 | model = BroadcastAsset 9 | -------------------------------------------------------------------------------- /docker-compose/https.yml: -------------------------------------------------------------------------------- 1 | services: 2 | nginx: 3 | volumes: 4 | - letsencrypt:/etc/letsencrypt 5 | ports: 6 | - '${HTTPS_PORT:-443}:443' 7 | 8 | volumes: 9 | # TODO move this to services_config, and symlink /etc/letsencrypt 10 | letsencrypt: 11 | -------------------------------------------------------------------------------- /docs/users-guide/dj/icecast.md: -------------------------------------------------------------------------------- 1 | # Icecast 2 2 | 3 | * [butt](https://danielnoethen.de/butt/) 4 | * [Audio HiJack](https://rogueamoeba.com/audiohijack/) 5 | * [Rocket Broadcaster](https://www.rocketbroadcaster.com/) (maybe) 6 | 7 | ## Troubleshooting 8 | 9 | * Only one DJ can be logged in the harbor at one time 10 | -------------------------------------------------------------------------------- /nginx/image/etc/nginx/conf.d/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server reuseport; 3 | listen [::]:80 default_server reuseport; 4 | 5 | location / { 6 | add_header Content-Type text/plain; 7 | return 200 'Crazy Arms failed to properly generate nginx config!'; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /sftp/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM drakkan/sftpgo:alpine 2 | 3 | USER root 4 | WORKDIR / 5 | 6 | RUN apk add --no-cache \ 7 | curl \ 8 | jq \ 9 | openssh-keygen 10 | 11 | RUN rm -rf /var/lib/sftpgo && ln -s /config/sftp /var/lib/sftpgo 12 | 13 | COPY image/ / 14 | 15 | ENTRYPOINT ["/entrypoint.sh"] 16 | CMD [] 17 | -------------------------------------------------------------------------------- /app/app/crazyarms/templates/admin/app_index_extra.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/index.html' %} 2 | 3 | {% if not is_popup %} 4 | {% block breadcrumbs %} 5 | 9 | {% endblock %} 10 | {% endif %} 11 | -------------------------------------------------------------------------------- /docker-compose/rtmp.yml: -------------------------------------------------------------------------------- 1 | services: 2 | rtmp: 3 | container_name: crazyarms-rtmp 4 | image: dtcooper/crazyarms-rtmp:${CRAZYARMS_VERSION} 5 | restart: always 6 | depends_on: 7 | - app 8 | volumes: 9 | - ./.env:/.env:ro 10 | build: 11 | context: ./rtmp 12 | ports: 13 | - "${RTMP_PORT:-1935}:1935" 14 | -------------------------------------------------------------------------------- /docs/about/support.md: -------------------------------------------------------------------------------- 1 | # Issues and Support 2 | 3 | ## Issues and Bugs 4 | 5 | If you encounter a bug or issue, please 6 | [create a new GitHub issue here](https://github.com/dtcooper/crazyarms/issues) 7 | outing the problem with as much detail as possible. 8 | 9 | ## Support 10 | 11 | Paid support is available. Reach out to David at david@dtcooper.com. 12 | 13 | -------------------------------------------------------------------------------- /sftp/image/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | mkdir -p /config/sftp 4 | cd /var/lib/sftpgo 5 | cp -v /etc/sftpgo.json sftpgo.json 6 | 7 | if [ ! -f id_rsa ]; then 8 | echo 'Generating an ssh key for sftp/scp' 9 | ssh-keygen -t rsa -b 4096 -f id_rsa -q -N "" 10 | fi 11 | 12 | if [ "$#" -gt 0 ]; then 13 | exec "$@" 14 | else 15 | exec sftpgo serve 16 | fi 17 | -------------------------------------------------------------------------------- /app/app/autodj/templates/admin/autodj/change_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/change_list.html' %} 2 | 3 | {% block object-tools %} 4 | {{ block.super }} 5 | 6 |

7 | Disk usage (on media root): 8 | {{ disk_usage.free|filesizeformat }} free, 9 | {{ disk_usage.used|filesizeformat }} / {{ disk_usage.total|filesizeformat }} used 10 |

11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /liquidsoap/Dockerfile.harbor-telnet: -------------------------------------------------------------------------------- 1 | FROM tsl0922/ttyd:alpine 2 | 3 | # Add in rlwrap to enable arrow keys 4 | RUN apk add --no-cache --repository http://dl-3.alpinelinux.org/alpine/edge/testing/ rlwrap 5 | 6 | CMD ["ttyd", "-t", "titleFixed=Harbor Telnet", "rlwrap", "--no-warnings", "sh", "-c", "echo '# Welcome to the Harbor telnet. Enter \"help\" + [ENTER] for more info.' && exec nc harbor 1234"] 7 | -------------------------------------------------------------------------------- /app/app/common/templates/common/forms/widgets/always_clearable_file_input.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | 6 | -------------------------------------------------------------------------------- /app/app/crazyarms/templates/admin/base_site_extra.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base_site.html' %} 2 | 3 | {% if not is_popup %} 4 | {% block breadcrumbs %} 5 | 10 | {% endblock %} 11 | {% endif %} 12 | -------------------------------------------------------------------------------- /app/app/webui/templates/webui/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'webui/form.html' %} 2 | 3 | {% block content %} 4 |

Enter your username and password to log into {{ config.STATION_NAME }}'s backend.

5 | 6 | {{ block.super }} 7 | 8 | {% if settings.EMAIL_ENABLED %} 9 |

If you have forgotten your password, click here to reset it.

10 | {% endif %} 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /app/app/crazyarms/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from .version import __version__ 4 | 5 | 6 | def crazyarms_extra_context(request): 7 | return { 8 | "settings": settings, 9 | "crazyarms_version": __version__, 10 | # can_boot has no relevant admin page 11 | "user_has_admin_permissions": bool(request.user.get_all_permissions() - {"common.can_boot"}), 12 | } 13 | -------------------------------------------------------------------------------- /docs/about/license.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | ## MIT License 4 | 5 | Crazy Arms is free and open source software licensed under the MIT License. You 6 | can pretty much do whatever you want it provided you preserve the below notices 7 | in your copy or derivative work. Differently licensed works, modifications, and 8 | larger works may be distributed under different terms and without source code. 9 | 10 | ``` 11 | {{ LICENSE }} 12 | ``` 13 | -------------------------------------------------------------------------------- /docker-compose/harbor-telnet-web.yml: -------------------------------------------------------------------------------- 1 | services: 2 | nginx: 3 | depends_on: 4 | - harbor-telnet-web 5 | harbor-telnet-web: 6 | container_name: crazyarms-harbor-telnet-web 7 | image: dtcooper/crazyarms-harbor-telnet-web:${CRAZYARMS_VERSION} 8 | build: 9 | context: ./liquidsoap 10 | dockerfile: Dockerfile.harbor-telnet 11 | restart: always 12 | environment: 13 | TZ: ${TIMEZONE:-US/Pacific} 14 | -------------------------------------------------------------------------------- /app/app/services/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from .services import HarborService 4 | 5 | 6 | class HarborCustomConfigForm(forms.Form): 7 | def __init__(self, *args, **kwargs): 8 | super().__init__(*args, **kwargs) 9 | for section_number in range(1, HarborService.CUSTOM_CONFIG_NUM_SECTIONS + 1): 10 | self.fields[f"section{section_number}"] = forms.CharField(widget=forms.Textarea, required=False) 11 | -------------------------------------------------------------------------------- /app/app/crazyarms/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin 3 | from django.urls import include, path, re_path 4 | from django.views.static import serve 5 | 6 | urlpatterns = [ 7 | path("", include("webui.urls")), 8 | path("api/", include("api.urls")), 9 | path("admin/", admin.site.urls), 10 | ] 11 | 12 | if settings.DEBUG: 13 | urlpatterns += [re_path(r"^media/(?P.*)$", serve, {"document_root": settings.MEDIA_ROOT})] 14 | -------------------------------------------------------------------------------- /app/app/crazyarms/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for crazyarms project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "crazyarms.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /app/app/crazyarms/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for crazyarms project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "crazyarms.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /icecast/image/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$#" -gt 0 ]; then 4 | exec "$@" 5 | fi 6 | 7 | CONF=/etc/icecast.xml 8 | 9 | if [ ! -e "/etc/icecast.xml" ]; then 10 | echo "Waiting for /etc/icecast.xml" 11 | while [ ! -e "/etc/icecast.xml" ]; do 12 | sleep 0.5 13 | done 14 | fi 15 | 16 | echo 'Running config change detection daemon' 17 | nohup inotifyd reload_icecast.sh /etc/icecast.xml:c & 18 | 19 | echo 'Starting Icecast' 20 | exec icecast -c "$CONF" 21 | -------------------------------------------------------------------------------- /docker-compose/icecast.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | depends_on: 4 | - icecast 5 | 6 | icecast: 7 | container_name: crazyarms-icecast 8 | image: dtcooper/crazyarms-icecast:${CRAZYARMS_VERSION} 9 | restart: always 10 | build: 11 | context: ./icecast 12 | dockerfile: Dockerfile 13 | ports: 14 | - '${ICECAST_PORT:-8000}:8000' 15 | volumes: 16 | - services_config:/config:ro 17 | environment: 18 | TZ: ${TIMEZONE:-US/Pacific} 19 | -------------------------------------------------------------------------------- /app/app/common/middleware.py: -------------------------------------------------------------------------------- 1 | import pytz 2 | 3 | from django.utils import timezone 4 | 5 | 6 | class UserTimezoneMiddleware: 7 | def __init__(self, get_response): 8 | self.get_response = get_response 9 | 10 | def __call__(self, request): 11 | if request.user.is_authenticated: 12 | tz = pytz.timezone(request.user.timezone) 13 | timezone.activate(tz) 14 | else: 15 | timezone.deactivate() 16 | return self.get_response(request) 17 | -------------------------------------------------------------------------------- /zoom/image/etc/supervisor/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | logfile=/dev/null 4 | logfile_maxbytes=0 5 | logfile_backups=0 6 | directory=/ 7 | 8 | [inet_http_server] 9 | port=*:9001 10 | 11 | [unix_http_server] 12 | file=/var/run/supervisor.sock 13 | chmod=0700 14 | 15 | [rpcinterface:supervisor] 16 | supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface 17 | 18 | [supervisorctl] 19 | serverurl=unix:///var/run/supervisor.sock 20 | 21 | [include] 22 | files = /etc/supervisor/conf.d/*.conf 23 | -------------------------------------------------------------------------------- /liquidsoap/image/etc/supervisor/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | logfile=/dev/null 4 | logfile_maxbytes=0 5 | logfile_backups=0 6 | directory=/ 7 | 8 | [inet_http_server] 9 | port=*:9001 10 | 11 | [unix_http_server] 12 | file=/var/run/supervisor.sock 13 | chmod=0700 14 | 15 | [rpcinterface:supervisor] 16 | supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface 17 | 18 | [supervisorctl] 19 | serverurl=unix:///var/run/supervisor.sock 20 | 21 | [include] 22 | files = /etc/supervisor/conf.d/*.conf 23 | -------------------------------------------------------------------------------- /docker-compose/zoom.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | depends_on: 4 | - zoom 5 | 6 | nginx: 7 | depends_on: 8 | - zoom 9 | 10 | zoom: 11 | container_name: crazyarms-zoom 12 | image: dtcooper/crazyarms-zoom:${CRAZYARMS_VERSION} 13 | restart: always 14 | build: 15 | context: ./zoom 16 | volumes: 17 | - services_config:/config 18 | - zoom_user_home:/home/user 19 | cap_add: 20 | - SYS_ADMIN 21 | environment: 22 | TZ: ${TIMEZONE:-US/Pacific} 23 | 24 | volumes: 25 | zoom_user_home: 26 | -------------------------------------------------------------------------------- /app/app/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path("dj-auth/", views.DJAuthAPIView.as_view(), name="dj_auth"), 7 | path( 8 | "validate-stream-key/", 9 | views.ValidateStreamKeyView.as_view(), 10 | name="validate_stream_key", 11 | ), 12 | path("next-track/", views.NextTrackAPIView.as_view(), name="next_track"), 13 | path("sftp-auth/", views.SFTPAuthView.as_view(), name="sftp_auth"), 14 | path("sftp-upload/", views.SFTPUploadView.as_view(), name="fstp_upload"), 15 | ] 16 | -------------------------------------------------------------------------------- /docker-compose/test.yml: -------------------------------------------------------------------------------- 1 | services: 2 | test: 3 | container_name: crazyarms-test 4 | image: dtcooper/crazyarms-app:${CRAZYARMS_VERSION} 5 | build: 6 | context: ./app 7 | volumes: 8 | - ./app/app:/app 9 | environment: 10 | DJANGO_SETTINGS_MODULE: crazyarms.settings_test 11 | TZ: ${TIMEZONE:-US/Pacific} 12 | EMAIL_ENABLED: 0 13 | HARBOR_TELNET_WEB_ENABLED: 1 14 | ICECAST_ENABLED: 1 15 | RTMP_ENABLED: 1 16 | SFTP_ENABLED: 1 17 | ZOOM_ENABLED: 1 18 | SECRET_KEY: topsecret 19 | command: pytest 20 | -------------------------------------------------------------------------------- /sftp/image/usr/local/bin/upload.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | . /.env 4 | 5 | INFILE= 6 | 7 | if [ "$1" = 'upload' ]; then 8 | # Only run if file *doesn't* end in .filepart (ie, WinSCP pre-rename) 9 | if ! expr "$3" : '.*\.filepart$' > /dev/null; then 10 | INFILE="$3" 11 | fi 12 | elif [ "$1" = 'rename' ]; then 13 | INFILE="$4" 14 | fi 15 | 16 | if [ "$INFILE" ]; then 17 | URL='http://app:8000/api/sftp-upload/' 18 | JSON_IN="$(jq -nc --arg p "$INFILE" '{"path": $p}')" 19 | curl -d "$JSON_IN" -H "X-Crazyarms-Secret-Key: $SECRET_KEY" "$URL" 20 | fi 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Release Changelog 2 | 3 | ## X.X.X-patchlevelX (Unreleased) 4 | 5 | * Upstreams flickering to upstream failsafe mp3 ([#4](https://github.com/dtcooper/crazyarms/issues/4)) 6 | * Added filesize to assets, disk usage on asset list display views 7 | * Fixed user search by keyword 8 | * Case insensitive usernames + gcal email addresses 9 | * Namespace containers ([#3](https://github.com/dtcooper/crazyarms/issues/3)) 10 | * Liquidsoap custom script config works again ([#2](https://github.com/dtcooper/crazyarms/issues/2)) 11 | 12 | ## 0.0.1-alpha1 13 | 14 | * _Everything new!_ 15 | -------------------------------------------------------------------------------- /app/app/services/templates/services/service.conf: -------------------------------------------------------------------------------- 1 | {% autoescape off %} 2 | [program:{{ program }}] 3 | autorestart={{ autorestart|default:True|yesno:'true,false' }} 4 | autostart=false 5 | command={{ command }} 6 | stderr_events_enabled=true 7 | stderr_logfile=/proc/1/fd/2 8 | stderr_logfile_maxbytes=0 9 | stdout_events_enabled=true 10 | stdout_logfile=/proc/1/fd/1 11 | stdout_logfile_maxbytes=0 12 | stopasgroup=true 13 | killasgroup=true 14 | stopsignal=INT 15 | {% if extras %} 16 | {% for key, value in extras.items %} 17 | {{ key }}={{ value }} 18 | {% endfor %} 19 | {% endif %} 20 | {% endautoescape %} 21 | -------------------------------------------------------------------------------- /app/app/requirements.txt: -------------------------------------------------------------------------------- 1 | argh 2 | dateutils 3 | django-cleanup 4 | django-constance[redis] 5 | django-dirtyfields 6 | django-environ 7 | django-extensions 8 | django-redis 9 | django-select2 10 | django-unused-media 11 | #Django>=3.0,<4.0 12 | Django==3.2rc1 13 | google-api-python-client 14 | google-auth-httplib2 15 | google-auth-oauthlib 16 | gunicorn 17 | huey 18 | psycopg2 19 | python-dotenv 20 | pyyaml 21 | requests 22 | setproctitle 23 | supervisor 24 | unidecode 25 | watchdog 26 | 27 | # Testing, TODO: move this to separate container 28 | fakeredis 29 | pytest 30 | pytest-cov 31 | pytest-django 32 | requests-mock 33 | -------------------------------------------------------------------------------- /app/app/common/static/common/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/app/webui/templates/webui/form.html: -------------------------------------------------------------------------------- 1 | {% extends 'webui/base.html' %} 2 | 3 | {% block content %} 4 | {% if form %} 5 | {% if form_description %} 6 |

{{ form_description }}

7 | {% endif %} 8 | 9 |
10 | {% csrf_token %} 11 | 12 | {% block form_table %} 13 | {{ form.as_table }} 14 | {% endblock %} 15 |
16 |

17 | 18 | 19 |

20 |
21 | {% endif %} 22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /app/app/common/static/common/admin/js/asset_source.js: -------------------------------------------------------------------------------- 1 | (function($) { 2 | function selectAssetSource() { 3 | var on = $('#id_source').val() 4 | if (on) { 5 | var off = (on == 'file') ? 'url' : 'file' 6 | $('div.form-row.field-' + on).show() 7 | $('#id_' + off).val('') 8 | $('div.form-row.field-' + off).hide() 9 | } 10 | } 11 | 12 | $(function() { 13 | // Detect add page (has a #source_id input) 14 | if ($('#id_source').length) { 15 | selectAssetSource() 16 | $('#id_source').change(selectAssetSource) 17 | } 18 | }) 19 | })(window.jQuery) 20 | -------------------------------------------------------------------------------- /app/app/common/static/common/admin/js/harbor_auth.js: -------------------------------------------------------------------------------- 1 | (function($) { 2 | function showHideGoogleCalenderGracePeriods() { 3 | var harborAuth = $('#id_harbor_auth').val() 4 | var $formRow = $('.form-row.field-gcal_entry_grace_minutes.field-gcal_exit_grace_minutes') 5 | if (harborAuth == 'g') { 6 | $formRow.show() 7 | } else { 8 | $formRow.hide() 9 | } 10 | } 11 | 12 | $(function() { 13 | // Detect add page (has a #source_id input) 14 | showHideGoogleCalenderGracePeriods() 15 | $('#id_harbor_auth').change(showHideGoogleCalenderGracePeriods) 16 | }) 17 | })(window.jQuery) 18 | -------------------------------------------------------------------------------- /app/app/webui/templates/webui/password_set.html: -------------------------------------------------------------------------------- 1 | {% extends 'webui/form.html' %} 2 | 3 | {% block content %} 4 | 5 | {% if cache_token_usable %} 6 |

Welcome to {{ config.STATION_NAME }}! Please {% if newly_created %}set{% else %}change{% endif %} 7 | the password for your{% if newly_created %} new{% endif %} account below.

8 | 9 | {{ block.super }} 10 | {% else %} 11 |
12 | {% csrf_token %} 13 | 14 |

15 | 16 |

17 |
18 | {% endif %} 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /sftp/image/etc/sftpgo.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "idle_timeout": 15, 4 | "upload_mode": 1, 5 | "setstat_mode": 1, 6 | "actions": { 7 | "execute_on": ["rename", "upload"], 8 | "hook": "/usr/local/bin/upload.sh" 9 | } 10 | }, 11 | "sftpd": { 12 | "bindings": [{"port": 2022}], 13 | "max_auth_tries": 0, 14 | "banner": "Crazy Arms Radio Backend SFTP Server", 15 | "host_keys": ["id_rsa"] 16 | }, 17 | "data_provider": { 18 | "track_quota": 0, 19 | "driver": "bolt", 20 | "name": "sftpgo.bolt", 21 | "users_base_dir": "/sftp_root", 22 | "external_auth_hook": "/usr/local/bin/auth.sh", 23 | "external_auth_scope": 0 24 | }, 25 | "httpd": { 26 | "bind_port": 0 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/app/gcal/templates/admin/gcal/gcalshow/change_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/change_list.html' %} 2 | {% load admin_list %} 3 | 4 | {% block object-tools %} 5 | 6 | 12 | 13 |

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 |

19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /app/app/crazyarms/constants.py: -------------------------------------------------------------------------------- 1 | CACHE_KEY_ASSET_TASK_LOG_PREFIX = "asset:task-log:" # + task.id 2 | CACHE_KEY_AUTODJ_CURRENT_STOPSET = "autodj:current-stopset" 3 | CACHE_KEY_AUTODJ_NO_REPEAT_ARTISTS = "autodj:no-repeat-artists" 4 | CACHE_KEY_AUTODJ_NO_REPEAT_IDS = "autodj:no-repeat-ids" 5 | CACHE_KEY_AUTODJ_REQUESTS = "autodj:requests" 6 | CACHE_KEY_AUTODJ_STOPSET_LAST_FINISHED_AT = "autodj:stopset-last-finished-at" 7 | CACHE_KEY_GCAL_LAST_SYNC = "gcal:last-sync" 8 | CACHE_KEY_HARBOR_BAN_PREFIX = "harbor:ban:" # + user.id 9 | CACHE_KEY_HARBOR_CONFIG_CONTEXT = "harbor:config-context" 10 | CACHE_KEY_YTDL_UP2DATE = "youtube-dl:up2date" 11 | CACHE_KEY_SET_PASSWORD_PREFIX = "user:set-password:" 12 | REDIS_KEY_ROOM_INFO = "zoom-runner:room-info" 13 | REDIS_KEY_SERVICE_LOGS = "service:logs" 14 | -------------------------------------------------------------------------------- /app/app/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | target-version = ['py38'] 4 | exclude = '/migrations/' 5 | experimental-string-processing = true 6 | 7 | [tool.isort] 8 | multi_line_output = 3 9 | include_trailing_comma = true 10 | force_grid_wrap = 0 11 | use_parentheses = true 12 | ensure_newline_before_comments = true 13 | line_length = 120 14 | force_sort_within_sections = true 15 | sections = ['FUTURE', 'STDLIB', 'THIRDPARTY', 'DJANGO', 'DJANGOTHIRDPARTY', 'FIRSTPARTY', 'LOCALFOLDER'] 16 | known_django = ['django'] 17 | known_djangothirdparty = ['environ', 'huey.contrib', 'constance', 'dirtyfields', 'django_redis', 'django_select2'] 18 | skip = ['migrations'] 19 | 20 | [tool.pytest.ini_options] 21 | addopts = '-s --cov=. --cov-report=term-missing' 22 | python_files = 'tests.py' 23 | cache_dir = '/tmp/.pytest_cache' 24 | -------------------------------------------------------------------------------- /app/app/webui/tasks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | from django_redis import get_redis_connection 5 | from huey.contrib import djhuey 6 | 7 | from crazyarms import constants 8 | from services.services import ZoomService 9 | 10 | logger = logging.getLogger(f"crazyarms.{__name__}") 11 | 12 | 13 | @djhuey.db_task(priority=5) 14 | def stop_zoom_broadcast(): 15 | logger.info("Stopping Zoom broadcast") 16 | 17 | redis = get_redis_connection() 18 | redis.delete(constants.REDIS_KEY_ROOM_INFO) 19 | # Wait for zoom-runner to cleanly quit room once redis key deleted 20 | # Don't feel good about tying up a Huey thread for 10 seconds, but stopping 21 | # Zoom is a rare enough occurrence that it's okay. 22 | time.sleep(10) 23 | 24 | service = ZoomService() 25 | service.supervisorctl("stop", "zoom", "zoom-runner") 26 | -------------------------------------------------------------------------------- /rtmp/image/usr/local/bin/ffmpeg_rtmp_to_harbor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | . /.env 4 | 5 | on_die () 6 | { 7 | # per nginx-rtmp manual, re: killing ffmpeg 8 | pkill -KILL -P $$ 9 | } 10 | 11 | EXTRA_FFMPEG_ARGS= 12 | 13 | if [ "$DEBUG" -a "$DEBUG" != '0' ]; then 14 | mkdir -p /tmp/ffmpeg-report 15 | cd /tmp/ffmpeg-report 16 | EXTRA_FFMPEG_ARGS='-report' 17 | else 18 | EXTRA_FFMPEG_ARGS='-hide_banner -loglevel warning' 19 | fi 20 | 21 | trap 'on_die' TERM 22 | # Just use a wav container (that'll strip out video) 23 | ffmpeg $EXTRA_FFMPEG_ARGS -re -i "rtmp://localhost:1935/stream/$1" -f wav \ 24 | -content_type 'audio/wav' "icecast://!:$1@harbor:8001/stream" & 25 | wait 26 | 27 | # Disconnect when ffmpeg quits unexpectedly, ie harbor restart, boot, etc 28 | curl "http://127.0.0.1:8080/control/drop/publisher?app=stream&name=$1" 29 | -------------------------------------------------------------------------------- /icecast/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | 3 | ENV ICECAST_KH_VERSION "2.4.0-kh15" 4 | 5 | RUN apk add --no-cache \ 6 | build-base \ 7 | curl \ 8 | libogg-dev \ 9 | libtheora-dev \ 10 | libvorbis-dev \ 11 | libxslt-dev \ 12 | speex-dev 13 | 14 | RUN cd /tmp \ 15 | && curl -sL "https://github.com/karlheyes/icecast-kh/archive/icecast-$ICECAST_KH_VERSION.tar.gz" | tar xzf - \ 16 | && cd "icecast-kh-icecast-$ICECAST_KH_VERSION" \ 17 | && ./configure --prefix=/usr --sysconfdir=/etc --localstatedir=/var \ 18 | && make && make install \ 19 | && cd .. && rm -r "icecast-kh-icecast-$ICECAST_KH_VERSION" \ 20 | && addgroup -S icecast && adduser -HSG icecast icecast 21 | 22 | RUN rm /etc/icecast.xml && ln -s /config/icecast/icecast.xml /etc/icecast.xml 23 | 24 | COPY image/ / 25 | 26 | USER icecast 27 | ENTRYPOINT ["/entrypoint.sh"] 28 | CMD [] 29 | -------------------------------------------------------------------------------- /app/app/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "crazyarms.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | from django.core.management.commands.runserver import Command as runserver 13 | except ImportError as exc: 14 | raise ImportError( 15 | "Couldn't import Django. Are you sure it's installed and " 16 | "available on your PYTHONPATH environment variable? Did you " 17 | "forget to activate a virtual environment?" 18 | ) from exc 19 | 20 | runserver.default_addr = "0.0.0.0" # For Docker 21 | execute_from_command_line(sys.argv) 22 | 23 | 24 | if __name__ == "__main__": 25 | main() 26 | -------------------------------------------------------------------------------- /rtmp/image/etc/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | # Load nchan module 2 | load_module /usr/local/nginx/modules/ngx_rtmp_module.so; 3 | 4 | error_log /var/log/nginx/error.log warn; 5 | pid /var/run/nginx.pid; 6 | 7 | worker_processes auto; 8 | rtmp_auto_push on; 9 | events {} 10 | 11 | rtmp { 12 | access_log /var/log/nginx/access.log; 13 | 14 | server { 15 | listen 1935; 16 | 17 | on_publish http://app:8000/api/validate-stream-key/; 18 | 19 | application stream { 20 | live on; 21 | record off; 22 | exec_push /usr/local/bin/ffmpeg_rtmp_to_harbor.sh $name; 23 | exec_kill_signal term; 24 | } 25 | } 26 | } 27 | 28 | http { 29 | access_log /var/log/nginx/access.log; 30 | 31 | server { 32 | listen 127.0.0.1:8080 default_server; 33 | 34 | location /control { 35 | rtmp_control all; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/app/common/mail.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | from django.contrib import messages 5 | from django.core.mail import send_mail as django_send_mail 6 | 7 | from constance import config 8 | 9 | logger = logging.getLogger(f"crazyarms.{__name__}") 10 | 11 | 12 | def send_mail(recipient, subject, body, request=None): 13 | from_email = f'"{config.STATION_NAME} Admin" ' 14 | 15 | try: 16 | django_send_mail(subject=subject, message=body, from_email=from_email, recipient_list=[recipient]) 17 | except Exception as e: 18 | logger.exception(f'An error occurred while sending mail "{subject}" to {recipient}: {e}') 19 | if request: 20 | messages.error( 21 | request, 22 | "An error occurred while sending email. Message not sent. Try again.", 23 | ) 24 | return False 25 | return True 26 | -------------------------------------------------------------------------------- /app/app/autodj/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.admin.helpers import ActionForm 3 | 4 | from common.forms import AudioAssetCreateFormBase 5 | 6 | from .models import AudioAsset, Playlist, Rotator, RotatorAsset 7 | 8 | 9 | class PlaylistActionForm(ActionForm): 10 | playlist = forms.ModelChoiceField( 11 | Playlist.objects.all(), 12 | required=False, 13 | label=" ", 14 | empty_label="--- Playlist ---", 15 | ) 16 | 17 | 18 | class RotatorActionForm(ActionForm): 19 | rotator = forms.ModelChoiceField(Rotator.objects.all(), required=False, label=" ", empty_label="--- Rotator ---") 20 | 21 | 22 | class AudioAssetCreateForm(AudioAssetCreateFormBase): 23 | class Meta(AudioAssetCreateFormBase.Meta): 24 | model = AudioAsset 25 | 26 | 27 | class RotatorAssetCreateForm(AudioAssetCreateFormBase): 28 | class Meta(AudioAssetCreateFormBase.Meta): 29 | model = RotatorAsset 30 | -------------------------------------------------------------------------------- /nginx/image/docker-entrypoint.d/40-certbot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ME=$(basename $0) 4 | 5 | . /.env 6 | 7 | if [ "$HTTPS_ENABLED" -a "$HTTPS_ENABLED" != '0' ]; then 8 | CERT_PATH="/etc/letsencrypt/live/${DOMAIN_NAME}" 9 | 10 | if [ ! -f "$CERT_PATH/fullchain.pem" -o ! -f "$CERT_PATH/privkey.pem" ]; then 11 | echo >&3 "$ME: certbot hasn't created a certificate yet, so generating a self-signed one" 12 | mkdir -p "$CERT_PATH" 13 | openssl req -x509 -nodes -newkey rsa:2048 -days 1 \ 14 | -keyout "$CERT_PATH/privkey.pem" \ 15 | -out "$CERT_PATH/fullchain.pem" \ 16 | -subj "/C=US/O=Crazy Arms Self-Signed/CN=$DOMAIN_NAME" 17 | fi 18 | 19 | CERTBOT_LOG=/var/log/certbot_daemon.log 20 | echo >&3 "$ME: running certbot daemon (https), log in $CERTBOT_LOG" 21 | nohup certbot-daemon.sh >"$CERTBOT_LOG" 2>&1 & 22 | else 23 | echo >&3 "$ME: https disabled by configuration" 24 | fi 25 | -------------------------------------------------------------------------------- /app/app/services/templatetags/services.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from django import template 4 | 5 | register = template.Library() 6 | 7 | 8 | @register.filter 9 | def liqval(value, comment_string=True): 10 | if isinstance(value, bool): 11 | encoded = str(value).lower() 12 | 13 | elif isinstance(value, float): 14 | encoded = f"{value:.5g}" # 5 decimal places 15 | if "." not in encoded: 16 | encoded += "." 17 | 18 | elif isinstance(value, int): 19 | encoded = value 20 | 21 | else: 22 | if not isinstance(value, str): 23 | value = str(value) 24 | 25 | # Best way to encode a string, since it's not exactly documented escape 26 | # characters properly for liquidsoap. 27 | encoded = base64.b64encode(value.encode("utf-8")).decode("utf-8") 28 | encoded = f'base64.decode("{encoded}")' 29 | if comment_string: 30 | encoded += f" # {value!r}" 31 | 32 | return encoded 33 | -------------------------------------------------------------------------------- /app/app/webui/templates/webui/ban_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'webui/base.html' %} 2 | 3 | {% block content %} 4 | {% if bans %} 5 |
6 | {% csrf_token %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {% for username, user_id, banned_until in bans %} 18 | 19 | 20 | 21 | 22 | 23 | {% endfor %} 24 | 25 |
DJ Ban List
UsernameBanned UntilLift Ban
{{ username }}{{ banned_until }}
26 |
27 | {% else %} 28 |

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 |
35 | {% csrf_token %} 36 | 37 | {# There shouldn't be any errors, but just in case #} 38 | {{ form.errors }} 39 | 40 |
{{ form_with_code }}
41 | 42 |
43 | 44 |
45 |
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 | 40 |

41 | 42 | 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 | ![Zoom Instructions Screenshot #1](../../img/zoom-instructions-1.png) 24 | 25 | 1. In the Meeting Information pop-up, copy the link to the Zoom by clicking 26 | _Copy Link_, shown below. 27 | 28 | ![Zoom Instructions Screenshot #2](../../img/zoom-instructions-2.png) 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('
  • ' + $message.html() 13 | + ' [dismiss]
  • ') 14 | updateMessageContainer() 15 | // Scroll to top 16 | $('html, body').animate({scrollTop: $('.message-container').position().top}, 'slow'); 17 | } 18 | 19 | var audio = new Audio 20 | 21 | $(function() { 22 | $('body').on('click', '.close-message', function(e) { 23 | e.preventDefault() 24 | $(this).closest('li').remove() 25 | updateMessageContainer() 26 | }) 27 | 28 | $('nav a[href="' + window.location.pathname + '"]').each(function(i, elem) { 29 | $(elem).addClass('current-page'); 30 | }); 31 | 32 | updateMessageContainer() 33 | 34 | var stream = new Audio 35 | var isPlaying = false 36 | var playText = $('#play-btn').text() 37 | 38 | $('#play-btn').click(function() { 39 | $(this).toggleClass(['bg-green', 'bg-red']) 40 | if (isPlaying) { 41 | stream.pause() 42 | stream.src = '' 43 | $(this).text(playText) 44 | } else { 45 | stream.src = '/live' 46 | stream.play() 47 | $(this).text('\u25A0 Stop Playing Stream') 48 | } 49 | isPlaying = !isPlaying 50 | }) 51 | 52 | $('.confirm-btn').click(function(event) { 53 | if (!confirm($(this).data('confirm-text'))) { 54 | event.preventDefault() 55 | } 56 | }); 57 | }) 58 | -------------------------------------------------------------------------------- /nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:alpine AS builder 2 | 3 | # TODO: Pin version of nchan 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/slact/nchan/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=../nchan-master" \ 29 | && make && make install 30 | 31 | 32 | FROM nginx:alpine 33 | 34 | COPY --from=builder /usr/lib/nginx/modules/ngx_nchan_module.so /usr/local/nginx/modules/ngx_nchan_module.so 35 | 36 | ENV NOVNC_VERSION "1.2.0" 37 | 38 | RUN apk add --no-cache \ 39 | certbot \ 40 | certbot-nginx \ 41 | openssl \ 42 | py3-pip 43 | 44 | RUN pip install --no-cache-dir j2cli 45 | 46 | RUN curl -sLo /usr/local/bin/wait-for https://raw.githubusercontent.com/eficode/wait-for/master/wait-for \ 47 | curl -sL curl "https://github.com/novnc/noVNC/archive/v${NOVNC_VERSION}.tar.gz" | tar xz -C /tmp \ 48 | && mv "/tmp/noVNC-${NOVNC_VERSION}" /usr/share/noVNC \ 49 | && chmod +x /usr/local/bin/wait-for 50 | 51 | RUN sed -i '1s/^/# Load nchan module\nload_module \/usr\/local\/nginx\/modules\/ngx_nchan_module.so;\n/' /etc/nginx/nginx.conf 52 | 53 | COPY image/ / 54 | -------------------------------------------------------------------------------- /app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 2 | 3 | ENV PYTHONDONTWRITEBYTECODE 1 4 | ENV PYTHONUNBUFFERED 1 5 | 6 | ARG DEBIAN_FRONTEND=noninteractive 7 | RUN apt-get update \ 8 | && apt-get install -y --no-install-recommends \ 9 | build-essential \ 10 | ca-certificates \ 11 | exiftool \ 12 | ffmpeg \ 13 | gnupg \ 14 | wget \ 15 | libpq-dev \ 16 | && echo 'deb http://ppa.launchpad.net/deadsnakes/ppa/ubuntu focal main' > /etc/apt/sources.list.d/deadsnakes.list \ 17 | && apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 6A755776 \ 18 | && apt-get update \ 19 | && apt-get install -y --no-install-recommends \ 20 | python3.9 \ 21 | python3.9-dev \ 22 | python3.9-distutils \ 23 | && rm -rf /var/lib/apt/lists/* 24 | 25 | RUN wget -qO /usr/local/bin/wait-for-it https://raw.githubusercontent.com/vishnubob/wait-for-it/81b1373f/wait-for-it.sh \ 26 | && chmod +x /usr/local/bin/wait-for-it 27 | 28 | RUN ln -s /usr/bin/python3.9 /usr/local/bin/python \ 29 | && ln -s /usr/bin/python3.9 /usr/local/bin/python3 \ 30 | && wget -qO - https://bootstrap.pypa.io/get-pip.py | python3.9 31 | 32 | # # TODO: Remove for initial release / development only 33 | RUN apt-get update \ 34 | && apt-get install -y --no-install-recommends \ 35 | less \ 36 | nano \ 37 | netcat \ 38 | telnet \ 39 | && rm -rf /var/lib/apt/lists/* \ 40 | && pip install --no-cache-dir ipdb ipython remote-pdb 41 | 42 | RUN mkdir /app 43 | WORKDIR /app 44 | COPY app/requirements.txt /app/ 45 | RUN pip install --no-cache-dir -r requirements.txt \ 46 | # TODO: fix for ipython, broken on jedi 0.18.0, remove for initial release / dev only 47 | && pip install --no-cache-dir jedi==0.17.2 48 | 49 | COPY app /app/ 50 | COPY image/ / 51 | 52 | ENTRYPOINT ["/entrypoint.sh"] 53 | CMD [] 54 | -------------------------------------------------------------------------------- /app/app/services/templates/services/icecast.xml: -------------------------------------------------------------------------------- 1 | 2 | {{ config.ICECAST_LOCATION }} 3 | {{ config.ICECAST_ADMIN_EMAIL }} 4 | {{ settings.DOMAIN_NAME }} 5 | 6 | 7 | {% if config.ICECAST_MAX_CLIENTS > 0 %} 8 | {{ config.ICECAST_MAX_CLIENTS }} 9 | {% endif %} 10 | {% if config.ICECAST_MAX_SOURCES > 0%} 11 | {{ ICECAST_MAX_SOURCES }} 12 | {% endif %} 13 | 524288 14 | 30 15 | 15 16 | 10 17 | 65535 18 | 19 | 20 | 21 | {{ config.ICECAST_SOURCE_PASSWORD }} 22 | {{ config.ICECAST_RELAY_PASSWORD }} 23 | 24 | admin 25 | {{ config.ICECAST_ADMIN_PASSWORD }} 26 | 27 | 28 | localhost 29 | 30 | 31 | 8000 32 | 33 | 1 34 | 35 | 36 | 172.* 37 | /usr/share/icecast 38 | /var/log/icecast 39 | /usr/share/icecast/web 40 | /usr/share/icecast/admin 41 | 42 | 43 | 44 | 45 | - 46 | - 47 | 3 48 | 49 | 50 | 51 | 0 52 | 53 | 54 | -------------------------------------------------------------------------------- /.default.env: -------------------------------------------------------------------------------- 1 | # .default.env -- The default configuration for Crazy Arms 2 | # 3 | # Notes: 4 | # * The ./compose.sh script will copy this and fill out some values for you 5 | # on first run. 6 | # * Boolean values must be 0 or 1 7 | # * SECRET_KEY _must_ be set (./compose.sh will set it for you) 8 | # 9 | 10 | # Whether to run in DEBUG mode (Do _NOT_ set this to 1 on production) 11 | DEBUG=0 12 | 13 | # Django secret key -- MUST be set, ./compose.sh will generate one for you 14 | # - Valid characters: Letter, numbers and any of: ! @ # % ^ & * ( - _ = + ) 15 | SECRET_KEY= 16 | 17 | # Default timezone for server (note: users can set their own TZ in the web UI) 18 | TIMEZONE=US/Pacific 19 | 20 | # Domain name used for the web app, ie if it's at http://radio.example.com/, 21 | # enter radio.crazyarm.xyz. 22 | # 23 | # Note: This to match what users type into their web browser or the web app will 24 | # refuse connections. 25 | DOMAIN_NAME=localhost 26 | 27 | # Allow DJs to broadcast via a Zoom room 28 | ZOOM_ENABLED=0 29 | 30 | # Whether or not to run a local Icecast server (kh fork) 31 | ICECAST_ENABLED=1 32 | 33 | # Use letsencrypt to enable HTTPS. The DNS record for DOMAIN_NAME must resolve 34 | # to the IP address of your server. 35 | HTTPS_ENABLED=0 36 | # letencrypt requires an email address if you use it 37 | HTTPS_CERTBOT_EMAIL= 38 | 39 | # Enable email notifications 40 | EMAIL_ENABLED=0 41 | # If you've enabled emails, these must to be specified 42 | EMAIL_SMTP_SERVER=smtp.mystation.com 43 | EMAIL_SMTP_PORT=587 44 | EMAIL_SMTP_USERNAME=no-reply@mystation.com 45 | EMAIL_SMTP_PASSWORD= 46 | EMAIL_SMTP_USE_TLS=1 47 | 48 | # Harbor Telnet Access over Web (experimental, so disabled by default) 49 | HARBOR_TELNET_WEB_ENABLED=0 50 | 51 | # RTMP streaming 52 | RTMP_ENABLED=0 53 | 54 | # Custom port overrides, set these to whatever you like. SFTP could be set to 22 55 | # if you aren't running ssh on your server. 56 | #HARBOR_PORT=8001 57 | #HTTP_PORT=80 58 | #HTTPS_PORT=443 59 | #ICECAST_PORT=8000 60 | #SFTP_PORT=2022 61 | #RTMP_PORT=1935 62 | -------------------------------------------------------------------------------- /app/app/services/management/commands/run_log_subscriber.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import traceback 4 | 5 | import pytz 6 | 7 | from django.conf import settings 8 | from django.core.management.base import BaseCommand 9 | 10 | from django_redis import get_redis_connection 11 | 12 | from crazyarms.constants import REDIS_KEY_SERVICE_LOGS 13 | from services.models import PlayoutLogEntry 14 | 15 | 16 | class Command(BaseCommand): 17 | help = "Consume And Store Logs Messages From Redis" 18 | 19 | def handle(self, *args, **options): 20 | redis = get_redis_connection() 21 | 22 | self.stdout.write(f"Running log subscriber for redis key {REDIS_KEY_SERVICE_LOGS}...") 23 | 24 | while True: 25 | _, data = redis.brpop(REDIS_KEY_SERVICE_LOGS, timeout=0) 26 | 27 | try: 28 | log_entry_kwargs = json.loads(data) 29 | if not isinstance(log_entry_kwargs, dict): 30 | raise ValueError 31 | 32 | except ValueError: 33 | self.stderr.write(f"Error decoding JSON in data: {data.decode()!r}") 34 | 35 | else: 36 | if settings.DEBUG: 37 | self.stdout.write(f"Got message: {json.dumps(log_entry_kwargs, indent=2, sort_keys=True)}") 38 | 39 | if "created" in log_entry_kwargs: 40 | try: 41 | log_entry_kwargs["created"] = datetime.datetime.utcfromtimestamp( 42 | float(log_entry_kwargs["created"]) 43 | ).replace(tzinfo=pytz.utc) 44 | except ValueError: 45 | pass 46 | 47 | try: 48 | log_entry = PlayoutLogEntry(**log_entry_kwargs) 49 | log_entry.full_clean() 50 | log_entry.save() 51 | self.stdout.write(f"Wrote log entry: {log_entry}") 52 | 53 | except Exception: 54 | self.stderr.write(f"Uncaught exception creating log entry with kwargs: {log_entry_kwargs!r}:") 55 | self.stderr.write(traceback.format_exc()) 56 | -------------------------------------------------------------------------------- /app/app/services/templates/services/library.liq: -------------------------------------------------------------------------------- 1 | {% load services %} 2 | 3 | set('server.telnet.bind_addr', '0.0.0.0') 4 | set('server.timeout', 15.) 5 | 6 | # Things common to harbor.liq and upstream.liq 7 | REDIS_KEY_SERVICE_LOGS = {{ REDIS_KEY_SERVICE_LOGS|liqval }} 8 | CRAZYARMS_VERSION = getenv('CRAZYARMS_VERSION') 9 | BUFFER = 5. 10 | MAX = 10. 11 | 12 | {% for var, value in event_types %} 13 | EVENT_{{ var }} = {{ value|liqval }} 14 | {% endfor %} 15 | 16 | def safe_quote(s) 17 | # Needed because of https://github.com/savonet/liquidsoap/issues/1215 18 | if string.match(pattern='[^\\w@%+=:,./-]', s) then 19 | "'" ^ string.replace(pattern="'", fun(_) -> "'\"'\"'", s) ^ "'" 20 | else 21 | s 22 | end 23 | end 24 | 25 | is_shutting_down = ref false 26 | current_source_name = ref 'N/A' 27 | def log_event(~extras=[], ~type=EVENT_GENERAL, ~async=true, description) 28 | if not !is_shutting_down then 29 | def log_event_func() 30 | log('Logging playout event (#{type}): #{description}') 31 | 32 | # Add description, active source and default event type 33 | log_entry = json_of(list.append([('description', description), ('active_source', !current_source_name), 34 | ('event_type', type), ('created', string_of(time()))], extras)) 35 | 36 | cmd = 'redis-cli -h redis LPUSH #{safe_quote(REDIS_KEY_SERVICE_LOGS)} #{safe_quote(log_entry)}' 37 | if not test_process(cmd) then 38 | log("ERROR: couldn't log playout event with command: #{cmd}") 39 | end 40 | end 41 | if async then 42 | add_timeout(fast=false, 0., fun() -> begin 43 | log_event_func() 44 | -1. 45 | end) 46 | else 47 | log_event_func() 48 | end 49 | end 50 | end 51 | 52 | # Register health check ping/pong 53 | harbor.http.register(port=HEALTHCHECK_PORT, method='GET', '^/ping$', fun(~protocol, ~data, ~headers, uri) -> begin 54 | http_response(protocol=protocol, code=200, headers=[('Content-Type', 'text/plain')], data='pong') 55 | end) 56 | 57 | on_shutdown(fun() -> begin 58 | log_event(async=false, '#{SCRIPT_NAME} is shutting down') 59 | is_shutting_down := true 60 | end) 61 | 62 | log_event(async=false, '#{SCRIPT_NAME} came online (version: #{CRAZYARMS_VERSION})') 63 | -------------------------------------------------------------------------------- /docs/about/author-miscellany.md: -------------------------------------------------------------------------------- 1 | # About the Author and Other Miscellany 2 | 3 | 4 | ## David Cooper 5 | 6 | Crazy Arms Radio Back is written by [David Cooper](https://dtcooper.com). :eyeglasses: 7 | 8 | David is a comedian :rolling_on_the_floor_laughing:, radio personality :radio:, 9 | and podcaster :studio_microphone:. His background in engineering is from having formerly been 10 | a software engineer :scientist: at [Eventbrite](https://www.eventbrite.com/) :ticket: 11 | and [Autodesk](https://www.autodesk.com/) :triangular_ruler:. 12 | 13 | He's staff and on-air talent at [BMIR 94.5 FM](https://en.wikipedia.org/wiki/BMIR) 14 | (Burning Man Information Radio) :fire::man_singer:, has been on 15 | [KALW 91.7 FM](https://www.kalw.org/) (an [NPR](https://www.npr.org/) affiliate) 16 | and a number of stations and programs in the San Francisco Bay Area :bridge_at_night:. 17 | He runs an _entirely inappropriate_ :underage: weekly, wacky 18 | :clown_face: call-in :telephone: radio show at a coffee shop :coffee: 19 | window in San Francisco described below. 20 | 21 | ![David Cooper](../img/david.jpg){: style="min-width: 350px; width:30%"} 22 | ![This Is Going Well, I think with David Cooper](../img/tigwit.jpg){: style="min-width: 350px; width:30%"} 23 | 24 | !!! danger ":underage: :underage: :underage: NSFW Art Ahead :underage: :underage: :underage:" 25 | We'd love it if you'd listen to David's weekly comedy show _This Is Going 26 | Well, I Think,_ however **be warned: it's very, very NSFW.** Find out more at [www.jew.pizza](https://www.jew.pizza). 27 | _(And yes, that domain is real.)_ 28 | 29 | :heart: :heart: :heart: :star_of_david: :pizza: 30 | 31 | ## Crazy Arms In The Wild 32 | 33 | David has deployed Crazy Arms to the following online stations, 34 | 35 | * [Shouting Fire](https://shoutingfire.com) 36 | * [KTLC The Lost Church Radio](https://www.thelostchurch.com) 37 | * [820hz](https://820hz.fm) 38 | * [BMIR 94.5 FM](https://bmir.org) for their 2020 online-only broadcast 39 | 40 | 41 | ## Donations 42 | 43 | There's about a hundred million other charities that would be a better use of 44 | your money, so donate to one of those! The [ACLU](https://www.aclu.org/) or 45 | the [EFF](https://www.eff.org/) could always use a hand. If you still want to 46 | donate against your better judgment, David's Bitcoin address is as follows, 47 | 48 | ``` 49 | 1PoDcAStyJoB7zZz2mny4KjtjiEu8S44ns 50 | ``` 51 | 52 | 53 | ## Final Note 54 | 55 | _...and remember kids, have fun!_ 56 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | dev_addr: 127.0.0.1:8888 # Not used by docker-compose 2 | edit_uri: "" 3 | markdown_extensions: 4 | - admonition 5 | - attr_list 6 | - def_list 7 | - footnotes 8 | - pymdownx.details 9 | - pymdownx.emoji: 10 | emoji_index: !!python/name:materialx.emoji.twemoji 11 | emoji_generator: !!python/name:materialx.emoji.to_svg 12 | - pymdownx.superfences: 13 | # make exceptions to highlighting of code: 14 | custom_fences: 15 | - name: mermaid 16 | class: mermaid 17 | format: !!python/name:mermaid2.fence_mermaid 18 | - pymdownx.keys 19 | - pymdownx.smartsymbols 20 | - pymdownx.tabbed 21 | - smarty 22 | - toc: 23 | permalink: True 24 | nav: 25 | - Home: index.md 26 | - User's Guide: 27 | - AutoDJ: users-guide/autodj.md 28 | - Prerecorded Broadcasts: users-guide/prerecorded-broadcasts.md 29 | - Live DJing: 30 | - Icecast 2: users-guide/dj/icecast.md 31 | - Zoom: users-guide/dj/zoom.md 32 | - RTMP: users-guide/dj/rtmp.md 33 | - Admin's Guide: 34 | - Configuration: admin-guide/configuration.md 35 | - AutoDJ: admin-guide/autodj.md 36 | - User Permissions: admin-guide/permissions.md 37 | - Google Calendar Scheduling: admin-guide/google-calendar.md 38 | - Server Setup: server-setup.md 39 | - Glossary of Terms: glossary.md 40 | - About & Support: 41 | - Issues & Support: about/support.md 42 | - License: about/license.md 43 | - Changelog: about/changelog.md 44 | - Author & Miscellany: about/author-miscellany.md 45 | plugins: 46 | - macros: 47 | module_name: docs_macros 48 | - mermaid2: 49 | arguments: 50 | theme: default 51 | - search 52 | repo_name: dtcooper/crazyarms 53 | repo_url: https://github.com/dtcooper/crazyarms 54 | site_dir: _docs 55 | site_name: Crazy Arms Radio Backend Documentation 56 | site_url: https://dtcooper.github.io/crazyarms 57 | theme: 58 | favicon: img/favicon.svg 59 | name: material 60 | features: 61 | - navigation.instant 62 | - navigation.sections 63 | - navigation.tabs 64 | - toc.integrate 65 | palette: 66 | primary: black 67 | accent: light blue 68 | font: 69 | text: Inter 70 | icon: 71 | repo: fontawesome/brands/github 72 | copyright: Copyright © 2020-2021 David Cooper 73 | -------------------------------------------------------------------------------- /docs_macros.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest.mock import Mock 3 | 4 | _django_settings = None 5 | 6 | 7 | def get_django_settings(): 8 | global _django_settings 9 | if _django_settings is None: 10 | sys.path.append("app/app") 11 | # Mock out imports 12 | sys.modules.update( 13 | { 14 | "django.utils.safestring": Mock(mark_safe=lambda s: s), 15 | "environ": Mock(Env=lambda: Mock(bool=lambda *args, **kwargs: True)), 16 | } 17 | ) 18 | from crazyarms import settings 19 | 20 | _django_settings = settings 21 | return _django_settings 22 | 23 | 24 | def get_constance_config_type(default, type_hint=None): 25 | types_to_string = { 26 | int: "Integer", 27 | float: "Decimal", 28 | bool: "Boolean (true or false)", 29 | str: "String", 30 | } 31 | type_hints_to_string = { 32 | "clearable_file": "File", 33 | "email": "Email Address", 34 | "nonzero_positive_int": "Integer (non-zero)", 35 | "positive_float": "Decimal (non-negative)", 36 | "positive_int": "Integer (non-negative)", 37 | "required_char": "String (required)", 38 | "station_name": "String (required, 40 characters max)", 39 | "zoom_minutes": "Integer (number of minutes)", 40 | } 41 | 42 | settings = get_django_settings() 43 | for choice_field in ( 44 | "asset_bitrate_choices", 45 | "asset_encoding_choices", 46 | "autodj_requests_choices", 47 | ): 48 | choices = settings.CONSTANCE_ADDITIONAL_FIELDS[choice_field][1]["choices"] 49 | type_hints_to_string[ 50 | choice_field 51 | ] = f'Choice of: {", ".join(choice for _, choice in choices)}' 52 | 53 | if type_hint is None or type_hint not in type_hints_to_string: 54 | return types_to_string[type(default)] 55 | else: 56 | return type_hints_to_string[type_hint] 57 | 58 | 59 | def get_constance_config_default(name, default): 60 | if name == 'ICECAST_ADMIN_EMAIL': 61 | default = 'admin@mystation.com' 62 | return repr(default) 63 | 64 | 65 | def define_env(env): 66 | env.variables["DJANGO_SETTINGS"] = get_django_settings() 67 | with open("LICENSE", "r") as license: 68 | env.variables["LICENSE"] = license.read() 69 | with open(".default.env", "r") as default_env: 70 | env.variables["DEFAULT_ENV"] = default_env.read() 71 | env.macro(get_constance_config_type) 72 | env.macro(get_constance_config_default) 73 | -------------------------------------------------------------------------------- /app/app/crazyarms/templates/admin/app_list.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% if app_list %} 4 | {% for app in app_list %} 5 |
    6 | 7 | 10 | {% for model in app.models %} 11 | 12 | {% if model.admin_url %} 13 | 14 | {% else %} 15 | 16 | {% endif %} 17 | 18 | {% if model.add_url %} 19 | 20 | {% else %} 21 | 22 | {% endif %} 23 | 24 | {% if model.admin_url and show_changelinks %} 25 | {% if model.view_only %} 26 | 27 | {% else %} 28 | 29 | {% endif %} 30 | {% elif show_changelinks %} 31 | 32 | {% endif %} 33 | 34 | {% endfor %} 35 |
    8 | {{ app.name }} 9 |
    {{ model.name }}{{ model.name }}{% translate 'Add' %}{% translate 'View' %}{% translate 'Change' %}
    36 |
    37 | {% endfor %} 38 | {% endif %} 39 | {% if not app_list and not extra_urls %} 40 |

    {% translate 'You don’t have permission to view or edit anything.' %}

    41 | {% endif %} 42 | 43 | {% if extra_urls %} 44 |
    45 | 46 | 49 | {% for title, url, is_external in extra_urls %} 50 | 51 | 52 | 53 | 54 | 55 | {% endfor %} 56 |
    47 | Miscellaneous Configuration 48 |
    {{ title }}
    57 |
    58 | {% endif %} 59 | -------------------------------------------------------------------------------- /app/app/services/tasks.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | 4 | from huey import crontab 5 | import requests 6 | 7 | from django.conf import settings 8 | from django.utils import timezone 9 | 10 | from constance import config 11 | from huey.contrib import djhuey 12 | 13 | from common.models import User 14 | from common.tasks import local_daily_task 15 | 16 | from .models import PlayoutLogEntry, UpstreamServer 17 | from .services import init_services 18 | 19 | logger = logging.getLogger(f"crazyarms.{__name__}") 20 | 21 | 22 | LIQUIDSOAP_HEALTHCHECK_TIMEOUT = 5 # in seconds 23 | 24 | 25 | @djhuey.periodic_task(priority=1, validate_datetime=local_daily_task(hour=3, minute=30)) # daily @ 3:30am local time 26 | def purge_playout_log_entries(): 27 | if config.PLAYOUT_LOG_PURGE_DAYS > 0: 28 | purge_less_than_datetime = timezone.now() - datetime.timedelta(days=config.PLAYOUT_LOG_PURGE_DAYS) 29 | num_deleted, _ = PlayoutLogEntry.objects.filter(created__lt=purge_less_than_datetime).delete() 30 | logger.info(f"purged {num_deleted} playout log entries {config.PLAYOUT_LOG_PURGE_DAYS} days or older.") 31 | else: 32 | logger.info("keeping playout log entries due to configuration (PLAYOUT_LOG_PURGE_DAYS <= 0)") 33 | 34 | 35 | @djhuey.periodic_task(crontab(minute="*/2")) 36 | def liquidsoap_services_watchdog(force=False): 37 | if User.objects.exists(): 38 | services_to_check = [("harbor", "harbor", 8001)] 39 | services_to_check.extend(("upstream", u.name, u.healthcheck_port) for u in UpstreamServer.objects.all()) 40 | 41 | if force or not settings.DEBUG: 42 | for service, subservice, port in services_to_check: 43 | response = None 44 | 45 | try: 46 | response = requests.get(f"http://{service}:{port}/ping", timeout=LIQUIDSOAP_HEALTHCHECK_TIMEOUT) 47 | except Exception: 48 | logger.exception(f"{service}:{subservice} healthcheck threw exception") 49 | 50 | if response and response.status_code == 200 and response.text == "pong": 51 | logger.info(f"{service}:{subservice} healthcheck passed") 52 | else: 53 | logger.info(f"{service}:{subservice} healthcheck failed. Restarting.") 54 | init_services(services=service, subservices=subservice) 55 | else: 56 | logger.info("Liquidsoap services healthcheck disabled in DEBUG mode") 57 | else: 58 | logger.info("Liquidsoap services health check won't run when no user exists (pre-first run)") 59 | -------------------------------------------------------------------------------- /app/app/broadcast/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | from django.utils.formats import date_format 4 | 5 | from huey.contrib.djhuey import revoke_by_id 6 | 7 | from common.models import AudioAssetBase, TimestampedModel, User, after_db_commit 8 | 9 | 10 | class BroadcastAsset(AudioAssetBase): 11 | UNNAMED_TRACK = "Unnamed Broadcast" 12 | UPLOAD_DIR = "broadcasts" 13 | 14 | class Meta: 15 | verbose_name = "scheduled broadcast asset" 16 | verbose_name_plural = "scheduled broadcast assets" 17 | ordering = ("title", "id") 18 | 19 | 20 | class Broadcast(TimestampedModel): 21 | class Status(models.TextChoices): 22 | PENDING = "-", "to be queued" 23 | QUEUED = "q", "queued for play" 24 | PLAYED = "p", "played" 25 | FAILED = "f", "failed to play" 26 | 27 | creator = models.ForeignKey(User, verbose_name="creator", on_delete=models.SET_NULL, null=True) 28 | asset = models.ForeignKey( 29 | BroadcastAsset, 30 | verbose_name="broadcast asset", 31 | related_name="broadcasts", 32 | on_delete=models.CASCADE, 33 | ) 34 | scheduled_time = models.DateTimeField() 35 | status = models.CharField(max_length=1, choices=Status.choices, default=Status.PENDING) 36 | task_id = models.UUIDField(null=True) 37 | 38 | def __init__(self, *args, **kwargs): 39 | self.queue_after_save = False 40 | super().__init__(*args, **kwargs) 41 | 42 | def __str__(self): 43 | scheduled_time = date_format(timezone.localtime(self.scheduled_time), "SHORT_DATETIME_FORMAT") 44 | return f"{self.asset} ({self.get_status_display()} at {scheduled_time})" 45 | 46 | class Meta: 47 | ordering = ("-scheduled_time",) 48 | verbose_name = "scheduled broadcast" 49 | verbose_name_plural = "scheduled broadcasts" 50 | 51 | @after_db_commit 52 | def queue(self): 53 | from .tasks import play_broadcast 54 | 55 | task = play_broadcast.schedule(args=(self,), eta=self.scheduled_time) 56 | Broadcast.objects.filter(id=self.id).update(task_id=task.id) 57 | 58 | def clean(self): 59 | if self.status == self.Status.PENDING: 60 | self.status = self.Status.QUEUED 61 | self.queue_after_save = True 62 | 63 | def save(self, *args, **kwargs): 64 | super().save(*args, **kwargs) 65 | if self.queue_after_save: 66 | self.queue() 67 | 68 | def delete(self, *args, **kwargs): 69 | if self.task_id: 70 | revoke_by_id(self.task_id) 71 | return super().delete(*args, **kwargs) 72 | -------------------------------------------------------------------------------- /app/app/gcal/admin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.contrib import admin, messages 4 | from django.core.cache import cache 5 | from django.core.exceptions import PermissionDenied 6 | from django.shortcuts import redirect 7 | from django.urls import path 8 | from django.utils import timezone 9 | 10 | from constance import config 11 | 12 | from crazyarms import constants 13 | 14 | from .models import GCalShow 15 | from .tasks import sync_gcal_api 16 | 17 | logger = logging.getLogger(f"crazyarms.{__name__}") 18 | 19 | 20 | class GCalShowAdmin(admin.ModelAdmin): 21 | save_on_top = True 22 | search_fields = ("title",) 23 | fields = ("title_non_blank", "users", "start", "end") 24 | list_display = ("title_non_blank", "start", "end") 25 | date_hierarchy = "start" 26 | list_filter = (("users", admin.RelatedOnlyFieldListFilter),) 27 | 28 | def get_urls(self): 29 | return [ 30 | path("sync/", self.admin_site.admin_view(self.sync_view), name="gcal_gcalshow_sync") 31 | ] + super().get_urls() 32 | 33 | def title_non_blank(self, obj): 34 | return obj.title or "Untitled Event" 35 | 36 | title_non_blank.short_description = "Title" 37 | title_non_blank.admin_order_field = ("title",) 38 | 39 | def sync_view(self, request): 40 | if not self.has_view_permission(request): 41 | raise PermissionDenied 42 | 43 | cache.set(constants.CACHE_KEY_GCAL_LAST_SYNC, "currently running", timeout=None) 44 | sync_gcal_api() 45 | messages.info(request, "Google Calendar is currently being sync'd. Please refresh this page in a few moments.") 46 | return redirect("admin:gcal_gcalshow_changelist") 47 | 48 | def changelist_view(self, request, extra_context=None): 49 | now = timezone.now() 50 | extra_context = { 51 | "last_sync": GCalShow.get_last_sync(), 52 | "SYNC_RANGE_MIN_DAYS": GCalShow.SYNC_RANGE_MIN.days, 53 | "SYNC_RANGE_MAX_DAYS": GCalShow.SYNC_RANGE_MAX.days, 54 | "sync_range_start": now - GCalShow.SYNC_RANGE_MIN, 55 | "sync_range_end": now + GCalShow.SYNC_RANGE_MAX, 56 | **(extra_context or {}), 57 | } 58 | return super().changelist_view(request, extra_context=extra_context) 59 | 60 | def has_add_permission(self, request): 61 | return False 62 | 63 | def has_delete_permission(self, request, obj=None): 64 | return False 65 | 66 | def has_change_permission(self, request, obj=None): 67 | return False 68 | 69 | def has_view_permission(self, request, obj=None): 70 | return config.GOOGLE_CALENDAR_ENABLED 71 | 72 | 73 | admin.site.register(GCalShow, GCalShowAdmin) 74 | -------------------------------------------------------------------------------- /app/app/webui/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth import views as auth_views 3 | from django.urls import path, re_path 4 | 5 | from . import views 6 | 7 | urlpatterns = [ 8 | re_path("^(?:|status/)$", views.StatusView.as_view(), name="status"), 9 | path("banlist/", views.BanListView.as_view(), name="banlist"), 10 | path("change-password/", views.PasswordChangeView.as_view(), name="password_change"), 11 | path("first-run/", views.FirstRunView.as_view(), name="first_run"), 12 | path( 13 | "login/", 14 | auth_views.LoginView.as_view( 15 | extra_context={ 16 | "hide_login_link": True, 17 | "title": "Login", 18 | "submit_text": "Login", 19 | }, 20 | redirect_authenticated_user=True, 21 | template_name="webui/login.html", 22 | ), 23 | name="login", 24 | ), 25 | path("logout/", auth_views.LogoutView.as_view(), name="logout"), 26 | path("info/", views.InfoView.as_view(), name="info"), 27 | path( 28 | "set-password//", 29 | views.SetPasswordByEmailView.as_view(), 30 | name="password_set_by_email", 31 | ), 32 | path("playout-log/", views.PlayoutLogView.as_view(), name="playout_log"), 33 | path("profile/", views.UserProfileView.as_view(), name="profile"), 34 | path( 35 | "profile/email//", 36 | views.UserProfileEmailUpdateView.as_view(), 37 | name="profile_email_update", 38 | ), 39 | path("scheduled-shows/", views.GCalView.as_view(), name="gcal"), 40 | path( 41 | "status/autodj-request/", 42 | views.AutoDJRequestAJAXFormView.as_view(), 43 | name="autodj_request", 44 | ), 45 | path( 46 | "status/autodj-request/choices/", 47 | views.AutoDJRequestChoicesView.as_view(), 48 | name="autodj_request_choices", 49 | ), 50 | path("status/boot/", views.BootView.as_view(), name="boot"), 51 | path("status/skip/", views.SkipView.as_view(), name="skip"), 52 | path("zoom/", views.ZoomView.as_view(), name="zoom"), 53 | re_path( 54 | "^(?Plogs|websockify|telnet|sse)", 55 | views.nginx_protected, 56 | name="nginx_protected", 57 | ), 58 | ] 59 | 60 | if settings.EMAIL_ENABLED: 61 | urlpatterns += [ 62 | path( 63 | "password-reset/", 64 | views.PasswordResetView.as_view(), 65 | name="admin_password_reset", 66 | ), 67 | path( 68 | "password-reset///", 69 | views.PasswordResetConfirmView.as_view(), 70 | name="password_reset_confirm", 71 | ), 72 | ] 73 | -------------------------------------------------------------------------------- /app/app/webui/static/webui/css/base.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* From https://materializecss.com/color.html */ 3 | --nc-lk-1: #2196f3; /* blue */ 4 | --nc-lk-2: #26c6da; /* cyan lighten-1 */ 5 | --nc-bg-2: #fafafa; /* grey lighten-5 */ 6 | --nc-bg-3: #e0e0e0; /* grey lighten-2 */ 7 | --mt-orange: #ff9800; 8 | --mt-orange-darken-2: #f57c00; 9 | --mt-red: #f44336; 10 | --mt-green: #4caf50; 11 | --mt-grey: #9e9e9e; 12 | --mt-grey-darken-1: #757575; 13 | } 14 | 15 | input::placeholder { 16 | color: var(--mt-grey-lighten-1); 17 | font-style: italic; 18 | opacity: 1; 19 | } 20 | 21 | body { 22 | max-width: 1200px; 23 | padding: 0.5rem; 24 | } 25 | 26 | body.show-debug-warning { 27 | padding-top: 2rem; 28 | } 29 | 30 | #debug-warning { 31 | position: fixed; 32 | right: 0; 33 | left: 0; 34 | top: 0; 35 | z-index: 999; 36 | height: 1.5rem; 37 | text-align: center; 38 | background-color: var(--mt-red); 39 | font-weight: bold; 40 | color: #ffffff; 41 | } 42 | 43 | * { hyphens: auto; } 44 | code, pre { hyphens: none; } 45 | 46 | .button:focus, 47 | .button:enabled:hover, 48 | button:focus, 49 | button:enabled:hover, 50 | input[type="submit"]:focus, 51 | input[type="submit"]:enabled:hover, 52 | input[type="reset"]:focus, 53 | input[type="reset"]:enabled:hover, 54 | input[type="button"]:focus, 55 | input[type="button"]:enabled:hover { 56 | filter: brightness(92%); 57 | } 58 | 59 | input[type=text], input[type=password], input[type=email], textarea { width: 100%; } 60 | 61 | .hidden { display: none; } 62 | 63 | .warning, .orange { color: var(--mt-orange-darken-2); } 64 | .bg-orange { background-color: var(--mt-orange); } 65 | .error, .red, .errorlist { color: var(--mt-red); } 66 | button[type=reset], .bg-red { background-color: var(--mt-red); } 67 | .error, .bold { font-weight: bold; } 68 | .success, .green { color: var(--mt-green); } 69 | .bg-green { background-color: var(--mt-green); } 70 | .white { color: #ffffff; } 71 | 72 | .center-text { text-align: center; } 73 | .right-text, 74 | .first-td-right td:first-child:not(:only-child), 75 | .first-td-right th:first-child:not(:only-child) { 76 | text-align: right; 77 | } 78 | .form-table th { width: 25%; } 79 | table caption { text-decoration: underline; } 80 | 81 | .boot-btn { 82 | margin: 2px 0; 83 | white-space: normal; 84 | } 85 | 86 | th { text-align: center; } 87 | 88 | .current-page { 89 | cursor: default; 90 | text-decoration: none; 91 | color: var(--mt-grey-darken-1); 92 | } 93 | .current-page:hover { text-decoration: underline; } 94 | 95 | img { 96 | margin: 10px; 97 | } 98 | 99 | /* django_select2 */ 100 | .autodj-requests-table select, .autodj-requests-table input { 101 | width: 100%; 102 | } 103 | -------------------------------------------------------------------------------- /app/app/webui/templates/webui/playout_log.html: -------------------------------------------------------------------------------- 1 | {% extends 'webui/base.html' %} 2 | 3 | {% block content %} 4 | 5 |

    6 | Below is a 7 | {% if perms.services.view_playoutlogentry %}simplified{% endif %} 8 | view of 9 | {% if object_list.count == MAX_ENTRIES %} 10 | {% if perms.services.view_playoutlogentry %}only{% endif %} 11 | the latest {{ MAX_ENTRIES }} 12 | {% else %} 13 | all {{ object_list.count }} 14 | {% endif %} 15 | playout log entr{{ object_list.count|pluralize:"y,ies" }}. 16 | Refresh this page to get the most up-to-date data. 17 | {% if perms.services.view_playoutlogentry %} 18 |
    19 | 20 | (For a more advanced view, head over to the 21 | 22 | Station Admin Site.) 23 | 24 | {% endif %} 25 |

    26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {% for playout_log_entry in object_list %} 39 | 40 | 41 | 42 | 43 | 66 | 67 | 68 | {% endfor %} 69 | 70 |
    Playout Log
    #Time ({{ user.timezone }})Event TypeDescriptionActive Source
    {{ forloop.counter }}{{ playout_log_entry.created|date:'SHORT_DATETIME_FORMAT' }}{{ playout_log_entry.get_event_type_display }} 44 | {# TODO: add playlist to logs? #} 45 | 46 | {# audio assets #} 47 | {% if config.AUTODJ_ENABLED and playout_log_entry.audio_asset_id and perms.playout.change_audioasset %} 48 | {{ playout_log_entry.description }} 49 | 50 | {# rotator assets #} 51 | {% elif config.AUTODJ_ENABLED and config.AUTODJ_STOPSETS_ENABLED and playout_log_entry.rotator_asset_id and perms.playout.change_audioasset %} 52 | {{ playout_log_entry.description }} 53 | 54 | {# broadcast assets #} 55 | {% elif playout_log_entry.broadcast_asset_id and perms.broadcast.change_broadcast %} 56 | {{ playout_log_entry.description }} 57 | 58 | {# user related #} 59 | {% elif user.is_superuser and playout_log_entry.user_id %} 60 | {{ playout_log_entry.description }} 61 | 62 | {% else%} 63 | {{ playout_log_entry.description }} 64 | {% endif %} 65 | {{ playout_log_entry.active_source }}
    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 |

    22 | {% csrf_token %} 23 | 28 |
    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 |

    76 | Errors: 77 | {% for errors in form.errors.values %} 78 | {{ errors }} 79 | {% endfor %} 80 |

    81 | {% endif %} 82 | {% endif %} 83 | 84 | {% endblock %} 85 | -------------------------------------------------------------------------------- /app/app/webui/templates/webui/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% load static %} 5 | 6 | 7 | {% block title %}{% if title %}{{ title }} - {% endif %}{{ station_name_override|default:config.STATION_NAME }}{% endblock %} 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% block extrahead %}{% endblock %} 15 | 16 | 17 | {% if settings.DEBUG %} 18 |
    Warning: running in DEBUG mode.
    19 | {% endif %} 20 | {% block header %} 21 |
    22 |

    23 | {{ station_name_override|default:config.STATION_NAME }} 24 | {% if title %} 25 |
    {{ title }} 26 | {% endif %} 27 |

    28 | {% if not hide_nav %} 29 | 59 | {% endif %} 60 |
    61 | {% endblock %} 62 | 63 | 75 | 76 |
    77 | {% block content %}{% if simple_content %}

    {{ simple_content }}

    {% endif %}{% endblock %} 78 |
    79 | 80 |
    81 | 82 |
    83 |

    84 | 85 | {% now 'Y' as year %} 86 | Crazy Arms Radio Backend 87 | — version {{ crazyarms_version }}
    88 | © 2020{% if year > '2020' %}-{{ year }}{% endif %} David Cooper 89 |
    90 |

    91 |
    92 | 93 | 94 | -------------------------------------------------------------------------------- /app/app/broadcast/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2b1 on 2021-03-22 16:59 2 | 3 | import common.models 4 | import datetime 5 | import dirtyfields.dirtyfields 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | import django.db.models.deletion 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [ 16 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='BroadcastAsset', 22 | fields=[ 23 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 24 | ('created', models.DateTimeField(auto_now_add=True, verbose_name='created')), 25 | ('modified', models.DateTimeField(auto_now=True, verbose_name='last modified')), 26 | ('title', common.models.TruncatingCharField(blank=True, db_index=True, help_text="If left empty, a title will be generated from the file's metadata.", max_length=255, verbose_name='title')), 27 | ('file_basename', models.CharField(max_length=512)), 28 | ('file', models.FileField(blank=True, help_text='You can provide either an uploaded audio file or a URL to an external asset.', max_length=512, upload_to=common.models.audio_asset_file_upload_to, verbose_name='audio file')), 29 | ('duration', models.DurationField(default=datetime.timedelta(0), verbose_name='Audio duration')), 30 | ('fingerprint', models.UUIDField(db_index=True, null=True)), 31 | ('status', models.CharField(choices=[('-', 'processing queued'), ('p', 'processing'), ('f', 'processing failed'), ('r', 'ready for play')], db_index=True, default='-', help_text='You will be able to edit this asset when status is "ready for play."', max_length=1, verbose_name='status')), 32 | ('task_id', models.UUIDField(null=True)), 33 | ('uploader', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='uploader')), 34 | ], 35 | options={ 36 | 'verbose_name': 'scheduled broadcast asset', 37 | 'verbose_name_plural': 'scheduled broadcast assets', 38 | 'ordering': ('title', 'id'), 39 | }, 40 | bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model), 41 | ), 42 | migrations.CreateModel( 43 | name='Broadcast', 44 | fields=[ 45 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 46 | ('created', models.DateTimeField(auto_now_add=True, verbose_name='created')), 47 | ('modified', models.DateTimeField(auto_now=True, verbose_name='last modified')), 48 | ('scheduled_time', models.DateTimeField()), 49 | ('status', models.CharField(choices=[('-', 'to be queued'), ('q', 'queued for play'), ('p', 'played'), ('f', 'failed to play')], default='-', max_length=1)), 50 | ('task_id', models.UUIDField(null=True)), 51 | ('asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='broadcasts', to='broadcast.broadcastasset', verbose_name='broadcast asset')), 52 | ('creator', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='creator')), 53 | ], 54 | options={ 55 | 'verbose_name': 'scheduled broadcast', 56 | 'verbose_name_plural': 'scheduled broadcasts', 57 | 'ordering': ('-scheduled_time',), 58 | }, 59 | ), 60 | ] 61 | -------------------------------------------------------------------------------- /app/app/webui/templates/webui/info.html: -------------------------------------------------------------------------------- 1 | {% extends 'webui/base.html' %} 2 | 3 | {% block content %} 4 | {% with host=request.get_host %} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 21 | 22 |
    Harbor Live DJ Access (Icecast 2)
    Server Address (Hostname):{{ host }}
    Type:Icecast 2 (not SHOUTCast, sometimes the "2" is omitted)
    Port:{{ settings.HARBOR_PORT }}
    Mountpoint:stream
    Username:{{ user.username }}
    Password:[your-password] (replace with your password, don't include square brackets)
    18 | Older clients may only allow you to enter a password. In that case, use: 19 | {{ user.username}}:[your-password] 20 |
    23 | 24 | {% if settings.RTMP_ENABLED %} 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 37 | 38 |
    Harbor Live DJ Access (RTMP)
    Server Address:rtmp://{{ host }}{% if settings.RTMP_PORT != 1935 %}:{{ settings.RTMP_PORT }}{% endif %}/stream
    Stream Key: 34 | {{ user.stream_key }}
    35 | (Generate a new on on your Profile page) 36 |
    39 | {% endif %} 40 | 41 | {% if has_sftp %} 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | {% if has_sftp_playlists_by_folder %} 53 | 54 | 55 | 56 | 57 | {% endif %} 58 |
    SFTP Asset Upload
    Server Address (Hostname):{{ host }}
    Type:SFTP or SSH (sometimes called SCP or SSH2, not FTP or FTPS)
    Port:{{ settings.SFTP_PORT }}
    Username:{{ user.username }}
    Password:[your-password] (replace with your password, don't include square brackets)
    Create Playlist for SFTP Uploads
    (by folder)
    {{ user.sftp_playlists_by_folder|yesno:'Yes,No' }}
    59 | {% endif %} 60 | 61 | {% if settings.ICECAST_ENABLED %} 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 81 |
    Local Icecast Server
    Server Status:http://{{ host }}:{{ settings.ICECAST_PORT }}/
    Live Stream (direct & insecure):http://{{ host }}:{{ settings.ICECAST_PORT }}/live
    Live Stream (proxied & {% if request.is_secure %}secure{% else %}insecure{% endif%}): 75 | 76 | 77 | http{% if request.is_secure %}s{% endif %}://{{ host }}/live 78 | 79 | 80 |
    82 | {% endif %} 83 | {% endwith %} 84 | {% endblock %} 85 | -------------------------------------------------------------------------------- /app/app/broadcast/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin, messages 2 | from django.utils import timezone 3 | from django.utils.formats import date_format 4 | 5 | from constance import config 6 | 7 | from autodj.models import AudioAsset, RotatorAsset 8 | from common.admin import AudioAssetAdminBase, DiskUsageChangelistAdminMixin, asset_conversion_action 9 | 10 | from .forms import BroadcastAssetCreateForm 11 | from .models import Broadcast, BroadcastAsset 12 | 13 | 14 | class BroadcastInline(admin.TabularInline): 15 | model = Broadcast 16 | extra = 0 17 | add_fields = ("scheduled_time",) 18 | change_fields = ("scheduled_time", "status", "creator") 19 | readonly_fields = ("status", "creator") 20 | 21 | def get_fields(self, request, obj=None): 22 | return self.add_fields if obj is None else self.change_fields 23 | 24 | def has_change_permission(self, request, obj=None): 25 | return False 26 | 27 | def has_add_permission(self, request, obj=None): 28 | return obj is None or obj.status == obj.Status.READY 29 | 30 | 31 | def message_broadcast_added(request, broadcast): 32 | scheduled_time = date_format(timezone.localtime(broadcast.scheduled_time), "SHORT_DATETIME_FORMAT") 33 | messages.warning( 34 | request, 35 | f"Your broadcast of {broadcast.asset.title} has been queued for {scheduled_time}. " 36 | "Come back at that time to check whether it was successfully played.", 37 | ) 38 | 39 | 40 | class BroadcastAssetAdmin(DiskUsageChangelistAdminMixin, AudioAssetAdminBase): 41 | non_popup_inlines = (BroadcastInline,) 42 | create_form = BroadcastAssetCreateForm 43 | 44 | def get_actions(self, request): 45 | actions = super().get_actions(request) 46 | if config.AUTODJ_ENABLED and request.user.has_perm("autodj.change_audioasset"): 47 | action = asset_conversion_action(BroadcastAsset, AudioAsset) 48 | actions["convert_to_audio_asset"] = (action, "convert_to_audio_asset", action.short_description) 49 | if config.AUTODJ_STOPSETS_ENABLED: 50 | action = asset_conversion_action(BroadcastAsset, RotatorAsset) 51 | actions["convert_to_rotator_asset"] = (action, "convert_to_rotator_asset", action.short_description) 52 | return actions 53 | 54 | def get_inlines(self, request, obj=None): 55 | return () if request.GET.get("_popup") else self.non_popup_inlines 56 | 57 | def get_search_results(self, request, queryset, search_term): 58 | queryset, use_distinct = super().get_search_results(request, queryset, search_term) 59 | 60 | # If it's the autocomplete view (from BroadcastAdmin), then filter by uploaded only 61 | if request.path.endswith("/autocomplete/"): 62 | queryset = queryset.filter(status=BroadcastAsset.Status.READY) 63 | 64 | return queryset, use_distinct 65 | 66 | def save_related(self, request, form, formsets, change): 67 | # TODO this is wacky and overwrites everything! 68 | existed_before = set(form.instance.broadcasts.all()) 69 | super().save_related(request, form, formsets, change) 70 | newly_created = set(form.instance.broadcasts.all()) - existed_before 71 | for broadcast in newly_created: 72 | message_broadcast_added(request, broadcast) 73 | broadcast.creator = request.user 74 | broadcast.save() 75 | 76 | 77 | class BroadcastAdmin(DiskUsageChangelistAdminMixin, admin.ModelAdmin): 78 | add_fields = ("scheduled_time", "asset") 79 | autocomplete_fields = ("asset",) 80 | date_hierarchy = "scheduled_time" 81 | list_display = change_fields = ("scheduled_time", "asset", "status", "creator") 82 | list_filter = ("status",) 83 | save_on_top = True 84 | 85 | def get_fields(self, request, obj=None): 86 | return self.add_fields if obj is None else self.change_fields 87 | 88 | def has_change_permission(self, request, obj=None): 89 | return False 90 | 91 | def save_model(self, request, obj, form, change): 92 | obj.creator = request.user 93 | super().save_model(request, obj, form, change) 94 | message_broadcast_added(request, obj) 95 | 96 | 97 | admin.site.register(BroadcastAsset, BroadcastAssetAdmin) 98 | admin.site.register(Broadcast, BroadcastAdmin) 99 | -------------------------------------------------------------------------------- /docker-compose/base.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | nginx: 5 | container_name: crazyarms-nginx 6 | image: dtcooper/crazyarms-nginx:${CRAZYARMS_VERSION} 7 | restart: always 8 | build: 9 | context: ./nginx 10 | ports: 11 | - '${HTTP_PORT:-80}:80' 12 | depends_on: 13 | - app 14 | - logs 15 | volumes: 16 | - ./.env:/.env:ro 17 | - ./media:/media_root:ro 18 | - static_root:/static_root:ro 19 | environment: 20 | TZ: ${TIMEZONE:-US/Pacific} 21 | 22 | app: 23 | container_name: crazyarms-app 24 | image: dtcooper/crazyarms-app:${CRAZYARMS_VERSION} 25 | restart: always 26 | build: 27 | context: ./app 28 | volumes: 29 | - ./.env:/.env:ro 30 | - ./imports:/imports_root 31 | - ./media:/media_root 32 | - services_config:/config 33 | - static_root:/static_root 34 | depends_on: 35 | - db 36 | - harbor 37 | - redis 38 | - upstream 39 | environment: 40 | CRAZYARMS_VERSION: ${CRAZYARMS_VERSION} 41 | TZ: ${TIMEZONE:-US/Pacific} 42 | 43 | tasks: 44 | container_name: crazyarms-tasks 45 | image: dtcooper/crazyarms-app:${CRAZYARMS_VERSION} 46 | restart: always 47 | build: 48 | context: ./app 49 | volumes: 50 | - ./.env:/.env:ro 51 | - ./imports:/imports_root 52 | - ./media:/media_root 53 | - sftp_root:/sftp_root 54 | depends_on: 55 | - db 56 | - redis 57 | environment: 58 | CRAZYARMS_VERSION: ${CRAZYARMS_VERSION} 59 | RUN_HUEY: 1 60 | TZ: ${TIMEZONE:-US/Pacific} 61 | 62 | log-subscriber: 63 | container_name: crazyarms-log-subscriber 64 | image: dtcooper/crazyarms-app:${CRAZYARMS_VERSION} 65 | restart: always 66 | build: 67 | context: ./app 68 | volumes: 69 | - ./.env:/.env:ro 70 | depends_on: 71 | - db 72 | - redis 73 | command: ./manage.py run_log_subscriber 74 | environment: 75 | CRAZYARMS_VERSION: ${CRAZYARMS_VERSION} 76 | TZ: ${TIMEZONE:-US/Pacific} 77 | 78 | db: 79 | container_name: crazyarms-db 80 | image: library/postgres:13-alpine 81 | restart: always 82 | volumes: 83 | - postgres_data:/var/lib/postgresql/data/ 84 | environment: 85 | POSTGRES_PASSWORD: postgres 86 | TZ: ${TIMEZONE:-US/Pacific} 87 | 88 | redis: 89 | container_name: crazyarms-redis 90 | image: library/redis:6-alpine 91 | restart: always 92 | volumes: 93 | - redis_data:/data 94 | environment: 95 | TZ: ${TIMEZONE:-US/Pacific} 96 | 97 | harbor: 98 | container_name: crazyarms-harbor 99 | image: dtcooper/crazyarms-liquidsoap:${CRAZYARMS_VERSION} 100 | restart: always 101 | build: 102 | context: ./liquidsoap 103 | ports: 104 | - '${HARBOR_PORT:-8001}:8001' 105 | depends_on: 106 | - db 107 | volumes: 108 | - services_config:/config:ro 109 | - ./media:/media_root:ro 110 | environment: 111 | CRAZYARMS_VERSION: ${CRAZYARMS_VERSION} 112 | CONTAINER_NAME: harbor 113 | SECRET_KEY: ${SECRET_KEY} 114 | TZ: ${TIMEZONE:-US/Pacific} 115 | 116 | upstream: 117 | container_name: crazyarms-upstream 118 | image: dtcooper/crazyarms-liquidsoap:${CRAZYARMS_VERSION} 119 | restart: always 120 | build: 121 | context: ./liquidsoap 122 | depends_on: 123 | - db 124 | volumes: 125 | - ./media:/media_root:ro 126 | - services_config:/config:ro 127 | environment: 128 | CRAZYARMS_VERSION: ${CRAZYARMS_VERSION} 129 | CONTAINER_NAME: upstream 130 | TZ: ${TIMEZONE:-US/Pacific} 131 | 132 | logs: 133 | container_name: crazyarms-logs 134 | image: amir20/dozzle:latest 135 | restart: always 136 | volumes: 137 | - /var/run/docker.sock:/var/run/docker.sock:ro 138 | environment: 139 | DOZZLE_BASE: "/logs" 140 | DOZZLE_TAILSIZE: 5000 141 | TZ: ${TIMEZONE:-US/Pacific} 142 | 143 | sftp: 144 | container_name: crazyarms-sftp 145 | image: dtcooper/crazyarms-sftp:${CRAZYARMS_VERSION} 146 | restart: always 147 | build: 148 | context: ./sftp 149 | volumes: 150 | - ./.env:/.env:ro 151 | - services_config:/config 152 | - sftp_root:/sftp_root 153 | ports: 154 | - "${SFTP_PORT:-2022}:2022" 155 | 156 | volumes: 157 | postgres_data: 158 | redis_data: 159 | services_config: 160 | sftp_root: 161 | static_root: 162 | -------------------------------------------------------------------------------- /app/app/crazyarms/admin.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin 3 | from django.template.response import TemplateResponse 4 | from django.urls import path, resolve, reverse 5 | from django.utils.html import format_html 6 | from django.utils.safestring import mark_safe 7 | from django.views.generic import View 8 | 9 | from constance import config 10 | 11 | 12 | class AdminBaseContextMixin: 13 | def get_context_data(self, **kwargs): 14 | context = super().get_context_data(title=self._admin_title, **kwargs) 15 | context.update(admin.site.each_context(self.request)) 16 | return context 17 | 18 | 19 | class CrazyArmsAdminSite(admin.AdminSite): 20 | AdminBaseContextMixin = AdminBaseContextMixin 21 | index_title = "" 22 | empty_value_display = mark_safe("none") 23 | site_url = None 24 | nginx_proxy_views = (("View server logs", "/logs/", "common.view_logs"),) 25 | if settings.ZOOM_ENABLED: 26 | nginx_proxy_views += (("Administer Zoom over VNC", "/zoom/vnc/", "common.view_websockify"),) 27 | if settings.HARBOR_TELNET_WEB_ENABLED: 28 | nginx_proxy_views += ( 29 | ( 30 | "Liquidsoap harbor telnet (experimental)", 31 | "/telnet/", 32 | "common.view_telnet", 33 | ), 34 | ) 35 | 36 | @property 37 | def site_title(self): 38 | return format_html("{} — Station Admin", config.STATION_NAME) 39 | 40 | site_header = site_title 41 | 42 | def __init__(self, *args, **kwargs): 43 | self.extra_urls = [] 44 | super().__init__(*args, **kwargs) 45 | 46 | def app_index_extra(self, request): 47 | return TemplateResponse( 48 | request, 49 | self.index_template or "admin/app_index_extra.html", 50 | { 51 | **self.each_context(request), 52 | "title": "Miscellaneous Configuration administration", 53 | "app_list": False, 54 | }, 55 | ) 56 | 57 | def app_index(self, request, app_label, extra_context=None): 58 | return super().app_index( 59 | request, 60 | app_label, 61 | extra_context={**(extra_context or {}), "extra_urls": []}, 62 | ) 63 | 64 | def each_context(self, request): 65 | context = super().each_context(request) 66 | current_url_name = resolve(request.path_info).url_name 67 | is_extra_url = False 68 | extra_urls = [] 69 | 70 | # Registered views 71 | for title, pattern, permission in self.extra_urls: 72 | if permission is None or request.user.has_perm(permission): 73 | extra_urls.append((title, reverse(f"admin:{pattern.name}"), False)) 74 | if current_url_name == pattern.name: 75 | is_extra_url = True 76 | for title, url, permission in self.nginx_proxy_views: 77 | if request.user.has_perm(permission): 78 | extra_urls.append((title, url, True)) 79 | 80 | context.update( 81 | { 82 | "current_url_name": current_url_name, 83 | "extra_urls": sorted(extra_urls), 84 | "is_extra_url": is_extra_url, 85 | } 86 | ) 87 | return context 88 | 89 | def register_view(self, route, title, kwargs=None, name=None): 90 | if name is None: 91 | name = route.replace("/", "").replace("-", "_") 92 | 93 | def register(cls_or_func): 94 | cls_or_func._admin_title = title 95 | view = self.admin_view(cls_or_func.as_view() if issubclass(cls_or_func, View) else cls_or_func) 96 | pattern = path( 97 | route=f"settings/{route}", 98 | view=self.admin_view(view), 99 | kwargs=kwargs, 100 | name=name, 101 | ) 102 | permission = getattr(cls_or_func, "permission_required", None) 103 | self.extra_urls.append((title, pattern, permission)) 104 | return cls_or_func 105 | 106 | return register 107 | 108 | def get_urls(self): 109 | return ( 110 | [ 111 | path( 112 | "settings/", 113 | view=self.admin_view(self.app_index_extra), 114 | name="app_index_extra", 115 | ) 116 | ] 117 | + [pattern for _, pattern, _ in self.extra_urls] 118 | + super().get_urls() 119 | ) 120 | -------------------------------------------------------------------------------- /app/app/services/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 | import django.db.models.deletion 7 | import django.utils.timezone 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ('autodj', '0001_initial'), 16 | ('broadcast', '0001_initial'), 17 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name='UpstreamServer', 23 | fields=[ 24 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 25 | ('name', models.SlugField(help_text='Unique codename to identify this upstream server.', max_length=20, unique=True, verbose_name='name')), 26 | ('hostname', models.CharField(help_text='Hostname for the server, eg. example.com', max_length=255, verbose_name='hostname')), 27 | ('protocol', models.CharField(choices=[('http', 'http'), ('https', 'https (secure)')], default='http', help_text="The protocol for the server, if unsure it's likely http", max_length=5, verbose_name='protocol')), 28 | ('port', models.PositiveSmallIntegerField(help_text='Port for this server, eg. 8000', verbose_name='port')), 29 | ('telnet_port', models.PositiveIntegerField()), 30 | ('username', models.CharField(default='source', max_length=255, verbose_name='username')), 31 | ('password', models.CharField(max_length=255, verbose_name='password')), 32 | ('mount', models.CharField(help_text='Mount point for the upstream server, eg. /stream', max_length=255, verbose_name='mount point')), 33 | ('encoding', models.CharField(choices=[('mp3', 'MP3'), ('fdkaac', 'AAC'), ('vorbis.cbr', 'OGG Vorbis'), ('ffmpeg', 'ffmpeg (custom additional arguments needed)')], default='mp3', max_length=20, verbose_name='encoding format')), 34 | ('bitrate', models.PositiveSmallIntegerField(blank=True, help_text='Encoding bitrate (kbits), blank for a sane default or ffmpeg.', null=True, verbose_name='bitrate')), 35 | ('mime', models.CharField(blank=True, help_text='MIME format, ie audio/mpeg, leave blank for Liquidsoap to guess. (Needed for ffmpeg.)', max_length=50, verbose_name='MIME format')), 36 | ('encoding_args', models.JSONField(blank=True, default=None, help_text='Enter any additional arguments for the encoder here. Advanced use cases only, see the Liquidsoap docs here for more info. Leave empty or null for none.', null=True, verbose_name='additional arguments for encoding')), 37 | ], 38 | options={ 39 | 'ordering': ('name',), 40 | 'unique_together': {('hostname', 'port', 'mount')}, 41 | }, 42 | ), 43 | migrations.CreateModel( 44 | name='PlayoutLogEntry', 45 | fields=[ 46 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 47 | ('created', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='Date')), 48 | ('event_type', models.CharField(choices=[('track', 'Track'), ('dj', 'Live DJ'), ('general', 'General'), ('source', 'Source Transition')], default='general', max_length=10, verbose_name='Type')), 49 | ('description', common.models.TruncatingCharField(max_length=500, verbose_name='Entry')), 50 | ('active_source', common.models.TruncatingCharField(default='N/A', max_length=50, verbose_name='Active Source')), 51 | ('audio_asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='autodj.audioasset')), 52 | ('broadcast_asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='broadcast.broadcastasset')), 53 | ('rotator_asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='autodj.rotatorasset')), 54 | ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), 55 | ], 56 | options={ 57 | 'verbose_name': 'playout log entry', 58 | 'verbose_name_plural': 'playout logs', 59 | 'ordering': ('-created',), 60 | }, 61 | ), 62 | ] 63 | -------------------------------------------------------------------------------- /app/app/webui/templates/webui/gcal.html: -------------------------------------------------------------------------------- 1 | {% extends 'webui/base.html' %} 2 | 3 | {% block content %} 4 | {% if user.harbor_auth_actual == user.HarborAuth.NEVER %} 5 |

    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.
    • 22 | {% endif %} 23 | {% if user.gcal_exit_grace_minutes > 0 %} 24 |
    • You can keep broadcasting up to 25 | {{ user.gcal_exit_grace_minutes }} minute{{ user.gcal_exit_grace_minutes|pluralize }} 26 | after your scheduled time.
    • 27 | {% endif %} 28 |
    29 | {% endif %} 30 | {% endif %} 31 |

    32 | {% endif %} 33 | 34 | {% if user.upcoming_show_times or user.current_show_times %} 35 | {% if user.harbor_auth_actual == user.HarborAuth.NEVER %} 36 |

    Below are your scheduled shows, however you will not be able to broadcast during 37 | them until you are authorized on the harbor.

    38 | {% endif %} 39 | 40 | {% if user.current_show_times %} 41 |

    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 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | {% for title, start, end in user.current_show_times %} 68 | 69 | 70 | 71 | 72 | 73 | 74 | {% endfor %} 75 | 76 |
    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 |
    #Start Time ({{ user.timezone }})End Time ({{ user.timezone }})Title
    {{ forloop.counter }}{{ start }}{{ end }}{% if title %}{{ title }}{% else %}Untitled Show{% endif %}
    77 | {% endif %} 78 | 79 | {% if user.upcoming_show_times %} 80 | 81 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | {% for title, start, end in user.upcoming_show_times %} 99 | 100 | 101 | 102 | 103 | 104 | 105 | {% endfor %} 106 | 107 |
    82 | My Upcoming Scheduled Shows 83 | {% if user.harbor_auth_actual == user.HarborAuth.GOOGLE_CALENDAR %} 84 | {% if user.gcal_entry_grace_minutes > 0 or user.gcal_exit_grace_minutes > 0 %} 85 | (Not Including Grace Period) 86 | {% endif %} 87 | {% endif %} 88 |
    #Start Time ({{ user.timezone }})End Time ({{ user.timezone }})Title
    {{ forloop.counter }}{{ start }}{{ end }}{% if title %}{{ title }}{% else %}Untitled Show{% endif %}
    108 | {% endif %} 109 | {% elif user.harbor_auth_actual != user.HarborAuth.NEVER %} 110 |

    No show times scheduled. Please contact the station administration if you believe this in error.

    111 | {% endif %} 112 | {% endblock %} 113 | -------------------------------------------------------------------------------- /app/app/common/forms.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django import forms 4 | from django.conf import settings 5 | from django.contrib.auth.forms import UserChangeForm, UserCreationForm 6 | from django.utils.safestring import mark_safe 7 | 8 | from constance import config 9 | from constance.admin import ConstanceForm 10 | 11 | from gcal.tasks import sync_gcal_api 12 | from services import init_services 13 | 14 | logger = logging.getLogger(f"crazyarms.{__name__}") 15 | 16 | 17 | class AudioAssetCreateFormBase(forms.ModelForm): 18 | url = forms.URLField( 19 | label="External URL", 20 | required=False, 21 | help_text=mark_safe( 22 | "URL on an external service like SoundCloud, Mixcloud, YouTube, direct link, etc. If provided, it" 23 | " will be downloaded. (Complete list of supported services here.)' 25 | ), 26 | ) 27 | url.widget.attrs.update({"class": "vLargeTextField"}) 28 | source = forms.ChoiceField( 29 | label="Source type", 30 | choices=(("file", "Uploaded file"), ("url", "External URL")), 31 | initial="file", 32 | ) 33 | 34 | class Meta: 35 | fields = "__all__" 36 | 37 | def __init__(self, *args, **kwargs): 38 | super().__init__(*args, **kwargs) 39 | for field in self._meta.model.TITLE_FIELDS: 40 | self.fields[field].widget.attrs.update( 41 | { 42 | "placeholder": "Leave empty to extract from file's metadata", 43 | "class": "vLargeTextField", 44 | } 45 | ) 46 | 47 | def clean(self): 48 | cleaned_data = super().clean() 49 | if cleaned_data["source"] == "url": 50 | if not cleaned_data.get("url"): 51 | self.add_error("url", "This field is required.") 52 | else: 53 | if not cleaned_data.get("file"): 54 | self.add_error("file", "This field is required.") 55 | 56 | def save(self, commit=True): 57 | asset = super().save(commit=False) 58 | if self.cleaned_data["source"] == "url": 59 | asset.run_download_after_save_url = self.cleaned_data["url"] 60 | if commit: 61 | asset.save() 62 | return asset 63 | 64 | 65 | class ProcessConfigChangesConstanceForm(ConstanceForm): 66 | def save(self): 67 | pre_save = {name: getattr(config, name) for name in settings.CONSTANCE_CONFIG} 68 | super().save() 69 | post_save = {name: getattr(config, name) for name in settings.CONSTANCE_CONFIG} 70 | config_changes = [name for name in settings.CONSTANCE_CONFIG if pre_save[name] != post_save[name]] 71 | if config_changes: 72 | self.process_config_changes(config_changes) 73 | 74 | def process_config_changes(self, changes): 75 | # TODO if we move this in ConstanceAdmin's save_model(), we can send messages to the request 76 | if any(change.startswith("GOOGLE_CALENDAR_") for change in changes): 77 | logger.info("Got GOOGLE_CALENDAR_* config change. Re-sync'ing") 78 | sync_gcal_api() 79 | if any(change.startswith("ICECAST_") for change in changes): 80 | logger.info("Got ICECAST_* config change. Restarting icecast.") 81 | init_services(services="icecast") 82 | if any(change.startswith("HARBOR_") for change in changes) or "AUTODJ_ENABLED" in changes: 83 | logger.info("Got HARBOR_* or AUTODJ_ENABLED config change. Restarting harbor.") 84 | init_services(services="harbor", subservices="harbor") 85 | if "ICECAST_SOURCE_PASSWORD" in changes: 86 | logger.info("Got ICECAST_SOURCE_PASSWORD config change. Setting local-icecast upstream password.") 87 | init_services(services="upstream", subservices="local-icecast") 88 | if any(change.startswith("UPSTREAM_") for change in changes): 89 | logger.info("Got UPSTREAM_* config change. Restarting upstreams.") 90 | init_services(services="upstream", restart_services=True) 91 | 92 | 93 | class EmailUserCreationForm(UserCreationForm): 94 | send_email = forms.BooleanField( 95 | label="Send welcome email to new user", 96 | required=False, 97 | help_text=( 98 | "Check this box to send the user an email notifying them of their new " 99 | "account, allowing them to set their password. The link will be good " 100 | "for 14 days." 101 | ), 102 | ) 103 | 104 | 105 | class EmailUserChangeForm(UserChangeForm): 106 | send_email = forms.BooleanField( 107 | label="Send password change email to user", 108 | required=False, 109 | help_text=( 110 | "Check this box and save the form to send the user an email, allowing " 111 | "them to change the password for their account. The link will be good " 112 | "for 14 days." 113 | ), 114 | ) 115 | -------------------------------------------------------------------------------- /app/app/gcal/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import logging 4 | 5 | from dateutil.parser import parse 6 | from google.oauth2.service_account import Credentials 7 | from googleapiclient.discovery import build 8 | 9 | from django.core.cache import cache 10 | from django.db import models 11 | from django.utils import timezone 12 | from django.utils.formats import date_format 13 | 14 | from constance import config 15 | 16 | from common.models import TruncatingCharField, User 17 | from crazyarms import constants 18 | 19 | logger = logging.getLogger(f"crazyarms.{__name__}") 20 | 21 | 22 | class GCalShow(models.Model): 23 | SYNC_RANGE_MIN = datetime.timedelta(days=60) 24 | SYNC_RANGE_MAX = datetime.timedelta(days=120) 25 | 26 | gcal_id = TruncatingCharField(max_length=256, unique=True) 27 | users = models.ManyToManyField(User, related_name="gcal_shows", verbose_name="users") 28 | title = TruncatingCharField("title", max_length=1024) 29 | start = models.DateTimeField("start time") 30 | end = models.DateTimeField("end time") 31 | 32 | def __str__(self): 33 | return ( 34 | f"{self.title}: {date_format(timezone.localtime(self.start), 'SHORT_DATETIME_FORMAT')}" 35 | f" - {date_format(timezone.localtime(self.end), 'SHORT_DATETIME_FORMAT')}" 36 | ) 37 | 38 | class Meta: 39 | ordering = ("start", "id") 40 | verbose_name = "Google Calendar show" 41 | verbose_name_plural = "Google Calendar shows" 42 | 43 | @staticmethod 44 | def get_last_sync(): 45 | return cache.get(constants.CACHE_KEY_GCAL_LAST_SYNC) 46 | 47 | @classmethod 48 | def sync_api(cls): 49 | logger.info( 50 | f"Syncing with Google Calendar events API in -{cls.SYNC_RANGE_MIN.days} days," 51 | f" +{cls.SYNC_RANGE_MAX.days} days range" 52 | ) 53 | credentials = Credentials.from_service_account_info(json.loads(config.GOOGLE_CALENDAR_CREDENTIALS_JSON)) 54 | service = build("calendar", "v3", credentials=credentials) 55 | 56 | email_to_user = {} # lookup cache 57 | shows = [] 58 | 59 | page_token = None 60 | while True: 61 | response = ( 62 | service.events() 63 | .list( 64 | calendarId=config.GOOGLE_CALENDAR_ID, 65 | maxResults=2500, 66 | timeMin=(datetime.datetime.utcnow() - cls.SYNC_RANGE_MIN).isoformat() + "Z", 67 | timeMax=(datetime.datetime.utcnow() + cls.SYNC_RANGE_MAX).isoformat() + "Z", 68 | timeZone="UTC", 69 | singleEvents="true", 70 | pageToken=page_token, 71 | ) 72 | .execute() 73 | ) 74 | 75 | for item in response["items"]: 76 | start = ( 77 | timezone.make_aware(parse(item["start"]["date"]), is_dst=False) 78 | if item["start"].get("dateTime") is None 79 | else parse(item["start"].get("dateTime")) 80 | ) 81 | end = ( 82 | timezone.make_aware( 83 | parse(item["end"]["date"]).replace(hour=23, minute=59, second=59), 84 | is_dst=True, 85 | ) 86 | if item["end"].get("dateTime") is None 87 | else parse(item["end"].get("dateTime")) 88 | ) 89 | 90 | emails = [attendee["email"].lower() for attendee in item.get("attendees", [])] 91 | creator = item.get("creator", {}).get("email") 92 | if creator is not None: 93 | emails.append(creator.lower()) 94 | 95 | users = [] 96 | 97 | for email in emails: 98 | user = email_to_user.get(email) 99 | if not user: 100 | try: 101 | user = email_to_user[email] = User.objects.get(email__iexact=email) 102 | except User.DoesNotExist: 103 | pass 104 | 105 | if user: 106 | users.append(user) 107 | 108 | title = item.get("summary", "").strip() 109 | shows.append((item["id"], users, {"title": title, "start": start, "end": end})) 110 | 111 | page_token = response.get("nextPageToken") 112 | if page_token is None: 113 | break 114 | 115 | synced_gcal_ids = [] 116 | 117 | for gcal_id, users, defaults in shows: 118 | gcal_show, _ = cls.objects.update_or_create(gcal_id=gcal_id, defaults=defaults) 119 | gcal_show.users.set(users) 120 | synced_gcal_ids.append(gcal_id) 121 | 122 | _, deleted_dict = cls.objects.exclude(gcal_id__in=synced_gcal_ids).delete() 123 | num_deleted = deleted_dict.get(f"{cls._meta.app_label}.{cls._meta.object_name}", 0) 124 | logger.info(f"Done. Synced {len(synced_gcal_ids)} shows, deleted {num_deleted} shows.") 125 | -------------------------------------------------------------------------------- /zoom/image/usr/local/sbin/zoom-runner.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Constants 4 | ROOM_INFO_KEY='zoom-runner:room-info' 5 | SLEEP_INTERVAL=2.5 6 | MEETING_USER='Broadcast+Bot' # TODO: Meeting user could be SHOW ENDS AT 7 | ROOT_WINDOW_NAME='Zoom Cloud Meetings' 8 | AUDIO_CONFERENCE_OPTIONS_WINDOW_NAME='audio conference options' 9 | AUDIO_CONFERENCE_OPTIONS_CLICK_X=290 10 | AUDIO_CONFERENCE_OPTIONS_CLICK_Y=154 11 | MEETING_WINDOW_NAME='Zoom Meeting' 12 | 13 | # Variables 14 | ZOOM_INFO= 15 | MEETING_ID= 16 | MEETING_PWD= 17 | MEETING_USERNAME= 18 | MEETING_USER_ID= 19 | 20 | zoom_service_running() { 21 | supervisorctl status zoom | grep -q 'STARTING\|RUNNING\|BACKOFF' 22 | } 23 | 24 | as_user() { 25 | sudo -u user PULSE_SERVER=harbor DISPLAY=:0 "$@" 26 | } 27 | 28 | xdo() { 29 | as_user xdotool "$@" 30 | } 31 | 32 | echo "Waiting on redis key $ROOM_INFO_KEY" 33 | 34 | while true; do 35 | ROOM_INFO="$(redis-cli -h redis get $ROOM_INFO_KEY)" 36 | 37 | if [ "$ROOM_INFO" ]; then 38 | eval "$ROOM_INFO" 39 | if [ -z "$MEETING_ID" ]; then 40 | echo "Meeting ID not found at redis key $ROOM_INFO_KEY. Possibly a bad redis key. Not starting." 41 | sleep "$SLEEP_INTERVAL" 42 | continue 43 | fi 44 | 45 | # Start up Zoom service 46 | if ! zoom_service_running; then 47 | echo "Starting Zoom and enabling it on the harbor." 48 | supervisorctl start zoom 49 | 50 | # Wait for Zoom to boot 51 | while ! xdo search --name "$ROOT_WINDOW_NAME" >/dev/null; do 52 | sleep 0.05 53 | done 54 | 55 | sleep 2.5 56 | fi 57 | 58 | # Enable Zoom source on harbor 59 | # TODO: is there a better way to do this + save state on harbor restart during a Zoom show? 60 | # is this script the best place to execute this? (for one it pollutes harbor logs) 61 | # Maybe this can happen at: 62 | # 1. Show start time 63 | # 2. Show end time 64 | # 3. When harbor boots it can check if redis key is set 65 | echo 'var.set zoom_enabled = true\nquit' | nc -w 2 harbor 1234 > /dev/null 66 | 67 | # Open meeting window 68 | MEETING_WINDOW="$(xdo search --name "$MEETING_WINDOW_NAME")" 69 | if [ -z "$MEETING_WINDOW" ]; then 70 | echo "Opening Meeting ID $MEETING_ID by request from user $MEETING_USERNAME (id = $MEETING_USER_ID)." 71 | # TODO: seems to join multiple times when using a waiting room, need to confirm + fix that 72 | as_user xdg-open "zoommtg://zoom.us/join?action=join&confno=$MEETING_ID&uname=$MEETING_USER&pwd=$MEETING_PWD" 73 | sleep 10 74 | 75 | # Make sure meeting window exists 76 | MEETING_WINDOW="$(xdo search --name "$MEETING_WINDOW_NAME")" 77 | if [ -z "$MEETING_WINDOW" ]; then 78 | echo "Error finding window named '$MEETING_WINDOW_NAME'. Meeting window did not open." 79 | sleep "$SLEEP_INTERVAL" 80 | continue 81 | fi 82 | fi 83 | 84 | # Select Audio pesky conference options popup that occasionally appears 85 | AUDIO_CONFERENCE_OPTIONS_WINDOW="$(xdo search --onlyvisible --name "$AUDIO_CONFERENCE_OPTIONS_WINDOW_NAME")" 86 | if [ "$AUDIO_CONFERENCE_OPTIONS_WINDOW" ]; then 87 | echo 'Found audio conference options. Selecting default.' 88 | sleep 1 89 | # Move mouse to the spot correct spot selects computer speakers 90 | xdo mousemove --window "$AUDIO_CONFERENCE_OPTIONS_WINDOW" \ 91 | "$AUDIO_CONFERENCE_OPTIONS_CLICK_X" \ 92 | "$AUDIO_CONFERENCE_OPTIONS_CLICK_Y" 93 | sleep 0.25 94 | # Click it 95 | xdo click --window "$AUDIO_CONFERENCE_OPTIONS_WINDOW" 1 96 | sleep 1 97 | fi 98 | 99 | # Minimize the Window, likely improves performance 100 | MEETING_WINDOW_VISIBLE="$(xdo search --onlyvisible --name "$MEETING_WINDOW_NAME")" 101 | if [ "$MEETING_WINDOW_VISIBLE" ]; then 102 | echo 'Meeting found maximized. Minimizing window.' 103 | xdo windowminimize "$MEETING_WINDOW_VISIBLE" 104 | sleep 1 105 | fi 106 | 107 | else 108 | echo "No zoom room info found at redis key $ROOM_INFO_KEY. Disabling Zoom broadcasting on harbor." 109 | echo 'var.set zoom_enabled = false\nquit' | nc -w 2 harbor 1234 > /dev/null 110 | 111 | # If room is running, close it 112 | MEETING_WINDOW="$(xdo search --name "$MEETING_WINDOW_NAME")" 113 | if [ "$MEETING_WINDOW" ]; then 114 | echo 'Closing meeting window.' 115 | # Focus it 116 | xdo windowactivate "$MEETING_WINDOW" 117 | sleep 1 118 | # Send a close window key combo 119 | xdo key --window "$MEETING_WINDOW" --clearmodifiers 'alt+F4' 120 | sleep 1 121 | # Press enter 122 | xdo key --window "$MEETING_WINDOW" --clearmodifiers Return 123 | sleep 1 124 | fi 125 | fi 126 | 127 | sleep "$SLEEP_INTERVAL" 128 | done 129 | -------------------------------------------------------------------------------- /nginx/image/etc/nginx/templates/default.conf.j2: -------------------------------------------------------------------------------- 1 | {% if HTTPS_ENABLED|int %} 2 | server { 3 | listen 80 default_server reuseport; 4 | listen [::]:80 default_server reuseport; 5 | 6 | location '/.well-known/acme-challenge' { 7 | default_type "text/plain"; 8 | proxy_pass http://localhost:8080; 9 | } 10 | 11 | location / { 12 | return 301 https://$http_host$request_uri; 13 | } 14 | } 15 | {% endif %} 16 | 17 | upstream app { 18 | server app:8000; 19 | } 20 | 21 | upstream logs { 22 | server logs:8080; 23 | } 24 | 25 | {% if ZOOM_ENABLED|int %} 26 | upstream zoom { 27 | server zoom:6080; 28 | } 29 | {% endif %} 30 | 31 | {% if HARBOR_TELNET_WEB_ENABLED|int %} 32 | upstream harbor-telnet-web { 33 | server harbor-telnet-web:7681; 34 | } 35 | {% endif %} 36 | 37 | server { 38 | server_name {{ DOMAIN_NAME }}; 39 | 40 | {% if HTTPS_ENABLED|int %} 41 | listen 443 ssl default_server reuseport; 42 | listen [::]:443 ssl default_server reuseport; 43 | 44 | ssl_certificate /etc/letsencrypt/live/{{ DOMAIN_NAME }}/fullchain.pem; 45 | ssl_certificate_key /etc/letsencrypt/live/{{ DOMAIN_NAME }}/privkey.pem; 46 | 47 | include {{ env('SSL_OPTIONS_PATH') }}; 48 | {% else %} 49 | listen 80 default_server reuseport; 50 | listen [::]:80 default_server reuseport; 51 | {% endif %} 52 | 53 | location = /favicon.ico { 54 | return 204; 55 | access_log off; 56 | log_not_found off; 57 | } 58 | 59 | {% if not DEBUG|int %} 60 | # gunicorn won't serve static root 61 | location /static/ { 62 | alias /static_root/; 63 | } 64 | {% endif %} 65 | 66 | location /media/ { 67 | alias /media_root/; 68 | } 69 | 70 | location /protected/logs/ { 71 | internal; 72 | proxy_pass http://logs/logs/; 73 | proxy_buffering off; 74 | proxy_cache off; 75 | } 76 | 77 | location = /protected/sse { 78 | internal; 79 | 80 | nchan_subscriber eventsource; 81 | nchan_channel_id status; 82 | nchan_eventsource_ping_interval 15; 83 | nchan_eventsource_ping_event ""; 84 | nchan_eventsource_ping_comment " ping"; 85 | } 86 | 87 | {% if HARBOR_TELNET_WEB_ENABLED|int %} 88 | location /protected/telnet/ { 89 | internal; 90 | proxy_pass http://harbor-telnet-web/; 91 | proxy_buffering off; 92 | proxy_cache off; 93 | proxy_http_version 1.1; 94 | proxy_set_header X-Forwarded-Proto $scheme; 95 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 96 | proxy_set_header Upgrade $http_upgrade; 97 | proxy_set_header Connection "upgrade"; 98 | } 99 | {% endif %} 100 | 101 | {% if ZOOM_ENABLED|int %} 102 | location /protected/websockify { 103 | internal; 104 | 105 | proxy_http_version 1.1; 106 | proxy_pass http://zoom/; 107 | proxy_set_header Upgrade $http_upgrade; 108 | proxy_set_header Connection "upgrade"; 109 | 110 | # VNC connection timeout 111 | proxy_read_timeout 61s; 112 | 113 | # Disable cache 114 | proxy_buffering off; 115 | } 116 | 117 | location /zoom/vnc/ { 118 | index vnc.html; 119 | alias /usr/share/noVNC/; 120 | } 121 | {% endif %} 122 | 123 | {% if ICECAST_ENABLED|int %} 124 | # Optionally forward /live URL on Icecast, useful for https or clean URLs 125 | location = /live { 126 | proxy_set_header Host $host; 127 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 128 | proxy_set_header X-Forwarded-Host $host; 129 | proxy_set_header X-Forwarded-Server $host; 130 | proxy_set_header X-Real-IP $remote_addr; 131 | proxy_pass http://icecast:8000; 132 | } 133 | {% endif %} 134 | 135 | location / { 136 | # Probably shouldn't use S3 if these are no good? 137 | client_max_body_size 1024M; 138 | client_body_buffer_size 1024M; 139 | client_body_timeout 150; 140 | proxy_set_header Host $http_host; 141 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 142 | proxy_set_header X-Forwarded-Proto $scheme; 143 | proxy_set_header X-Real-IP $remote_addr; 144 | proxy_redirect off; 145 | proxy_pass http://app; 146 | } 147 | } 148 | 149 | # Not publicly exposed 150 | server { 151 | listen 3000; 152 | 153 | location = /sse { 154 | nchan_subscriber eventsource; 155 | nchan_channel_id status; 156 | nchan_eventsource_ping_interval 15; 157 | nchan_eventsource_ping_event ""; 158 | nchan_eventsource_ping_comment " ping"; 159 | } 160 | 161 | location = /message { 162 | nchan_publisher http; 163 | nchan_channel_id status; 164 | nchan_store_messages off; 165 | } 166 | 167 | location = /test { 168 | root /usr/share/nginx/html; 169 | try_files /test_sse.html =404; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /app/app/services/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | from django.utils.safestring import mark_safe 4 | 5 | from autodj.models import AudioAsset, RotatorAsset 6 | from broadcast.models import BroadcastAsset 7 | from common.models import TruncatingCharField, User 8 | 9 | 10 | class UpstreamServer(models.Model): 11 | HEALTHCHECK_PORT_OFFSET = 1500 12 | 13 | class Encoding(models.TextChoices): 14 | MP3 = "mp3", "MP3" 15 | AAC = "fdkaac", "AAC" 16 | OGG = "vorbis.cbr", "OGG Vorbis" 17 | FFMPEG = "ffmpeg", "ffmpeg (custom additional arguments needed)" 18 | 19 | class Protocol(models.TextChoices): 20 | HTTP = "http", "http" 21 | HTTPS = "https", "https (secure)" 22 | 23 | name = models.SlugField( 24 | "name", 25 | max_length=20, 26 | unique=True, 27 | help_text="Unique codename to identify this upstream server.", 28 | ) 29 | hostname = models.CharField("hostname", max_length=255, help_text="Hostname for the server, eg. example.com") 30 | protocol = models.CharField( 31 | "protocol", 32 | max_length=5, 33 | choices=Protocol.choices, 34 | default=Protocol.HTTP, 35 | help_text="The protocol for the server, if unsure it's likely http", 36 | ) 37 | port = models.PositiveSmallIntegerField("port", help_text="Port for this server, eg. 8000") 38 | telnet_port = models.PositiveIntegerField() 39 | username = models.CharField("username", max_length=255, default="source") 40 | password = models.CharField("password", max_length=255) 41 | mount = models.CharField( 42 | "mount point", 43 | max_length=255, 44 | help_text="Mount point for the upstream server, eg. /stream", 45 | ) 46 | encoding = models.CharField("encoding format", max_length=20, choices=Encoding.choices, default=Encoding.MP3) 47 | bitrate = models.PositiveSmallIntegerField( 48 | "bitrate", 49 | null=True, 50 | blank=True, 51 | help_text="Encoding bitrate (kbits), blank for a sane default or ffmpeg.", 52 | ) 53 | mime = models.CharField( 54 | "MIME format", 55 | max_length=50, 56 | help_text="MIME format, ie audio/mpeg, leave blank for Liquidsoap to guess. (Needed for ffmpeg.)", 57 | blank=True, 58 | ) 59 | encoding_args = models.JSONField( 60 | "additional arguments for encoding", 61 | blank=True, 62 | null=True, 63 | default=None, 64 | help_text=mark_safe( 65 | # TODO dynamic Liquidsoap version somehow 66 | "Enter any additional arguments for the encoder here. Advanced use cases only, see the Liquidsoap' 68 | " docs here for more info. Leave empty or null for none." 69 | ), 70 | ) 71 | 72 | class Meta: 73 | ordering = ("name",) 74 | unique_together = ("hostname", "port", "mount") 75 | 76 | def __str__(self): 77 | s = f"{self.protocol}://{self.username}@{self.hostname}:{self.port}/{self.mount} ({self.get_encoding_display()}" 78 | if self.bitrate: 79 | s += f" @ {self.bitrate} kbit/s" 80 | return f"{s})" 81 | 82 | @property 83 | def healthcheck_port(self): 84 | return self.telnet_port + self.HEALTHCHECK_PORT_OFFSET 85 | 86 | def save(self, *args, **kwargs): 87 | self.mount = self.mount.removeprefix("/") 88 | 89 | if not self.telnet_port: 90 | # Find a free port 91 | port, used_ports = 1234, set(UpstreamServer.objects.values_list("telnet_port", flat=True)) 92 | while port in used_ports: 93 | port += 1 94 | self.telnet_port = port 95 | 96 | return super().save(*args, **kwargs) 97 | 98 | 99 | class PlayoutLogEntry(models.Model): 100 | class EventType(models.TextChoices): 101 | # These are used by templates/services/*.liq files, so be mindful before changing 102 | TRACK = "track", "Track" 103 | LIVE_DJ = "dj", "Live DJ" 104 | GENERAL = "general", "General" 105 | SOURCE_TRANSITION = "source", "Source Transition" 106 | 107 | created = models.DateTimeField("Date", default=timezone.now, db_index=True) 108 | event_type = models.CharField("Type", max_length=10, choices=EventType.choices, default=EventType.GENERAL) 109 | description = TruncatingCharField("Entry", max_length=500, blank=False) 110 | active_source = TruncatingCharField("Active Source", max_length=50, default="N/A") 111 | audio_asset = models.ForeignKey(AudioAsset, on_delete=models.SET_NULL, blank=True, null=True) 112 | broadcast_asset = models.ForeignKey(BroadcastAsset, on_delete=models.SET_NULL, blank=True, null=True) 113 | rotator_asset = models.ForeignKey(RotatorAsset, on_delete=models.SET_NULL, blank=True, null=True) 114 | user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True) 115 | 116 | def __str__(self): 117 | return f"[{self.get_event_type_display()}] {timezone.localtime(self.created)} - {self.description}" 118 | 119 | class Meta: 120 | ordering = ("-created",) 121 | verbose_name = "playout log entry" 122 | verbose_name_plural = "playout logs" 123 | -------------------------------------------------------------------------------- /app/app/common/static/common/admin/css/custom.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* From https://materializecss.com/color.html */ 3 | --mt-blue: #2196f3; 4 | --mt-cyan-lighten-1: #26c6da; 5 | --mt-orange: #ff9800; 6 | --mt-orange-darken-2: #f57c00; 7 | --mt-red: #f44336; 8 | --mt-green: #4caf50; 9 | --mt-grey-darken-1: #757575; 10 | 11 | /* TODO: Always use day mode for now */ 12 | --primary: #79aec8; 13 | --secondary: #417690; 14 | --accent: #f5dd5d; 15 | --primary-fg: #fff; 16 | 17 | --body-fg: #333; 18 | --body-bg: #fff; 19 | --body-quiet-color: #666; 20 | --body-loud-color: #000; 21 | 22 | --header-color: #ffc; 23 | --header-branding-color: var(--accent); 24 | --header-bg: var(--secondary); 25 | --header-link-color: var(--primary-fg); 26 | 27 | --breadcrumbs-fg: #c4dce8; 28 | --breadcrumbs-link-fg: var(--body-bg); 29 | --breadcrumbs-bg: var(--primary); 30 | 31 | --link-fg: #447e9b; 32 | --link-hover-color: #036; 33 | --link-selected-fg: #5b80b2; 34 | 35 | --hairline-color: #e8e8e8; 36 | --border-color: #ccc; 37 | 38 | --error-fg: #ba2121; 39 | 40 | --message-success-bg: #dfd; 41 | --message-warning-bg: #ffc; 42 | --message-error-bg: #ffefef; 43 | 44 | --darkened-bg: #f8f8f8; /* A bit darker than --body-bg */ 45 | --selected-bg: #e4e4e4; /* E.g. selected table cells */ 46 | --selected-row: #ffc; 47 | 48 | --button-fg: #fff; 49 | --button-bg: var(--primary); 50 | --button-hover-bg: #609ab6; 51 | --default-button-bg: var(--secondary); 52 | --default-button-hover-bg: #205067; 53 | --close-button-bg: #888; /* Previously #bbb, contrast 1.92 */ 54 | --close-button-hover-bg: #747474; 55 | --delete-button-bg: #ba2121; 56 | --delete-button-hover-bg: #a41515; 57 | 58 | --object-tools-fg: var(--button-fg); 59 | --object-tools-bg: var(--close-button-bg); 60 | --object-tools-hover-bg: var(--close-button-hover-bg); 61 | } 62 | } 63 | 64 | .help { 65 | font-size: 14px !important; 66 | } 67 | 68 | .timezonewarning { 69 | display: none; 70 | } 71 | 72 | #branding h1, #branding h1 a:link, #branding h1 a:visited { 73 | color: #fff; 74 | } 75 | 76 | #header { 77 | background: #292D32; 78 | color: #fff; 79 | } 80 | 81 | #header a:link, #header a:visited { 82 | color: #fff; 83 | } 84 | 85 | #header a:hover { 86 | color: #fff; 87 | } 88 | 89 | div.breadcrumbs { 90 | background: #f8f8f8; 91 | color: #999; 92 | } 93 | 94 | div.breadcrumbs a { 95 | color: var(--mt-blue); 96 | } 97 | 98 | div.breadcrumbs a.active { 99 | color: var(--mt-grey-darken-1); 100 | } 101 | 102 | div.breadcrumbs a:focus, div.breadcrumbs a:hover { 103 | color: var(--mt-cyan-lighten-1); 104 | } 105 | 106 | .select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] { 107 | background-color: #292D32; 108 | } 109 | 110 | .paginator a:link, .paginator a:visited { 111 | background: #292D32; 112 | } 113 | 114 | .button, input[type=submit], input[type=button], .submit-row input, a.button { 115 | background: #999; 116 | } 117 | 118 | .button:hover, input[type=submit]:hover, input[type=button]:hover { 119 | background: #878787; 120 | } 121 | 122 | .module h2, .module caption, .inline-group h2 { 123 | background: #292D32; 124 | } 125 | 126 | #user-tools a:focus, #user-tools a:hover { 127 | border: 0; 128 | color: #ddd; 129 | } 130 | 131 | .selector-chosen h2 { 132 | background: #292D32; 133 | } 134 | 135 | .calendar td.selected a { 136 | background: #292D32; 137 | } 138 | 139 | .calendar td a:focus, .timelist a:focus, 140 | .calendar td a:hover, .timelist a:hover { 141 | background: #292D32; 142 | } 143 | 144 | #changelist-filter li.selected a { 145 | color: var(--mt-grey-darken-1); 146 | font-weight: bold; 147 | } 148 | 149 | fieldset.collapsed .collapse-toggle { 150 | color: #444; 151 | } 152 | 153 | a:link, a:visited { 154 | color: var(--mt-blue); 155 | } 156 | 157 | a:focus, a:hover { 158 | color: var(--mt-cyan-lighten-1); 159 | } 160 | 161 | table thead th.sorted .sortoptions a.sortremove:focus:after, 162 | table thead th.sorted .sortoptions a.sortremove:hover:after { 163 | color: #444; 164 | } 165 | 166 | a.active.selector-chooseall:focus, a.active.selector-clearall:focus, 167 | a.active.selector-chooseall:hover, a.active.selector-clearall:hover { 168 | color: var(--mt-blue); 169 | } 170 | 171 | .calendar td a:active, .timelist a:active { 172 | background: #444; 173 | } 174 | 175 | .change-list ul.toplinks .date-back a:focus, 176 | .change-list ul.toplinks .date-back a:hover { 177 | color: #222; 178 | } 179 | 180 | .paginator a.showall:focus, .paginator a.showall:hover { 181 | color: #222; 182 | } 183 | 184 | .paginator a:focus, .paginator a:hover { 185 | background: #222; 186 | } 187 | 188 | #changelist-filter a:focus, #changelist-filter a:hover, 189 | #changelist-filter li.selected a:focus, 190 | #changelist-filter li.selected a:hover { 191 | color: var(--mt-cyan-lighten-1); 192 | } 193 | 194 | #changelist-filter a { 195 | color: var(--mt-blue); 196 | } 197 | 198 | .calendar td a:active, .timelist a:active { 199 | background: #292D32; 200 | color: white; 201 | } 202 | 203 | .calendar caption, .calendarbox h2 { 204 | background: #ccc; 205 | } 206 | 207 | .button.default, input[type=submit].default, .submit-row input.default { 208 | background: #292D32; 209 | } 210 | 211 | .button.default:hover, input[type=submit].default:hover { 212 | background: #191D22; 213 | } 214 | 215 | .select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] { 216 | background-color: #292D32; 217 | } 218 | 219 | .button.default, input[type=submit].default, .submit-row input.default { 220 | background: #292D32; 221 | } 222 | 223 | .object-tools a:focus, .object-tools a:hover { 224 | background-color: #292D32; 225 | } 226 | 227 | .selector-chosen h2 { 228 | background-color: #292D32 !important; 229 | } 230 | -------------------------------------------------------------------------------- /app/app/webui/tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shutil 4 | from unittest.mock import Mock, patch 5 | import uuid 6 | 7 | import requests_mock 8 | 9 | from django.conf import settings 10 | from django.contrib.messages import get_messages 11 | from django.test import TransactionTestCase 12 | from django.urls import reverse 13 | 14 | from constance import config 15 | 16 | from autodj.models import AudioAsset, Playlist, Rotator, Stopset, StopsetRotator 17 | from common.models import User 18 | from services.models import UpstreamServer 19 | 20 | from .forms import CCMIXTER_API_URL, FirstRunForm 21 | from .views import FirstRunView 22 | 23 | 24 | class FirstRunTests(TransactionTestCase): 25 | def login_admin(self): 26 | admin = User.objects.create_superuser("admin", "admin@example.com", "password") 27 | self.client.login(username=admin.username, password="password") 28 | 29 | def test_renders(self): 30 | response = self.client.get(reverse("first_run")) 31 | self.assertEqual(response.status_code, 200) 32 | self.assertTemplateUsed(response, "webui/form.html") 33 | form = response.context["form"] 34 | self.assertIsInstance(form, FirstRunForm) 35 | 36 | def assertHasMessage(self, response, message): 37 | messages = list(map(str, get_messages(response.wsgi_request))) 38 | self.assertIn(message, messages, f"Message {message} not found in request in {messages}") 39 | 40 | @requests_mock.Mocker() 41 | @patch("services.services.ServiceBase.supervisorctl") 42 | @patch("webui.forms.generate_random_string", lambda *args, **kwargs: "random-pw") 43 | @patch( 44 | "common.tasks.asset_download_external_url", 45 | lambda *args, **kwargs: Mock(id=uuid.uuid4()), 46 | ) 47 | def test_post(self, requests_mock, supervisor_mock): 48 | ccmixter_response = open(f"{settings.BASE_DIR}/crazyarms/test_data/ccmixter.json", "rb") 49 | requests_mock.register_uri("GET", CCMIXTER_API_URL, body=ccmixter_response) 50 | requests_mock.register_uri( 51 | "GET", 52 | re.compile(r"^http://ccmixter\.org/content/"), 53 | text="invalid-mp3-data", 54 | ) 55 | shutil.rmtree("/config", ignore_errors=True) 56 | 57 | self.assertEqual(User.objects.count(), 0) 58 | self.assertEqual(UpstreamServer.objects.count(), 0) 59 | 60 | response = self.client.post( 61 | reverse("first_run"), 62 | { 63 | "username": "admin", 64 | "email": "admin@crazyarms.example", 65 | "password1": "user-pw", 66 | "password2": "user-pw", 67 | "icecast_admin_password": "icecast-pw", 68 | "generate_sample_assets": "True", 69 | "station_name": "Test Station", 70 | }, 71 | ) 72 | self.assertRedirects(response, reverse("status"), fetch_redirect_response=False) 73 | self.assertHasMessage(response, FirstRunView.success_message) 74 | 75 | self.assertEqual(User.objects.count(), 1) 76 | user = User.objects.get() 77 | self.assertEqual(user.username, "admin") 78 | self.assertEqual(user.email, "admin@crazyarms.example") 79 | self.assertTrue(user.check_password("user-pw")) 80 | self.assertTrue(user.is_superuser) 81 | self.assertTrue(user.is_staff) 82 | 83 | self.assertEqual(config.STATION_NAME, "Test Station") 84 | self.assertEqual(config.ICECAST_ADMIN_PASSWORD, "icecast-pw") 85 | self.assertEqual(config.ICECAST_ADMIN_EMAIL, "admin@crazyarms.example") 86 | self.assertEqual(config.ICECAST_SOURCE_PASSWORD, "random-pw") 87 | self.assertEqual(config.ICECAST_RELAY_PASSWORD, "random-pw") 88 | 89 | self.assertEqual(UpstreamServer.objects.count(), 1) 90 | upstream = UpstreamServer.objects.get() 91 | self.assertEqual(upstream.name, "local-icecast") 92 | self.assertEqual(upstream.hostname, "icecast") 93 | self.assertEqual(upstream.protocol, UpstreamServer.Protocol.HTTP) 94 | self.assertEqual(upstream.port, 8000) 95 | self.assertEqual(upstream.username, "source") 96 | self.assertEqual(upstream.password, "random-pw") 97 | self.assertEqual(upstream.mount, "live") 98 | self.assertEqual(upstream.encoding, UpstreamServer.Encoding.MP3) 99 | self.assertIsNone(upstream.bitrate) 100 | self.assertEqual(upstream.mime, "") 101 | self.assertIsNone(upstream.encoding_args) 102 | 103 | # TODO: this should be tested in whatever tests services/services.py 104 | for config_file in ( 105 | "icecast/icecast.xml", 106 | "harbor/harbor.liq", 107 | "harbor/supervisor/harbor.conf", 108 | "harbor/supervisor/pulseaudio.conf", 109 | "upstream/local-icecast.liq", 110 | "upstream/supervisor/local-icecast.conf", 111 | "zoom/supervisor/xvfb-icewm.conf", 112 | "zoom/supervisor/x11vnc.conf", 113 | "zoom/supervisor/websockify.conf", 114 | ): 115 | self.assertTrue( 116 | os.path.exists(f"/config/{config_file}"), 117 | f"Config file {config_file} doesn't exist.", 118 | ) 119 | 120 | supervisor_start_calls = [set(c.args[1:]) for c in supervisor_mock.call_args_list if c.args[0] == "start"] 121 | self.assertIn({"xvfb-icewm", "x11vnc", "websockify"}, supervisor_start_calls) 122 | self.assertIn({"harbor", "pulseaudio"}, supervisor_start_calls) 123 | self.assertIn({"local-icecast"}, supervisor_start_calls) 124 | 125 | self.assertEqual(AudioAsset.objects.count(), 75) 126 | self.assertEqual(Rotator.objects.count(), 3) 127 | self.assertEqual(Playlist.objects.count(), 1) 128 | self.assertEqual(Stopset.objects.count(), 3) 129 | self.assertEqual(StopsetRotator.objects.count(), 13) 130 | 131 | def test_redirects_when_user_exists(self): 132 | User.objects.create_user("user") 133 | response = self.client.get(reverse("first_run")) 134 | self.assertRedirects(response, reverse("status"), fetch_redirect_response=False) 135 | -------------------------------------------------------------------------------- /app/app/common/management/commands/import_assets.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from django.conf import settings 5 | from django.core.exceptions import ValidationError 6 | from django.core.files import File 7 | from django.core.management.base import BaseCommand 8 | 9 | from autodj.models import AudioAsset, Playlist, RotatorAsset 10 | from broadcast.models import BroadcastAsset 11 | from common.models import User 12 | 13 | logger = logging.getLogger(f"crazyarms.{__name__}") 14 | 15 | 16 | class Command(BaseCommand): 17 | help = "Import Audio Files" 18 | 19 | def add_arguments(self, parser): 20 | parser.add_argument( 21 | "paths", 22 | nargs="+", 23 | type=str, 24 | help="path(s) of audio assets (or folders) in `imports/' folder, with that portion of the path omitted.", 25 | ) 26 | parser.add_argument("-u", "--username", help="Username of uploader (can be left blank)") 27 | group = parser.add_mutually_exclusive_group() 28 | group.add_argument("-p", "--playlist", help="Add to playlist by name. (Audio assets only)") 29 | group.add_argument( 30 | "-P", 31 | "--create-playlist", 32 | help="Add to playlist by name, creating it if it doesn't exist", 33 | ) 34 | group = parser.add_mutually_exclusive_group() 35 | group.add_argument( 36 | "--audio-assets", 37 | action="store_true", 38 | help="Import audio assets (the default behaviour)", 39 | ) 40 | group.add_argument("--rotator-assets", action="store_true", help="Import rotator assets") 41 | group.add_argument( 42 | "--scheduled-broadcast-assets", 43 | action="store_true", 44 | help="Import scheduled broadcast assets", 45 | ) 46 | parser.add_argument( 47 | "-d", 48 | "--delete", 49 | action="store_true", 50 | help="Delete input files, whether the can be converted to audio files or not (path still normalized).", 51 | ) 52 | 53 | def log(self, s, *args, **kwargs): 54 | if self.dont_print: 55 | logger.info(s) 56 | else: 57 | print(s, *args, **kwargs) 58 | 59 | def handle(self, *args, **options): 60 | if options["verbosity"] < 2: 61 | logging.disable(logging.WARNING) 62 | 63 | uploader = playlist = None 64 | if options["playlist"] or options["create_playlist"]: 65 | if options["rotator_assets"] or options["prerecorded_broadcast_assets"]: 66 | print("Can't add that type of asset to a playlist") 67 | return 68 | 69 | name = options["playlist"] or options["create_playlist"] 70 | 71 | try: 72 | playlist = Playlist.objects.get(name__iexact=name) 73 | except Playlist.DoesNotExist: 74 | if options["playlist"]: 75 | print(f"No playlist exists with name {name}. Exiting.") 76 | print("Try one of: ") 77 | for name in Playlist.objects.values_list("name", flat=True).order_by("name"): 78 | print(f" * {name}") 79 | else: 80 | print(f"Playlist {name} does not exist. Creating it.") 81 | playlist = Playlist.objects.create(name=name) 82 | 83 | if options["username"]: 84 | try: 85 | uploader = User.objects.get(username=options["username"]) 86 | except User.DoesNotExist: 87 | print(f'No user exists with username {options["username"]}. Exiting.') 88 | return 89 | 90 | if options["rotator_assets"]: 91 | asset_cls = RotatorAsset 92 | elif options["scheduled_broadcast_assets"]: 93 | asset_cls = BroadcastAsset 94 | else: 95 | asset_cls = AudioAsset 96 | 97 | asset_paths = [] 98 | 99 | for path in options["paths"]: 100 | if path in (".", "imports"): 101 | path = "" 102 | 103 | imports_root_path = f"{settings.AUDIO_IMPORTS_ROOT}{path}" 104 | if path.startswith("imports/") and not os.path.exists(imports_root_path): 105 | imports_root_path = f'{settings.AUDIO_IMPORTS_ROOT}{path.removeprefix("imports/")}' 106 | 107 | if os.path.isfile(imports_root_path): 108 | asset_paths.append(imports_root_path) 109 | 110 | elif os.path.isdir(imports_root_path): 111 | imports_root_path = imports_root_path.removesuffix("/") 112 | for root, dirs, files in os.walk(imports_root_path): 113 | for file in files: 114 | full_path = f"{root}/{file}" 115 | if os.path.isfile(full_path) and not os.path.islink(full_path): 116 | asset_paths.append(full_path) 117 | 118 | asset_paths.sort() 119 | 120 | if asset_paths: 121 | print(f"Found {len(asset_paths)} potential asset files in paths under imports/. Running.") 122 | else: 123 | print("Found no potential assets found with the supplied paths under imports/. Exiting.") 124 | return 125 | 126 | for path in asset_paths: 127 | delete_str = "and deleting " if options["delete"] else "" 128 | print( 129 | f"Importing {delete_str}{path.removeprefix(settings.AUDIO_IMPORTS_ROOT)}", 130 | end="", 131 | flush=True, 132 | ) 133 | 134 | asset = asset_cls(uploader=uploader, file_basename=os.path.basename(path)) 135 | asset.file.save(f"imported/{asset.file_basename}", File(open(path, "rb")), save=False) 136 | 137 | try: 138 | asset.clean() 139 | except ValidationError as e: 140 | print(f"... skipping, validation error: {e}") 141 | else: 142 | asset.save() 143 | if playlist: 144 | asset.playlists.add(playlist) 145 | print("... done!") 146 | finally: 147 | if options["delete"]: 148 | os.remove(path) 149 | --------------------------------------------------------------------------------