526 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Python
		
	
	
	
			
		
		
	
	
			526 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Python
		
	
	
	
#!/usr/bin/env python3
 | 
						|
from datetime import datetime
 | 
						|
import functools
 | 
						|
from http.server import BaseHTTPRequestHandler, HTTPServer
 | 
						|
import json
 | 
						|
import os
 | 
						|
from pathlib import Path
 | 
						|
import queue
 | 
						|
import signal
 | 
						|
import subprocess
 | 
						|
import threading
 | 
						|
import time
 | 
						|
 | 
						|
import click
 | 
						|
from crontab import CronTab
 | 
						|
import requests
 | 
						|
import psutil
 | 
						|
 | 
						|
 | 
						|
PA_CLIENT_NAME = 'cronplayer'
 | 
						|
 | 
						|
 | 
						|
class Entry:
 | 
						|
 | 
						|
    def __init__(self, number=None, crontab=None, play_definitions=None):
 | 
						|
        self.number = number
 | 
						|
        self.crontab = crontab
 | 
						|
        self.play_definitions = play_definitions or []
 | 
						|
 | 
						|
    def __repr__(self):
 | 
						|
        return f"Entry(number={self.number}, crontab={self.crontab}, play_definitions={self.play_definitions})"
 | 
						|
 | 
						|
    def play(self, pa_device_name=None, pa_client_name=PA_CLIENT_NAME, remote=None, debug=False, wait=True):
 | 
						|
        if not pa_device_name and not remote:
 | 
						|
            if debug:
 | 
						|
                print("DEBUG: Neither pa_device_name nor remote given: not playing anything")
 | 
						|
            return
 | 
						|
 | 
						|
        if not self.play_definitions:
 | 
						|
            if debug:
 | 
						|
                print(f"DEBUG: Not playing {self} without definitions")
 | 
						|
            return
 | 
						|
 | 
						|
        if pa_device_name and remote and debug:
 | 
						|
            print("Both pa_device_name and remote given: using remote to play")
 | 
						|
 | 
						|
        if remote:
 | 
						|
            return self._play_remote(remote, debug)
 | 
						|
        else:
 | 
						|
            return self._play_pa(pa_device_name, pa_client_name, debug, wait)
 | 
						|
 | 
						|
    def _play_remote(self, remote, debug):
 | 
						|
        payload = [pd.to_dict() for pd in self.play_definitions]
 | 
						|
        if debug:
 | 
						|
            print(f"DEBUG: Would PUT {payload} to {remote}")
 | 
						|
        else:
 | 
						|
            requests.put(remote, json=payload)
 | 
						|
 | 
						|
    def _play_pa(self, pa_device_name, pa_client_name, debug, wait):
 | 
						|
        env = {'XDG_RUNTIME_DIR': '/run/user/1000'}
 | 
						|
 | 
						|
        p = subprocess.run(f"pactl list sinks | grep -e Name: -e Description: | grep '{pa_device_name}' -B 1 | "
 | 
						|
                           "head -n 1 | awk '{print $2;}'",
 | 
						|
                           capture_output=True, shell=True, env=env, check=True, encoding='utf-8')
 | 
						|
        pa_device = p.stdout.strip()
 | 
						|
 | 
						|
        if not pa_device:
 | 
						|
            raise RuntimeError(f"Could not find PA device with Description set to '{pa_device_name}'.")
 | 
						|
 | 
						|
        ffmpeg_file_input = '\n'.join(f"file '{p}'" for pd in self.play_definitions for p in pd.paths)
 | 
						|
        cmd = (
 | 
						|
            'ffmpeg -loglevel 0 '
 | 
						|
            f"-f concat -safe 0 -i <(echo \"{ffmpeg_file_input}\") "
 | 
						|
            '-f wav - | '
 | 
						|
            f"paplay --client-name {pa_client_name} --device '{pa_device}'")
 | 
						|
        # Using this would output to pulseaudio directly, but this runs short as
 | 
						|
        # ffmpeg quits once it's encoded everything and doesn't wait till that's
 | 
						|
        # all played on pulse
 | 
						|
        # -f pulse -device "${DEVICE}" -name "Uhr" "Uhr"
 | 
						|
 | 
						|
        if debug:
 | 
						|
            print(f"DEBUG: {cmd}")
 | 
						|
        else:
 | 
						|
            kwargs = dict(shell=True, executable='/bin/bash', env=env)
 | 
						|
            if wait:
 | 
						|
                subprocess.run(cmd, **kwargs)
 | 
						|
            else:
 | 
						|
                return subprocess.Popen(cmd, **kwargs)
 | 
						|
 | 
						|
 | 
						|
