Skip to content

Commit

Permalink
Add Theme creation and YouTube link downloader (#12)
Browse files Browse the repository at this point in the history
* Allow empty themes, add theme track count sensor.

* Implement downloader

* Fix comments, minor refactor

* Added controls to create new Themes

* Add current version sensor, minor refactoring

* Rewrite readme

* Add updating section to readme

* Add YouTube section to readme

* Fix missing docstring

* Fix readme anchor link

* Fix readme formatting
  • Loading branch information
ejohb authored May 27, 2022
1 parent d1088de commit 3b6d952
Show file tree
Hide file tree
Showing 11 changed files with 442 additions and 104 deletions.
77 changes: 65 additions & 12 deletions amniotic/audio.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import getpass
import logging
import vlc
from datetime import datetime
from itertools import cycle
from pathlib import Path
from random import choice
from typing import Union, Optional

import vlc

VLC_VERBOSITY = 0


class Amniotic:
VOLUME_DEFAULT = 50
THEME_NAME_DEFAULT = 'Default Theme'

def __init__(self, path_base: Union[Path, str], device_names: Optional[dict[str, str]] = None):
def __init__(self, path: Union[Path, str], device_names: Optional[dict[str, str]] = None):
"""
Read audio directories and instantiate Theme objects
Expand All @@ -27,16 +29,23 @@ def __init__(self, path_base: Union[Path, str], device_names: Optional[dict[str,

self.device_names = device_names or {}
self._enabled = True
path_base = Path(path_base).absolute()
paths_themes = sorted([path.absolute() for path in path_base.glob('*') if path.is_dir()])
path = Path(path).absolute()
paths_themes = sorted([path.absolute() for path in path.glob('*') if path.is_dir()])

if not paths_themes:
msg = f'No audio directories found in "{path_base}"'
raise FileNotFoundError(msg)
msg = f'No audio directories found in "{path}". Default theme will be created.'
logging.warning(msg)

self.path = path

self.themes = [Theme(path, device_names=self.device_names) for path in paths_themes]
self.theme_current = self.themes[0]
self.themes = {theme.name: theme for theme in self.themes}
if not self.themes:
self.add_new_theme(self.THEME_NAME_DEFAULT)

self.theme_current = None
self.set_theme(next(iter(self.themes.keys())))

self.volume = 0
self.set_volume(self.VOLUME_DEFAULT)

Expand Down Expand Up @@ -82,6 +91,20 @@ def set_theme(self, id: str):
id = self.theme_current.get_device_id(id)
self.theme_current = self.themes[id]

def add_new_theme(self, name: str, set_current: bool = False):
"""
Add a new, empty theme by the specified name/ID.
"""
if name not in self.themes:
path = self.path / name
path.mkdir()
theme = Theme(path, device_names=self.device_names)
self.themes[name] = theme
if set_current:
self.set_theme(name)

def set_volume(self, value: int):
"""
Expand Down Expand Up @@ -124,10 +147,11 @@ def __init__(self, path: Path, device_names: Optional[dict[str, str]] = None):
"""
self.path = path
self.name = path.stem
self.paths = list(path.glob('*'))
self.paths = self.get_paths()

if not self.paths:
msg = f'Audio themes directory is empty: "{path}"'
raise FileNotFoundError(msg)
msg = f'Theme "{self.name}" directory is empty: "{self.path}"'
logging.warning(msg)

self.device_names = device_names or {}
self._enabled = False
Expand All @@ -140,7 +164,30 @@ def __init__(self, path: Path, device_names: Optional[dict[str, str]] = None):
self.volume = self.VOLUME_DEFAULT
self.volume_scaled = self.volume

def update_paths(self):
"""
Update file paths from disk.
"""
self.paths = self.get_paths()

def get_paths(self) -> list[Path]:
"""
Get file paths from disk.
"""
paths = list(self.path.glob('*'))

return paths

def get_player(self) -> vlc.MediaPlayer:
"""
Instantiate a new player, and register callbacks
"""
instance = vlc.Instance(f'--verbose {VLC_VERBOSITY}')
player = vlc.MediaPlayer(instance)
player.event_manager().event_attach(vlc.EventType.MediaPlayerEndReached, self.cb_media_player_end_reached)
Expand Down Expand Up @@ -204,7 +251,7 @@ def devices(self) -> dict[str, str]:

return devices

def set_device(self, device: str):
def set_device(self, device: Optional[str]):
"""
Set the output audio device from its ID. Also handle when that device had been unplugged, etc.
Expand Down Expand Up @@ -274,9 +321,14 @@ def enabled(self) -> bool:
def enabled(self, value: bool):
"""
Set whether Theme is enabled. If the input value if different from current, either start playing or toggle pause, depending on Theme state.
Set whether Theme is enabled. If the input value if different from current, either start playing or toggle pause, depending on Theme state. Themes
with no tracks (paths) cannot be enabled.
"""

if not self.paths:
return

value = bool(value)
if value == self._enabled:
return
Expand Down Expand Up @@ -325,6 +377,7 @@ def status(self):
'name': self.name,
'device': {'id': self.device, 'name': self.devices[self.device]},
'enabled': self.enabled,
'track_count': len(self.paths),
'volume': {'theme': self.volume, 'scaled': self.volume_scaled},
'position': position,
'position_percentage': round(position * 100) if position else None,
Expand Down
183 changes: 172 additions & 11 deletions amniotic/mqtt/control.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import threading
from functools import cached_property
from time import sleep
Expand All @@ -6,6 +7,7 @@
import pip
from johnnydep import JohnnyDist as Package
from paho.mqtt import client as mqtt
from pytube import YouTube, Stream

from amniotic.audio import Amniotic
from amniotic.config import NAME
Expand Down Expand Up @@ -178,7 +180,6 @@ def handle_outgoing(self, force_announce: bool = False):
self.queue.append(message)



class Select(Entity):
"""
Expand Down Expand Up @@ -225,7 +226,6 @@ def handle_outgoing(self, force_announce: bool = False):
super().handle_outgoing(force_announce=force_announce)



class SelectTheme(Select):
"""
Expand Down Expand Up @@ -299,10 +299,6 @@ def set_value(self, value) -> Any:
self.amniotic.set_volume(value)






class VolumeTheme(Volume):
"""
Expand All @@ -320,8 +316,6 @@ def set_value(self, value) -> Any:
self.amniotic.set_volume_theme(value)




class DeviceTheme(Select):
"""
Expand All @@ -346,9 +340,6 @@ def get_options(self, amniotic: Amniotic) -> list[str]:
return list(amniotic.devices.values())





class ToggleTheme(Entity):
"""
Expand Down Expand Up @@ -510,3 +501,173 @@ def handle_incoming(self, value: Any):

self.update_sensor.message = 'Updating...'
threading.Thread(target=self.do_update).start()


class TextInput(Entity):
"""
Base Home Assistant text input box. Note: this control abuses an alarm panel code entry box, as it seems to be the only way to allow a user to send
arbitrary text (e.g. a URL) from a Home Assistant control.
"""
HA_PLATFORM = 'alarm_control_panel'
DISARMED = 'disarmed'
TRIGGERED = 'triggered'
status = DISARMED

@cached_property
def update_sensor(self):
"""
Get the sensor for displaying update messages
"""
raise NotImplementedError()

def set_value(self, value) -> Any:
"""
Dummy method
"""
pass

def get_value(self) -> Any:
"""
The current state of this control. Pending means Idle, and Triggered means Downloading.
"""
return self.status

@property
def data(self):
"""
Home Assistant announce data for the entity.
"""
data = super().data | {
'code': 'REMOTE_CODE_TEXT',
'command_template': "{{ code }}"
}
return data


class NewTheme(TextInput):
"""
Home Assistant text input box for creating a new Theme
"""
HA_PLATFORM = 'alarm_control_panel'
ICON_SUFFIX = 'folder-plus-outline'
NAME = 'Create New Theme'

def handle_incoming(self, value: Any):
"""
Add specified theme and set to current
"""
self.amniotic.add_new_theme(value, set_current=True)


class Downloader(TextInput):
"""
Home Assistant track downloader URL input.
"""
HA_PLATFORM = 'alarm_control_panel'
ICON_SUFFIX = 'cloud-download-outline'
NAME = 'Download YouTube Link'

IDLE = TextInput.DISARMED
DOWNLOADING = TextInput.TRIGGERED
status = IDLE

@cached_property
def update_sensor(self):
"""
Get the sensor for displaying update messages
"""
from amniotic.mqtt.sensor import DownloaderStatus
update_status = self.loop.entities[DownloaderStatus]
return update_status

def progress_callback(self, stream: Stream, chunk: bytes, bytes_remaining: int):
"""
Send download progress to sensor
"""

percentage = (1 - (bytes_remaining / stream.filesize)) * 100
self.update_sensor.message = f'Downloading: {round(percentage)}% complete'

def completed_callback(self, stream: Stream, path: str):
"""
Send download completion message to sensor
"""

self.update_sensor.message = f'Download complete: "{stream.title}"'
self.status = self.IDLE

def do_download(self, url: str):
"""
Download highest bitrate audio stream from the video specified. Log any errors/progress to the relevant sensor
"""

try:

self.status = self.DOWNLOADING
theme = self.amniotic.theme_current
self.update_sensor.message = 'Fetching video metadata...'

video = YouTube(
url,
on_progress_callback=self.progress_callback,
on_complete_callback=self.completed_callback
)
self.update_sensor.message = 'Finding audio streams...'
audio_streams = video.streams.filter(only_audio=True).order_by('bitrate')
if not audio_streams:
self.update_sensor.message = f'Error downloading: no audio streams found in "{video.title}"'
self.status = self.IDLE
return
stream = audio_streams.last()

if stream.filesize == 0:
self.update_sensor.message = f'Error downloading: empty audio stream found in "{video.title}"'
self.status = self.IDLE
return

self.update_sensor.message = 'Starting download...'
stream.download(output_path=str(theme.path))

except Exception as exception:

self.update_sensor.message = f'Error downloading: {exception.__class__.__name__}'
logging.error(f'Download error for "{url}": {repr(exception)}')
self.status = self.IDLE
return

def handle_incoming(self, value: Any):
"""
Start download from the specified URL without blocking.
"""

if self.status == self.DOWNLOADING:
return
threading.Thread(target=self.do_download, args=[value]).start()


Loading

0 comments on commit 3b6d952

Please sign in to comment.