mirror of
https://github.com/nova-r/fediplug.git
synced 2025-02-02 06:07:27 +01:00
Add buttplugio device integration; Remove queue; Update dependencies
This is the first rudementary buttplugio integration that actually works. Use with caution. I still want to add the queue back and currently the duration and length of vibration is hard-coded.
This commit is contained in:
parent
7d18f6cf91
commit
1e1f2a40ac
7 changed files with 196 additions and 148 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -8,4 +8,5 @@
|
|||
.pytest_cache
|
||||
.vscode
|
||||
__pycache__
|
||||
fediplay.iml
|
||||
fediplug.iml
|
||||
venv
|
|
@ -1,3 +1,3 @@
|
|||
'''A Mastodon client for playing your friends' music.'''
|
||||
'''A Mastodon client that automatically plays your friends' music as they toot links to it.'''
|
||||
|
||||
from fediplug.cli import cli
|
||||
|
|
106
fediplug/buttplugio.py
Normal file
106
fediplug/buttplugio.py
Normal file
|
@ -0,0 +1,106 @@
|
|||
'''Buttplug controller'''
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from buttplug import Client, WebsocketConnector, ProtocolSpec
|
||||
|
||||
from fediplug.cli import options
|
||||
import fediplug.env as env
|
||||
|
||||
async def connect_plug_client():
|
||||
'''create Client object and connect plug client to Intiface Central or similar'''
|
||||
# And now we're in the main function. First, we'll need to set up a client
|
||||
# object. This is our conduit to the server.
|
||||
# We create a Client object, passing it the name we want for the client.
|
||||
# Names are shown in things like Intiface Central. We also can specify the
|
||||
# protocol version we want to use, which will default to the latest version.
|
||||
plug_client = Client("fediplug", ProtocolSpec.v3)
|
||||
|
||||
# Now we have a client called "Test Client", but it's not connected to
|
||||
# anything yet. We can fix that by creating a connector. Connectors
|
||||
# allow clients to talk to servers through different methods, including:
|
||||
# - Websockets
|
||||
# - IPC (Not currently available in Python)
|
||||
# - WebRTC (Not currently available in Python)
|
||||
# - TCP/UDP (Not currently available in Python)
|
||||
# For now, all we've implemented in python is a Websocket connector, so
|
||||
# we'll use that. This connector will connect to Intiface Central/Engine
|
||||
# on the local machine, using the 12345 port for insecure websockets.
|
||||
# We also pass the client logger so that it is used as the parent.
|
||||
connector = WebsocketConnector("ws://127.0.0.1:12345", logger=plug_client.logger)
|
||||
|
||||
# Finally, we connect.
|
||||
# If this succeeds, we'll be connected. If not, we'll probably have some
|
||||
# sort of exception thrown of type ButtplugError.
|
||||
try:
|
||||
await plug_client.connect(connector)
|
||||
except Exception as e:
|
||||
logging.error(f"Could not connect to server, exiting: {e}")
|
||||
return
|
||||
print('plug client connected')
|
||||
return plug_client
|
||||
|
||||
async def scan_devices(plug_client):
|
||||
# Now we move on to looking for devices. We will tell the server to start
|
||||
# scanning for devices. It returns while it is scanning, so we will wait
|
||||
# for 10 seconds, and then we will tell the server to stop scanning.
|
||||
await plug_client.start_scanning()
|
||||
await asyncio.sleep(5)
|
||||
await plug_client.stop_scanning()
|
||||
|
||||
# We can use the found devices as we see fit. The list of devices is
|
||||
# automatically kept up to date by the client:
|
||||
|
||||
# First, we are going to list the found devices.
|
||||
plug_client.logger.info(f"Devices: {plug_client.devices}")
|
||||
|
||||
# If we have any device we can print the list of devices
|
||||
# and access it by its ID: ( this step is done is trigger_actuators() )
|
||||
if len(plug_client.devices) != 0:
|
||||
print(plug_client.devices)
|
||||
print(len(plug_client.devices), "devices found")
|
||||
return plug_client
|
||||
|
||||
|
||||
async def trigger_actuators(plug_client, play_command):
|
||||
# If we have any device we can access it by its ID:
|
||||
device = plug_client.devices[0]
|
||||
# The most common case among devices is that they have some actuators
|
||||
# which accept a scalar value (0.0-1.0) as their command.
|
||||
play_command = (1, 1)
|
||||
if len(device.actuators) != 0:
|
||||
print(len(device.actuators), "actuators found")
|
||||
# cycle through all actuators in device
|
||||
print(device.actuators)
|
||||
for actuator in device.actuators:
|
||||
await actuator.command(play_command[0])
|
||||
print("generic actuator")
|
||||
await asyncio.sleep(play_command[1])
|
||||
#stops all actuators
|
||||
for actuator in device.actuators:
|
||||
await actuator.command(0)
|
||||
'''
|
||||
# Some devices may have linear actuators which need a different command.
|
||||
# The first parameter is the time duration in ms and the second the
|
||||
# position for the linear axis (0.0-1.0).
|
||||
if len(device.linear_actuators) != 0:
|
||||
await device.linear_actuators[0].command(1000, 0.5)
|
||||
print("linear actuator")
|
||||
|
||||
# Other devices may have rotatory actuators which another command.
|
||||
# The first parameter is the speed (0.0-1.0) and the second parameter
|
||||
# is a boolean, true for clockwise, false for anticlockwise.
|
||||
if len(device.rotatory_actuators) != 0:
|
||||
await device.rotatory_actuators[0].command(0.5, True)
|
||||
print("rotary actuator")
|
||||
'''
|
||||
|
||||
async def disconnect_plug_client(plug_client):
|
||||
# Disconnect the plug_client.
|
||||
await plug_client.disconnect()
|
||||
|
||||
|
||||
# First things first. We set logging to the console and at INFO level.
|
||||
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
|
|
@ -6,23 +6,18 @@ import os
|
|||
path = os.path
|
||||
import sys
|
||||
|
||||
import atexit
|
||||
import appdirs
|
||||
import click
|
||||
import anyio
|
||||
import click as click
|
||||
import atexit
|
||||
from mastodon import Mastodon
|
||||
import asyncio
|
||||
|
||||
from fediplug.dirs import DIRS
|
||||
import fediplug.mastodon as mastodon
|
||||
import fediplug.keyring as keyring
|
||||
|
||||
def ensure_dirs():
|
||||
'''Make sure the application directories exist.'''
|
||||
|
||||
if not path.exists(DIRS.user_config_dir):
|
||||
os.makedirs(DIRS.user_config_dir)
|
||||
|
||||
if not path.exists(DIRS.user_cache_dir):
|
||||
os.makedirs(DIRS.user_cache_dir)
|
||||
import fediplug.buttplugio as buttplugio
|
||||
|
||||
def get_access_token(instance):
|
||||
'''Ensure the user credential exists.'''
|
||||
|
@ -30,7 +25,7 @@ def get_access_token(instance):
|
|||
keyring.migrate_access_token(instance)
|
||||
|
||||
if not keyring.has_credential(instance, keyring.CREDENTIAL_ACCESS_TOKEN):
|
||||
click.echo('user credential for {} does not exist; try `fediplug login`'.format(instance))
|
||||
click.echo(f'user credential for {instance} does not exist; try `fediplug login`')
|
||||
sys.exit(1)
|
||||
|
||||
return keyring.get_credential(instance, keyring.CREDENTIAL_ACCESS_TOKEN)
|
||||
|
@ -42,7 +37,7 @@ def get_client_credentials(instance):
|
|||
|
||||
if not (keyring.has_credential(instance, keyring.CREDENTIAL_CLIENT_ID) and
|
||||
keyring.has_credential(instance, keyring.CREDENTIAL_CLIENT_SECRET)):
|
||||
click.echo('client credentials for {} do not exist; try `fediplug register`'.format(instance))
|
||||
click.echo(f'client credentials for {instance} do not exist; try `fediplug register`')
|
||||
sys.exit(1)
|
||||
|
||||
return (
|
||||
|
@ -57,8 +52,6 @@ def cli(debug):
|
|||
|
||||
options['debug'] = debug
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
@cli.command()
|
||||
@click.argument('instance')
|
||||
def register(instance):
|
||||
|
@ -85,27 +78,13 @@ def login(instance):
|
|||
@cli.command()
|
||||
@click.argument('instance')
|
||||
@click.argument('users', nargs=-1)
|
||||
@click.option('--clean-up-files', is_flag=True)
|
||||
def stream(instance, users, clean_up_files):
|
||||
'''Stream music from your timeline.'''
|
||||
if ( clean_up_files ):
|
||||
atexit.register(delete_files)
|
||||
def stream(instance, users):
|
||||
'''control buttplug.io device from your timeline.'''
|
||||
event_loop = asyncio.get_event_loop()
|
||||
plug_client = event_loop.run_until_complete(buttplugio.connect_plug_client())
|
||||
plug_client = event_loop.run_until_complete(buttplugio.scan_devices(plug_client))
|
||||
|
||||
client_id, client_secret = get_client_credentials(instance)
|
||||
access_token = get_access_token(instance)
|
||||
|
||||
mastodon.stream(instance, users, client_id, client_secret, access_token, cache_dir=DIRS.user_cache_dir)
|
||||
|
||||
def delete_files():
|
||||
cache_dir = DIRS.user_cache_dir
|
||||
for the_file in os.listdir(cache_dir):
|
||||
file_path = os.path.join(cache_dir, the_file)
|
||||
if os.path.isfile(file_path):
|
||||
os.remove(file_path)
|
||||
print('deleted ' + the_file)
|
||||
|
||||
@cli.command()
|
||||
def clean_up_files():
|
||||
delete_files()
|
||||
|
||||
|
||||
mastodon.stream(instance, users, client_id, client_secret, access_token, plug_client, event_loop)
|
||||
|
|
|
@ -5,13 +5,15 @@ LISTEN_TO_HASHTAG = 'fediplug'
|
|||
from os import umask
|
||||
|
||||
import click
|
||||
from lxml.etree import HTML # pylint: disable=no-name-in-module
|
||||
import lxml.html as lh
|
||||
from lxml.html.clean import clean_html
|
||||
import mastodon
|
||||
from youtube_dl.utils import DownloadError
|
||||
import asyncio
|
||||
|
||||
from fediplug.cli import options
|
||||
import fediplug.keyring as keyring
|
||||
from fediplug.queue import Queue
|
||||
#from fediplug.queue import Queue
|
||||
from fediplug.buttplugio import trigger_actuators
|
||||
|
||||
Mastodon = mastodon.Mastodon
|
||||
|
||||
|
@ -22,19 +24,20 @@ def api_base_url(instance):
|
|||
return 'https://' + instance
|
||||
|
||||
class StreamListener(mastodon.StreamListener):
|
||||
'''Listens to a Mastodon timeline and adds links the given Queue.'''
|
||||
'''Listens to a Mastodon timeline and adds buttplug instructions the given queue.'''
|
||||
|
||||
def __init__(self, queue, instance, users):
|
||||
self.queue = queue
|
||||
def __init__(self, plug_client, instance, users, event_loop):
|
||||
self.plug_client = plug_client
|
||||
self.instance = instance
|
||||
self.users = users
|
||||
self.event_loop = event_loop
|
||||
|
||||
if options['debug']:
|
||||
print('listener initialized with users={!r}'.format(self.users))
|
||||
print(rf'listener initialized with users={self.users}')
|
||||
|
||||
def on_update(self, status):
|
||||
if options['debug']:
|
||||
print('incoming status: acct={!r}'.format(status.account.acct))
|
||||
print(rf'incoming status: acct={status.account.acct}')
|
||||
|
||||
if self.users and normalize_username(status.account.acct, self.instance) not in self.users:
|
||||
if options['debug']:
|
||||
|
@ -43,20 +46,29 @@ class StreamListener(mastodon.StreamListener):
|
|||
|
||||
tags = extract_tags(status)
|
||||
if options['debug']:
|
||||
print('expecting: {!r}, extracted tags: {!r}'.format(LISTEN_TO_HASHTAG, tags))
|
||||
print(rf'expecting: {LISTEN_TO_HASHTAG}, extracted tags: {tags}')
|
||||
|
||||
if LISTEN_TO_HASHTAG in tags:
|
||||
links = extract_links(status)
|
||||
''' Here we extract the instructions for the butplug'''
|
||||
# TODO: still need to write extraction code
|
||||
buttplug_instructions = extract_buttplug_instructions(status)
|
||||
click.echo('queueing instructions')
|
||||
self.event_loop.run_until_complete(trigger_actuators(self.plug_client, buttplug_instructions))
|
||||
|
||||
|
||||
|
||||
'''
|
||||
if options['debug']:
|
||||
print('links: {!r}'.format(links))
|
||||
print(rf'instructions: {buttplug_instructions}')
|
||||
|
||||
for link in links:
|
||||
try:
|
||||
click.echo('==> Trying {}'.format(link))
|
||||
click.echo(rf'==> Trying {link}')
|
||||
self.queue.add(link)
|
||||
return
|
||||
except DownloadError:
|
||||
pass
|
||||
'''
|
||||
|
||||
def register(instance):
|
||||
'''Register fediplug to a Mastodon server and save the client credentials.'''
|
||||
|
@ -83,22 +95,23 @@ def login(instance, client_id, client_secret, grant_code):
|
|||
access_token = client.log_in(code=grant_code, scopes=['read'])
|
||||
keyring.set_credential(instance, keyring.CREDENTIAL_ACCESS_TOKEN, access_token)
|
||||
|
||||
def stream(instance, users, client_id, client_secret, access_token, cache_dir='.'):
|
||||
def stream(instance, users, client_id, client_secret, access_token, plug_client, event_loop):
|
||||
'''Stream statuses and add them to a queue.'''
|
||||
|
||||
client = build_client(instance, client_id, client_secret, access_token)
|
||||
users = [normalize_username(user, instance) for user in users]
|
||||
listener = StreamListener(Queue(cache_dir), instance, users)
|
||||
listener = StreamListener(plug_client, instance, users, event_loop)
|
||||
|
||||
|
||||
existing_statuses = client.timeline_hashtag(LISTEN_TO_HASHTAG, limit=1)
|
||||
|
||||
if options['debug']:
|
||||
print('existing_statuses: {!r}'.format(existing_statuses))
|
||||
print(rf'existing_statuses: {existing_statuses}')
|
||||
|
||||
for status in existing_statuses:
|
||||
listener.on_update(status)
|
||||
|
||||
click.echo('==> Streaming from {}'.format(instance))
|
||||
click.echo(f'==> Streaming from {instance}')
|
||||
client.stream_user(listener)
|
||||
|
||||
def extract_tags(toot):
|
||||
|
@ -110,29 +123,18 @@ def normalize_username(user, instance):
|
|||
user = user.lstrip('@')
|
||||
parts = user.split('@')
|
||||
if options['debug']:
|
||||
print('parts: {!r}'.format(parts))
|
||||
print(rf'parts: {parts}')
|
||||
|
||||
if len(parts) == 1 or parts[1] == instance:
|
||||
return parts[0]
|
||||
else:
|
||||
return user
|
||||
|
||||
def link_is_internal(link):
|
||||
'''Determines if a link is internal to the Mastodon instance.'''
|
||||
|
||||
classes = link.attrib.get('class', '').split(' ')
|
||||
|
||||
if options['debug']:
|
||||
print('href: {!r}, classes: {!r}'.format(link.attrib['href'], classes))
|
||||
|
||||
if classes:
|
||||
return 'mention' in classes
|
||||
|
||||
return False
|
||||
|
||||
def extract_links(toot):
|
||||
'''Extract all external links from a toot.'''
|
||||
|
||||
html = HTML(toot['content'])
|
||||
all_links = html.cssselect('a')
|
||||
return [link.attrib['href'] for link in all_links if not link_is_internal(link)]
|
||||
def extract_buttplug_instructions(toot):
|
||||
'''Extract buttplug instruction informations from a toot.'''
|
||||
doc_list = []
|
||||
doc = lh.fromstring(toot['content'])
|
||||
doc = clean_html(doc)
|
||||
doc_list.append(doc.text_content())
|
||||
print(rf'extracted buttplug_instruction: {doc_list}')
|
||||
return doc_list
|
|
@ -2,118 +2,73 @@
|
|||
|
||||
from os import path, listdir, makedirs, remove, utime
|
||||
from time import time, localtime
|
||||
from subprocess import Popen, run
|
||||
from threading import Thread, Lock
|
||||
|
||||
import click
|
||||
from youtube_dl import YoutubeDL, utils
|
||||
import asyncio
|
||||
|
||||
from fediplug.cli import options
|
||||
import fediplug.env as env
|
||||
import fediplug.buttplugio as buttplugio
|
||||
|
||||
|
||||
|
||||
|
||||
'''--deprecated--'''
|
||||
class Queue(object):
|
||||
'''The play queue.'''
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
|
||||
def __init__(self, cache_dir):
|
||||
def __init__(self, plug_client):
|
||||
self.lock = Lock()
|
||||
self.playing = False
|
||||
self.queue = []
|
||||
self.cache_dir = cache_dir
|
||||
self.plug_client = plug_client
|
||||
|
||||
def add(self, url):
|
||||
'''Fetches the url and adds the resulting audio to the play queue.'''
|
||||
|
||||
filenames = Getter(self.cache_dir).get(url)
|
||||
def add(self, buttplug_instructions):
|
||||
'''adds the buttplug instructions to the play queue.'''
|
||||
|
||||
with self.lock:
|
||||
self.queue.extend(filenames)
|
||||
self.queue.extend(buttplug_instructions)
|
||||
if not self.playing:
|
||||
self._play(self.queue.pop(0), self._play_finished)
|
||||
|
||||
def _play(self, filename, cb_complete):
|
||||
def _play(self, buttplug_instructions, cb_complete):
|
||||
self.playing = True
|
||||
|
||||
def _run_thread(filename, cb_complete):
|
||||
play_command = build_play_command(filename)
|
||||
def _run_thread(buttplug_instructions, cb_complete):
|
||||
play_command = build_play_command(buttplug_instructions)
|
||||
if options['debug']:
|
||||
click.echo(f'==> Playing {filename} with {play_command}')
|
||||
click.echo(f'==> Playing {buttplug_instructions} with {play_command}')
|
||||
else:
|
||||
click.echo(f'==> Playing {filename}')
|
||||
run(play_command)
|
||||
click.echo(f'==> Playing {buttplug_instructions}')
|
||||
"""
|
||||
loop = asyncio.new_event_loop
|
||||
asyncio.run_coroutine_threadsafe(buttplugio.trigger_actuators(self.plug_client, play_command), loop)
|
||||
# run command THIS DOES NOT WORK RIGHT NOW ##### THIS REALLY NEEDS TO BE FIXED
|
||||
#loop = asyncio.events._get_running_loop()
|
||||
#print(loop) """
|
||||
|
||||
print('foo')
|
||||
|
||||
click.echo('==> Playback complete')
|
||||
cb_complete()
|
||||
|
||||
thread = Thread(target=_run_thread, args=(filename, cb_complete))
|
||||
thread = Thread(target=_run_thread, args=(buttplug_instructions, cb_complete))
|
||||
thread.start()
|
||||
|
||||
|
||||
def _play_finished(self):
|
||||
with self.lock:
|
||||
self.playing = False
|
||||
if self.queue:
|
||||
self._play(self.queue.pop(0), self._play_finished)
|
||||
|
||||
class Getter(object):
|
||||
'''Fetches music from a url.'''
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
def __init__(self, cache_dir):
|
||||
self.filename = None
|
||||
self.filenames = []
|
||||
self.cache_dir = cache_dir
|
||||
|
||||
def _progress_hook(self, progress):
|
||||
if options['debug']:
|
||||
print('progress hook: status {!r}, filename {!r}'.format(progress['status'], progress['filename']))
|
||||
|
||||
if (progress['status'] in ('downloading', 'finished') and
|
||||
progress['filename'] not in self.filenames):
|
||||
self.filenames.append(progress['filename'])
|
||||
|
||||
def get(self, url):
|
||||
'''Fetches music from the given url.'''
|
||||
|
||||
'''deleting files here'''
|
||||
auto_delete_files(self.cache_dir)
|
||||
|
||||
ytdl_options = {
|
||||
'format': 'mp3/mp4',
|
||||
'nocheckcertificate': env.no_check_certificate(),
|
||||
'outtmpl': path.join(self.cache_dir, utils.DEFAULT_OUTTMPL),
|
||||
'progress_hooks': [self._progress_hook],
|
||||
'restrictfilenames': True,
|
||||
'quiet': True,
|
||||
'no_warnings': True
|
||||
}
|
||||
|
||||
if options['debug']:
|
||||
ytdl_options['quiet'] = False
|
||||
ytdl_options['no_warnings'] = False
|
||||
|
||||
with YoutubeDL(ytdl_options) as downloader:
|
||||
downloader.download([url])
|
||||
|
||||
for file in self.filenames:
|
||||
utime(file)
|
||||
|
||||
return self.filenames
|
||||
|
||||
def build_play_command(filename):
|
||||
def build_play_command(buttplug_instructions):
|
||||
'''Builds a play command for the given filename.'''
|
||||
# hardcoded for now
|
||||
# return tuple with ( strength [0.0-1.0] , duration [in s] )
|
||||
|
||||
filename = rf"{filename}"
|
||||
|
||||
command_args = env.play_command().split()
|
||||
command_args.append(filename)
|
||||
return command_args
|
||||
|
||||
def auto_delete_files(cache_dir):
|
||||
for the_file in listdir(cache_dir):
|
||||
file_path = path.join(cache_dir, the_file)
|
||||
if path.isfile(file_path):
|
||||
file_time = path.getmtime(file_path)
|
||||
if file_time + 604800 < time():
|
||||
remove(file_path)
|
||||
return (0.5, 1)
|
||||
|
|
7
setup.py
7
setup.py
|
@ -11,7 +11,12 @@ setup(
|
|||
'lxml',
|
||||
'Mastodon.py',
|
||||
'python-dotenv',
|
||||
'youtube-dl'
|
||||
'asyncio',
|
||||
'asyncclick',
|
||||
'anyio',
|
||||
'keyring',
|
||||
'buttplug-py'
|
||||
|
||||
],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
|
|
Loading…
Reference in a new issue