class PlayDefinitionError(Exception):
 | 
						|
    pass
 | 
						|
 | 
						|
 | 
						|
class PlayDefinition:
 | 
						|
 | 
						|
    def __init__(self, path, repeat=1):
 | 
						|
        self._path = path
 | 
						|
        self._repeat = repeat
 | 
						|
 | 
						|
    @property
 | 
						|
    def paths(self):
 | 
						|
        return [self._path] * self._repeat
 | 
						|
 | 
						|
    @classmethod
 | 
						|
    def create(cls, path, repeat=1, check_exists=True):
 | 
						|
        path = Path(path).expanduser().resolve()
 | 
						|
        if check_exists and not path.exists():
 | 
						|
            raise PlayDefinitionError(f"{path} does not exist")
 | 
						|
 | 
						|
        return cls(path, repeat)
 | 
						|
 | 
						|
    @classmethod
 | 
						|
    def create_from_dict(cls, parsed_definition, check_exists=True):
 | 
						|
        if 'repeat' not in parsed_definition:
 | 
						|
            raise PlayDefinitionError("Entry is missing key 'repeat'")
 | 
						|
        if 'file' not in parsed_definition:
 | 
						|
            raise PlayDefinitionError("Entry is missing key 'file'")
 | 
						|
 | 
						|
        dt = datetime.now()
 | 
						|
        params = {
 | 
						|
            'month': dt.month,
 | 
						|
            'day': dt.day,
 | 
						|
            'dow': dt.isoweekday(),
 | 
						|
            'hour': dt.hour,
 | 
						|
            'analog_hour': int(dt.strftime('%I')),
 | 
						|
            'minute': dt.minute
 | 
						|
        }
 | 
						|
 | 
						|
        repeat = parsed_definition['repeat']
 | 
						|
        if isinstance(repeat, int):
 | 
						|
            repeat = str(repeat)
 | 
						|
        try:
 | 
						|
            repeat = repeat.format(**params)
 | 
						|
        except Exception as e:
 | 
						|
            raise PlayDefinitionError(f"definition formatting failed: {e}")
 | 
						|
 | 
						|
        repeat = eval(repeat, {}, {})
 | 
						|
 | 
						|
        return cls.create(parsed_definition['file'], repeat, check_exists=check_exists)
 | 
						|
 | 
						|
    @classmethod
 | 
						|
    def create_from_json(cls, json_definition, check_exists=True):
 | 
						|
        return cls.create_from_dict(json.loads(json_definition), check_exists=check_exists)
 | 
						|
 | 
						|
    def __repr__(self):
 | 
						|
        return f"PlayDefinition(path={self._path}, repeat={self._repeat})"
 | 
						|
 | 
						|
    def to_dict(self):
 | 
						|
        return {'file': str(self._path), 'repeat': self._repeat}
 | 
						|
 | 
						|
    def to_json(self):
 | 
						|
        return json.dumps(self.to_dict())
 | 
						|
 | 
						|
 | 
						|
def client_options(f):
 | 
						|
    @click.option('--pa-device-name')
 | 
						|
    @click.option('--pa-client-name', default=PA_CLIENT_NAME)
 | 
						|
    @click.option('--remote')
 | 
						|
    @click.option('--debug', is_flag=True)
 | 
						|
    @functools.wraps(f)
 | 
						|
    def wrapper(*args, **kwargs):
 | 
						|
        if kwargs.get('pa_device_name') and kwargs.get('remote'):
 | 
						|
            raise click.ClickException('"--remote" and "--pa-device-name" are mutually exclusive')
 | 
						|
 | 
						|
        f(*args, **kwargs)
 | 
						|
 | 
						|
    return wrapper
 | 
						|
 | 
						|
 | 
						|
@click.group()
 | 
						|
def cli():
 | 
						|
    ...
 | 
						|
 | 
						|
 | 
						|
@cli.command()
 | 
						|
@click.option('--file', 'file_path', type=click.Path(), required=True)
 | 
						|
@click.option('--repeat', default="1")
 | 
						|
@client_options
 | 
						|
def play(file_path, repeat, pa_device_name, pa_client_name, remote, debug):
 | 
						|
    """Play a file with the given repeat definition
 | 
						|
 | 
						|
    Construct a PlayDefinition and an Entry and play them as if they would have
 | 
						|
    been read from the crontab-like config-file.
 | 
						|
    """
 | 
						|
    pd = PlayDefinition.create_from_json(
 | 
						|
        json.dumps({'file': file_path, 'repeat': repeat}), check_exists=not bool(remote))
 | 
						|
 | 
						|
    entry = Entry(0, None, [pd])
 | 
						|
    entry.play(pa_device_name, pa_client_name, remote, debug)
 | 
						|
 | 
						|
 | 
						|
@cli.command()
 | 
						|
@click.option('--remote', required=True)
 | 
						|
def stop(remote):
 | 
						|
    """Send the stop command to the given remote"""
 | 
						|
    requests.post(remote)
 | 
						|
 | 
						|
 | 
						|
@cli.command()
 | 
						|
@click.option('--config-file', type=click.Path(exists=True), required=True)
 | 
						|
@client_options
 | 
						|
def play_by_config(config_file, pa_device_name, pa_client_name, remote, debug):
 | 
						|
    """Play the defined entries according to given crontab-like config-file
 | 
						|
 | 
						|
    Only entries for which the current time matches the definition in the
 | 
						|
    crontab-like config-file are played.
 | 
						|
    """
 | 
						|
    # parse the config file
 | 
						|
    # config file entry starting with a # (and any space before) are ignored
 | 
						|
    # entries contain a crontab definition followed by a ' = ' and then one or
 | 
						|
    # more play definitions separated by '|'
 | 
						|
    #
 | 
						|
    # a play definition is either
 | 
						|
    #   * a path to a file (without '|')
 | 
						|
    #   * a JSON dict containing the keys "file" and "repeat", where "times" can
 | 
						|
    #   contain mathematical expressions and can use Python formatting with the
 | 
						|
    #   variables month, day, dow (starting at monday being 1), hour (0 - 23),
 | 
						|
    #   analog_hour (1 - 12), minute containing the current run's values
 | 
						|
    with open(config_file) as f:
 | 
						|
        lines = [l.strip() for l in f.readlines()]
 | 
						|
 | 
						|
    lines = [(i, l) for (i, l) in enumerate(lines, start=1)
 | 
						|
             if l and not l.startswith('#')]
 | 
						|
 | 
						|
    entries = []
 | 
						|
    for i, line in lines:
 | 
						|
        if ' = ' not in line:
 | 
						|
            print(f"Error: Config file {config_file} line {i} contains no ' = '. Ignoring.")
 | 
						|
            continue
 | 
						|
 | 
						|
        cron_definition, play_definitions = line.split(' = ', 1)
 | 
						|
 | 
						|
        try:
 | 
						|
            c = CronTab(cron_definition)
 | 
						|
        except ValueError as e:
 | 
						|
            print(f"Error: Config file {config_file} line {i} contains no valid cron definition: {e}")
 | 
						|
            continue
 | 
						|
 | 
						|
        entry = Entry(i, c)
 | 
						|
 | 
						|
        play_definitions = play_definitions.split('|')
 | 
						|
        for p_def in play_definitions:
 | 
						|
            p_def = p_def.strip()
 | 
						|
            try:
 | 
						|
                play_definition = PlayDefinition.create_from_json(p_def)
 | 
						|
            except PlayDefinitionError as e:
 | 
						|
                print(f"Error: Config file {config_file} line {i}: {e}")
 | 
						|
                continue
 | 
						|
            except json.decoder.JSONDecodeError:
 | 
						|
                # no valid JSON, treat as filepath
 | 
						|
                try:
 | 
						|
                    play_definition = PlayDefinition.create(p_def)
 | 
						|
                except PlayDefinitionError as e:
 | 
						|
                    print(f"Error: Config file {config_file} line {i}: {e}")
 | 
						|
                    continue
 | 
						|
 | 
						|
            entry.play_definitions.append(play_definition)
 | 
						|
 | 
						|
        if entry.play_definitions:
 | 
						|
            entries.append(entry)
 | 
						|
 | 
						|
    if debug:
 | 
						|
        from pprint import pprint
 | 
						|
        print("\n## All entries")
 | 
						|
        pprint(entries)
 | 
						|
        print()
 | 
						|
 | 
						|
    this_runs_entries = []
 | 
						|
    # we filter first into a new list, because multiple Entries might be valid
 | 
						|
    # for this run and the farther down the entry is defined the later it would
 | 
						|
    # be checked
 | 
						|
    for entry in entries:
 | 
						|
        # check if this entry should be played right now
 | 
						|
        if debug:
 | 
						|
            print(f"{entry.crontab.previous(default_utc=False)} {entry}")
 | 
						|
 | 
						|
        if entry.crontab.previous(default_utc=False) < -59:
 | 
						|
            continue
 | 
						|
 | 
						|
        this_runs_entries.append(entry)
 | 
						|
 | 
						|
    if debug:
 | 
						|
        from pprint import pprint
 | 
						|
        print("\n## This run's entries")
 | 
						|
        pprint(this_runs_entries)
 | 
						|
        print()
 | 
						|
 | 
						|
    for entry in this_runs_entries:
 | 
						|
        entry.play(pa_device_name, pa_client_name, remote, debug=debug)
 | 
						|
 | 
						|
 | 
						|
class PlayerHttpServer(HTTPServer):
 | 
						|
 | 
						|
    def __init__(self, *args, player, **kwargs):
 | 
						|
        self.player = player
 | 
						|
        super().__init__(*args, **kwargs)
 | 
						|
 | 
						|
 | 
						|
class PlayerHttpHandler(BaseHTTPRequestHandler):
 | 
						|
 | 
						|
    def do_GET(self):
 | 
						|
        """Return the last played thing on / only"""
 | 
						|
        if self.path != '/':
 | 
						|
            self.send_response(404, 'Invalid path')
 | 
						|
            return
 | 
						|
 | 
						|
        # return the last thing that was played
 | 
						|
        self.send_response(200)
 | 
						|
        self.send_header('Content-type', 'application/json')
 | 
						|
        self.end_headers()
 | 
						|
        data = {'previous': None, 'playing': self.server.player.is_playing}
 | 
						|
        if self.server.player.last_played:
 | 
						|
            data['previous'] = [pd.to_dict() for pd in self.server.player.last_played.play_definitions]
 | 
						|
        self.wfile.write(bytes(json.dumps(data), 'utf-8'))
 | 
						|
 | 
						|
    def do_PUT(self):
 | 
						|
        """Accept something new to be played on / only"""
 | 
						|
        if self.path != '/':
 | 
						|
            self.send_response(404)
 | 
						|
            return
 | 
						|
 | 
						|
        if self.headers.get('Content-Type') != 'application/json':
 | 
						|
            self.send_response(400)
 | 
						|
            self.end_headers()
 | 
						|
            self.wfile.write(b'Content-type application/json only')
 | 
						|
            return
 | 
						|
 | 
						|
        content_length = self.headers.get('content-length')
 | 
						|
        length = int(content_length) if content_length else 0
 | 
						|
 | 
						|
        if not length:
 | 
						|
            self.send_response(400)
 | 
						|
            self.end_headers()
 | 
						|
            self.wfile.write(b'Got empty body')
 | 
						|
            return
 | 
						|
        data = self.rfile.read(length)
 | 
						|
        try:
 | 
						|
            entry_data = json.loads(data)
 | 
						|
        except json.JSONDecodeError as e:
 | 
						|
            self.send_response(400)
 | 
						|
            self.end_headers()
 | 
						|
            self.wfile.write(bytes(f"Could not parse JSON: {e}", 'utf-8'))
 | 
						|
            return
 | 
						|
 | 
						|
        if not isinstance(entry_data, list):
 | 
						|
            self.send_response(400)
 | 
						|
            self.end_headers()
 | 
						|
            self.wfile.write(b'Entry must be a list of dicts')
 | 
						|
            return
 | 
						|
        if not entry_data:
 | 
						|
            self.send_response(400)
 | 
						|
            self.end_headers()
 | 
						|
            self.wfile.write(b'Empty entry')
 | 
						|
            return
 | 
						|
 | 
						|
        entry = Entry()
 | 
						|
        for i, pd_data in enumerate(entry_data):
 | 
						|
            try:
 | 
						|
                entry.play_definitions.append(PlayDefinition.create_from_dict(pd_data))
 | 
						|
            except Exception as e:
 | 
						|
                self.send_response(400)
 | 
						|
                self.end_headers()
 | 
						|
                self.wfile.write(bytes(f"Invalid PlayDefinition {i}: {e}"), 'utf-8')
 | 
						|
                return
 | 
						|
        self.server.player.append(entry)
 | 
						|
        self.send_response(200)
 | 
						|
        self.end_headers()
 | 
						|
 | 
						|
    def do_POST(self):
 | 
						|
        """Stop the currently-playing entry on /"""
 | 
						|
        if self.path != '/':
 | 
						|
            self.send_response(404)
 | 
						|
            return
 | 
						|
 | 
						|
        self.server.player.stop()
 | 
						|
        self.send_response(200)
 | 
						|
        self.end_headers()
 | 
						|
 | 
						|
 | 
						|
class Player(threading.Thread):
 | 
						|
 | 
						|
    def __init__(self, *args, done_event, entry, pa_device_name, pa_client_name, debug, **kwargs):
 | 
						|
        threading.Thread.__init__(self, *args, **kwargs)
 | 
						|
        self._done_event = done_event
 | 
						|
        self._entry = entry
 | 
						|
        self._process = None
 | 
						|
        self._child_pids = []
 | 
						|
        self._child_pids_event = threading.Event()
 | 
						|
 | 
						|
        self._pa_device_name = pa_device_name
 | 
						|
        self._pa_client_name = pa_client_name
 | 
						|
        self._debug = debug
 | 
						|
 | 
						|
    def stop(self):
 | 
						|
        if not self._process:
 | 
						|
            print("no process to stop")
 | 
						|
            return
 | 
						|
 | 
						|
        self._child_pids_event.wait()
 | 
						|
 | 
						|
        for child_pid in self._child_pids:
 | 
						|
            os.kill(child_pid, signal.SIGTERM)
 | 
						|
        self._process.terminate()
 | 
						|
        try:
 | 
						|
            self._process.wait(2)
 | 
						|
        except subprocess.TimeoutExpired:
 | 
						|
            self._process.kill()
 | 
						|
        # TODO check if children are still running and try kill
 | 
						|
        # for child_pid in self._child_pids:
 | 
						|
 | 
						|
    def run(self):
 | 
						|
        self._process = self._entry.play(pa_device_name=self._pa_device_name, pa_client_name=self._pa_client_name,
 | 
						|
                                         debug=self._debug, wait=False)
 | 
						|
        p = psutil.Process(self._process.pid)
 | 
						|
        while len(p.children()) < 1:
 | 
						|
            time.sleep(0.2)
 | 
						|
        for child in p.children():
 | 
						|
            self._child_pids.append(child.pid)
 | 
						|
        self._child_pids_event.set()
 | 
						|
        self._process.wait()
 | 
						|
 | 
						|
 | 
						|
class PlayerManager(threading.Thread):
 | 
						|
 | 
						|
    def __init__(self, *args, pa_device_name, pa_client_name, debug, **kwargs):
 | 
						|
        threading.Thread.__init__(self, *args, **kwargs)
 | 
						|
 | 
						|
        self._pa_device_name = pa_device_name
 | 
						|
        self._pa_client_name = pa_client_name
 | 
						|
        self._debug = debug
 | 
						|
 | 
						|
        self._queue = queue.SimpleQueue()
 | 
						|
        # event signalling this PlayerThread to stop
 | 
						|
        self._stop_thread_event = threading.Event()
 | 
						|
 | 
						|
        # a Player object currently running
 | 
						|
        self._playing_thread = None
 | 
						|
        # an event to be set by the currently running Player to signal it's
 | 
						|
        # done
 | 
						|
        self._playing_done_event = threading.Event()
 | 
						|
        # event signalling to stop the current Player
 | 
						|
        self._stop = threading.Event()
 | 
						|
 | 
						|
        # an Entry object that was last played
 | 
						|
        self.last_played = None
 | 
						|
 | 
						|
    def append(self, entry):
 | 
						|
        self._queue.put(entry)
 | 
						|
 | 
						|
    def stop(self):
 | 
						|
        self._stop.set()
 | 
						|
 | 
						|
    def stop_thread(self):
 | 
						|
        self._stop_thread_event.set()
 | 
						|
        if self._playing_thread and self._playing_thread.is_alive():
 | 
						|
            self._playing_thread.stop()
 | 
						|
 | 
						|
    @property
 | 
						|
    def is_playing(self):
 | 
						|
        return bool(self._playing_thread)
 | 
						|
 | 
						|
    def run(self):
 | 
						|
        while not self._stop_thread_event.is_set():
 | 
						|
            # check if something is currently playing
 | 
						|
            if self._playing_thread:
 | 
						|
                # try to stop what's currently happening
 | 
						|
                if self._stop.is_set():
 | 
						|
                    self._playing_thread.stop()
 | 
						|
                    self._stop.clear()
 | 
						|
 | 
						|
                # clean up if playing was done
 | 
						|
                if not self._playing_thread.is_alive():
 | 
						|
                    self._playing_thread.join()
 | 
						|
                    self._playing_done_event.clear()
 | 
						|
                    self._playing_thread = None
 | 
						|
                    continue
 | 
						|
                # wait for playing to be done for some time, but continue to
 | 
						|
                # let stop() have some effect
 | 
						|
                self._playing_done_event.wait(1)
 | 
						|
                continue
 | 
						|
 | 
						|
            try:
 | 
						|
                entry = self._queue.get(block=True, timeout=1)
 | 
						|
            except queue.Empty:
 | 
						|
                continue
 | 
						|
 | 
						|
            self.last_played = entry
 | 
						|
 | 
						|
            # make sure we don't immediately try to stop it again, because it was set some time ago
 | 
						|
            self._stop.clear()
 | 
						|
 | 
						|
            player = Player(entry=entry, done_event=self._playing_done_event, pa_device_name=self._pa_device_name,
 | 
						|
                            pa_client_name=self._pa_client_name, debug=self._debug, daemon=True)
 | 
						|
            player.start()
 | 
						|
            self._playing_thread = player
 | 
						|
 | 
						|
 | 
						|
@cli.command()
 | 
						|
@click.option('--listen-host', default='127.0.0.1')
 | 
						|
@click.option('--listen-port', default=8227)
 | 
						|
@click.option('--pa-device-name', required=True)
 | 
						|
@click.option('--pa-client-name', default=PA_CLIENT_NAME)
 | 
						|
@click.option('--debug', is_flag=True)
 | 
						|
def daemon(listen_host, listen_port, pa_device_name, pa_client_name, debug):
 | 
						|
    """Listen on given host:port with an HTTP server
 | 
						|
 | 
						|
    Via HTTP, new requests to play files are accepted as PlayDefinitions as new
 | 
						|
    entry and currently playing entries can be stopped. The last playing entry
 | 
						|
    can be replayed, too.
 | 
						|
 | 
						|
    If --config-file is given, additionally play the crontab-like config
 | 
						|
    """
 | 
						|
    play_thread = PlayerManager(pa_device_name=pa_device_name, pa_client_name=pa_client_name,
 | 
						|
                                debug=debug, daemon=True)
 | 
						|
    play_thread.start()
 | 
						|
 | 
						|
    server_address = (listen_host, listen_port)
 | 
						|
    httpd = PlayerHttpServer(server_address, PlayerHttpHandler, player=play_thread)
 | 
						|
    httpd.serve_forever()
 | 
						|
 | 
						|
 | 
						|
if __name__ == '__main__':
 | 
						|
    import sys
 | 
						|
    sys.exit(cli())
 |