diff --git a/README.md b/README.md index 887121b..13f43fa 100644 --- a/README.md +++ b/README.md @@ -144,12 +144,6 @@ Contents - links to doc section for each script here: - [\[desktop/media\]](#hdr-__desktop_media__) - - [parec_from_flash](#hdr-parec_from_flash) - - [pa_track_history](#hdr-pa_track_history) - - [pa_modtoggle](#hdr-pa_modtoggle) - - [mpv_icy_track_history](#hdr-mpv_icy_track_history) - - [icy_record](#hdr-icy_record) - - [radio](#hdr-radio) - [toogg](#hdr-toogg) - [totty](#hdr-totty) - [split](#hdr-split) @@ -3105,86 +3099,8 @@ like passing magnet: links to transmission, or processing .torrent files. #### [\[desktop/media\]](desktop/media) -Scripts - mostly wrappers around ffmpeg and pulseaudio - to work with (or -process) various media files and streams. - - - -##### [parec_from_flash](desktop/media/parec_from_flash) - -Creates null-sink in pulseaudio and redirects browser flash plugin audio output -stream to it, also starting "parec" and oggenc to record/encode whatever happens -there. - -Can be useful to convert video to podcast if downloading flv is tricky for -whatever reason. - - - -##### [pa_track_history](desktop/media/pa_track_history) - -Queries pa sinks for specific pid (which it can start) and writes "media.name" -(usually track name) history, which can be used to record played track names -from e.g. online radio stream in player-independent fashion. - - - -##### [pa_modtoggle](desktop/media/pa_modtoggle) - -Script to toggle - load or unload - pulseaudio module. - -For example, to enable/disable forwarding sound over network (e.g. to be played -in vlc as rtp://224.0.0.56:9875): - - % pa_modtoggle module-rtp-send \ - source=alsa-speakers.monitor destination=224.0.0.56 port=9875 - Loaded: [31] module-rtp-send source=alsa-speakers.monitor destination=224.0.0.56 port=9875 - -Same exact command will unload the module (matching it by module name only), if necessary. - -Optional `-s/--status` flag can be used to print whether module is currently loaded. - -Uses/requires [pulsectl module], python. - -[pulsectl module]: https://github.com/mk-fg/python-pulse-control/ - - - -##### [mpv_icy_track_history](desktop/media/mpv_icy_track_history) - -Same as pa_track_history above, but gets tracks when [mpv] dumps icy-\* tags -(passed in shoutcast streams) to stdout, which should be at the start of every -next track. - -More efficient and reliable than pa_track_history, but obviously mpv-specific. - -[mpv]: https://mpv.io/ - - - -##### [icy_record](desktop/media/icy_record) - -Simple script to dump "online radio" kind of streams to a bunch of separate -files, split when stream title (as passed in icy StreamTitle metadata) changes. - -By default, filenames will include timestamp of recording start, sequence -number, timestamp of a track start and a stream title (in a filename-friendly form). - -Sample usage: `icy_record --debug -x https://pub5.di.fm/di_vocaltrance` - -Note that by default dumped streams will be in some raw adts format (as streamed -over the net), so maybe should be converted (with e.g. ffmpeg) afterwards. - -This doesn't seem to be an issue for at least mp3 streams though, which work -fine as "MPEG ADTS, layer III, v1" even in dumb hardware players. - - - -##### [radio](desktop/media/radio) - -Wrapper around mpv_icy_track_history to pick and play hard-coded radio streams -with appropriate settings, generally simplified ui, logging and echoing what's -being played, with a mute button (on SIGQUIT button from terminal). +Scripts - mostly wrappers around ffmpeg and pulseaudio - to work with +(or process) various media files and streams. diff --git a/desktop/media/icy_record b/desktop/media/icy_record deleted file mode 100755 index c11971a..0000000 --- a/desktop/media/icy_record +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- -from __future__ import print_function - -import itertools as it, operator as op, functools as ft -from contextlib import closing -from collections import deque -from datetime import datetime -from threading import Event -import os, sys, re, signal - -import requests - -p = lambda fmt,*a,**k: print(fmt.format(*a,**k), file=sys.stderr) -try: - import pyaml - pp = lambda data: pyaml.dump(data, sys.stderr) -except ImportError: - from pprint import pprint as pp - - -def find_frame_n(chunk0=None, chunk1=None, n_target=None): - '''Locates start of adts frame as close to the middle of chunk0 as possible. - Presumably stream title changes between chunk0 - and chunk1, so new track is expected to start somewhere in chunk0. - Returns 0 <= n < (len(chunk0) + len(chunk1)).''' - # http://wiki.multimedia.cx/index.php?title=ADTS - chunk0, chunk1 = chunk0 or '', chunk1 or '' - if n_target is None: n_target = len(chunk0) // 2 - nn, n, n_offset = None, -1, None - chunk = chunk0 + chunk1 - for m in xrange(len(chunk)): - n = chunk.find('\xff', n+1) - if n == -1: break - if ord(chunk[n+1]) >> 4 != 0xf: continue - n_offset_new = abs(n_target - n) - if n_offset is None or n_offset_new < n_offset: nn, n_offset = n, n_offset_new - else: break - else: raise RuntimeError(chunk) - if nn is None: - log.debug('Failed to find ADTS frame header in supplied chunks') - nn = n_target - return nn - - -filename_subs = { - r'[\\/]': '_', r'^\.+': '_', r'[\x00-\x1f]': '_', r':': '-_', - r'<': '(', r'>': ')', r'\*': '+', r'[|!"]': '-', r'[\?\*]': '_', - '[\'’]': '', r'\.+$': '_', r'\s+$': '', r'\s': '_' } -filename_subs = list( - (re.compile(k), v) for k,v in filename_subs.viewitems() ) - -def filename_process(name): - for sub_re, sub in filename_subs: name = sub_re.sub(sub, name) - return name - -def ts_format(fmt=None, ts=None): - if not fmt: return '' - if not ts: ts = datetime.now() - return ts.strftime(fmt) - - -def main(args=None): - import argparse - parser = argparse.ArgumentParser( - description='Follow specified icy (shoutcast/icecast/*cast)' - ' stream and dump individual tracks from it to a separate files.') - parser.add_argument('url', help='URL of an http stream to process.') - parser.add_argument('-d', '--dir', metavar='path', - help='Directory to store tracks in (default: current dir).') - parser.add_argument('-f', '--filename-format', - metavar='format', default='{ts_start}__{n:03d}__{ts}__{name}.mp3', - help='Filename template to use for each track (default: %(default)r).') - parser.add_argument('-t', '--ts-format', - metavar='strftime_format', default='%Y%m%d-%H%M%S', - help='Format to use for timestamps in --filename-format' - ' (as parsed by datetime.strftime in python, default: %(default)s).') - parser.add_argument('-x', '--cut-on-meta-blocks', action='store_true', - help='Assume that media blocks always belong to track in the following icy-meta.' - ' Seem to be the case with at least some online radios.') - parser.add_argument('-1', '--skip-first', action='store_true', - help='Skip dumping first track in the stream, which should be incomplete.') - parser.add_argument('-s', '--skip-regexp', metavar='title_regexp', - help='Skip dumping tracks where title matches specified (python) regexp.') - # parser.add_argument('-c', '--convert', action='store_true', - # help='Convert each track to ogg in the background after it is stored.') - parser.add_argument('--debug', action='store_true', help='Verbose operation mode.') - opts = parser.parse_args(sys.argv[1:] if args is None else args) - - global log - import logging - logging.basicConfig( - level=logging.DEBUG if opts.debug else logging.WARNING, - format='%(asctime)s :: %(levelname)s :: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' ) - log = logging.getLogger() - - if opts.dir: os.chdir(os.path.expanduser(opts.dir)) - skip_regexp = opts.skip_regexp and re.compile(opts.skip_regexp) - - r = requests.get(opts.url, headers={'Icy-MetaData': '1'}, stream=True) - with closing(r): - r.raise_for_status() - bs = int(r.headers.get('icy-metaint') or 0) - if not bs: - p('HTTP response is missing "icy-metaint" header, aborting.') - pp(dict(response=dict(code=r.status_code, headers=dict(r.headers.items())))) - return 1 - log.debug('icy-metaint block size: %dB', bs) - - ts_start = ts_format(opts.ts_format) - title_err_streak, title_err_streak_max = 0, 10 - title = title_new = None - chunk = chunk_new = '' - dst, dst_buff, dst_n, dst_n_iter = None, deque(), 0, iter(xrange(1, 2**30)) - n_target = 0 if opts.cut_on_meta_blocks else None - - title_changed = Event() - signal.signal(signal.SIGQUIT, lambda sig,frm: title_changed.set()) - - try: - while True: - chunk_new = r.raw.read(bs) - - if not title_changed.is_set(): dst_buff.append(chunk_new) - else: - if not title_new: - title_new = '{} [#{:03d}]'.format(re.sub(r'^(.*) \[#\d+\]$', r'\1', title), dst_n) - log.info('Detected stream title change: %r -> %r', title, title_new) - n = find_frame_n(chunk, chunk_new, n_target=n_target) - if n >= len(chunk): - dst_buff.append(chunk) - n = n - len(chunk) - chunk, chunk_new = chunk_new[:n], chunk_new[n:] - dst_buff.append(chunk) - else: - chunk, chunk_new = chunk[:n], chunk[n:] + chunk_new - dst_buff.append(chunk) - if not opts.skip_first or title is not None: chunk = None - title = title_new - title_changed.clear() - - if dst_buff and dst: - while dst_buff: dst.write(dst_buff.popleft()) - if chunk is None and title is not None: - if dst: - dst.close() - dst = None - dst_buff.clear() - if skip_regexp and skip_regexp.search(title): - log.debug('Skipping dump of track title: %r', title) - else: - dst_n = next(dst_n_iter) - name = opts.filename_format.format( ts_start=ts_start, n=dst_n, - ts=ts_format(opts.ts_format), name=filename_process(title) ) - log.debug('Creating new file: %r', name) - dst = open(name, 'wb') - chunk = chunk_new - if not chunk: - log.debug('Reached the end of stream, exiting') - return - - title_new_bs = r.raw.read(1) - if not title_new_bs: continue - title_new_bs = ord(title_new_bs) * 16 - title_new = r.raw.read(title_new_bs).rstrip('\0') - if title_new: - m = re.search(r'\bStreamTitle=\'(.*?)\';', title_new) - if not m: - log.error( 'Failed to process stream' - ' title block (len: %dB): %r', title_new_bs, title_new ) - title_err_streak += 1 - if title_err_streak > title_err_streak_max: - p('Too many title-parsing errors in a row - probably a desync issue, aborting') - return 1 - continue - else: title_new, title_err_streak = m.group(1), 0 - if title_new != title: title_changed.set() - - finally: - if dst: dst.close() - -if __name__ == '__main__': sys.exit(main()) diff --git a/desktop/media/mpv_icy_track_history b/desktop/media/mpv_icy_track_history deleted file mode 100755 index 8209016..0000000 --- a/desktop/media/mpv_icy_track_history +++ /dev/null @@ -1,265 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- -from __future__ import print_function - -from subprocess import Popen, PIPE, STDOUT -from datetime import datetime -import os, sys, logging, string, re, time, signal, tempfile, atexit - - -# Since mpv-0.6.2, it doesn't seem to accept chars to stdin as control anymore -# This was used to send "mute" key to it ("m") -# Following code is the workaround to toggle mute for the stream of mpv pid instead - -_bus_cache = None -def get_bus(srv_addr=None): - global _bus_cache - if not srv_addr and _bus_cache is not None: return _bus_cache - import dbus - if srv_addr is None: - srv_addr = os.environ.get('PULSE_DBUS_SERVER') - if not srv_addr\ - and os.access('/run/pulse/dbus-socket', os.R_OK | os.W_OK): - # Well-known system-wide daemon socket - srv_addr = 'unix:path=/run/pulse/dbus-socket' - if not srv_addr: - srv_addr = dbus.SessionBus().get_object( - 'org.PulseAudio1', '/org/pulseaudio/server_lookup1')\ - .Get('org.PulseAudio.ServerLookup1', - 'Address', dbus_interface='org.freedesktop.DBus.Properties') - _bus_cache = dbus.connection.Connection(srv_addr) - return _bus_cache - -def dbus_bytes(dbus_arr, strip='\0' + string.whitespace): - return bytes(bytearray(dbus_arr).strip(strip)) - -def pa_mute(pid, val, srv_addr=None): - import dbus - bus = get_bus(srv_addr=srv_addr) - streams = bus.get_object(object_path='/org/pulseaudio/core1')\ - .Get( 'org.PulseAudio.Core1', 'PlaybackStreams', - dbus_interface='org.freedesktop.DBus.Properties' ) - if not streams: return None - for path in streams: - stream = dbus.Interface( - bus.get_object(object_path=path), - dbus_interface='org.freedesktop.DBus.Properties' ) - props = stream.Get('org.PulseAudio.Core1.Stream', 'PropertyList') - stream_pid = int(dbus_bytes(props['application.process.id'])) - if stream_pid == pid: - stream.Set('org.PulseAudio.Core1.Stream', 'Mute', bool(val)) - break - else: - logging.getLogger('pactl').warn('Failed to find pa stream for pid: {}'.format(pid)) - - -track_max_len = 16384 -mute_prefix_default = 'muted: ' - -class MpvCtl(object): - - player_cmd_base = ['mpv', '--term-status-msg='] - player = track_last = None - muted = muted_auto = muted_change = False - mute_types = 'stdin', 'pulse', 'input-file' - - def __init__( self, dst_file=None, - mute_re=None, mute_delay=None, mute_type='input-file', mute_prefix=None, - recode=None, line_format=None, ts_format=None ): - self.log = logging.getLogger('mpvctl') - - self.line_format = line_format or '{ts}{mute}{}\n' - self.ts_format, self.recode = ts_format, recode - self.mute_re, self.mute_delay = mute_re, mute_delay - self.mute_type, self.mute_prefix = mute_type, mute_prefix - assert self.mute_type in self.mute_types, [self.mute_type, self.mute_types] - if dst_file: - self.dst = dst = open(dst_file, 'a+b') - dst.seek(max(0, os.fstat(dst.fileno()).st_size - (track_max_len + 2))) - last_line = dst.read() - if '\n' in last_line: - self.track_last = last_line.rstrip('\r\n').rsplit('\n', 1)[-1].strip() - else: - self.dst = sys.stdout - - self.install_mute_sig_handler() - if mute_re: self.install_mute_sig_handler(signal.SIGALRM) - - def install_mute_sig_handler(self, sig=signal.SIGQUIT): - signal.signal(sig, self.send_mute) - - def terminate(self): - if not (self.player and self.player.poll() is None): return - self.log.debug('Terminating running player instance') - self.player.terminate() - - def send_mute(self, sig=None, frm=None): - if not self.player: return - if self.muted_change: return - self.muted_change = True # racy lock - self.muted = not self.muted - auto = sig is True or sig == signal.SIGALRM - if auto: - if not self.muted_auto: return - elif self.muted_auto: self.muted_auto = False # manual action - if not self.muted: self.muted_auto = False - self.log.debug( 'Toggling "mute" on a running player' - ' instance (sig: %s, auto: %s) to: %s', sig, auto, self.muted ) - if self.mute_type == 'input-file': - with open(self.player.input_fifo, 'wb') as dst: - dst.write('set mute {}\n'.format(['no', 'yes'][bool(self.muted)])) - elif self.mute_type == 'stdin': self.player.stdin.write('m') - elif self.mute_type == 'pulse': pa_mute(self.player.pid, self.muted) - else: raise ValueError(self.mute_type) - self.muted_change = False - return self.muted - - def _send_mute_auto(self): - if not self.mute_delay or self.mute_delay < 0: self.send_mute(True) - else: signal.alarm(self.mute_delay) - - def send_mute_auto(self, enable): - if enable: - if self.muted: return - self.muted_auto = True - self._send_mute_auto() - else: - if not self.muted_auto: return - self._send_mute_auto() - - def track_match(self, line): - line = line.strip() - if not line.startswith('icy-title:'): return - track = line[10:].strip() - if self.track_last and self.track_last.endswith(track): return - self.track_last = track - return track - - def track_changed(self, track): - mute_prefix = '' - if self.mute_re: - if self.mute_re.search(track): - self.send_mute_auto(True) - if self.mute_prefix: mute_prefix = self.mute_prefix - elif self.muted_auto: self.send_mute_auto(False) - if self.recode: - track = track\ - .decode(self.recode, 'replace')\ - .encode('utf-8', 'backslashreplace') - # "datetime" allows for more strftime options here than just "time" - ts = datetime.now().strftime(self.ts_format) if self.ts_format else '' - self.dst.write(self.line_format.format(track, ts=ts, mute=mute_prefix)) - self.dst.flush() - - def track_dump_loop(self, opts): - player_cmd, player_input = self.player_cmd_base, None - if self.mute_type == 'input-file': - player_input = os.path.join( - tempfile.gettempdir(), - '.mpv_input.{}.fifo'.format(os.getpid()) ) - os.mkfifo(player_input, 0700) # probably insecure - atexit.register(os.unlink, player_input) - player_cmd.extend(['--input-file', player_input]) - elif self.mute_type == 'stdin': player_cmd.append('--input-terminal') - player_cmd.extend(opts.mpv_args) - - ts = time.time() - while True: - if self.player: - player_poll = self.player.poll() - if player_poll is not None: - player_poll, self.player = self.player.wait(), None - if player_poll != 0: - self.log.error('mpv failed (exit code: %s), exiting', player_poll) - return player_poll - - if not self.player: - self.player = Popen( player_cmd, - stdin=PIPE, stdout=PIPE, stderr=STDOUT, preexec_fn=os.setsid ) - self.player.input_fifo = player_input - - line_last = None - for line in iter(self.player.stdout.readline, ''): - ls = line.rstrip() - if opts.passthrough and ls != line_last: self.log.debug(ls) - line_last = ls - track = self.track_match(line) - if not track: continue - self.track_changed(track) - - -def main(args=None): - import argparse - parser = argparse.ArgumentParser( - description='Record whatever is playing in mpv (as supplied by icy-* tags) to some file.' - ' Sending SIGQUIT (Ctrl+\ from terminal) toggles mute for the playback' - ' (actually writes "m" char to mpv stdin, which is bound to "mute" by default).') - parser.add_argument('mpv_args', nargs='+', - help='Options/arguments (including playback source) to pass to mpv.' - ' Some terminal-io options will be prepended to these as well.' - ' Use "--" to make sure its options wont get processed by this wrapper script.') - - parser.add_argument('-m', '--mute-regexp', metavar='regexp', - help='Toggle mute than track name matches' - ' that regexp, unmuting than name changes again.') - parser.add_argument('--mute-delay', type=float, metavar='seconds', default=1, - help='When auto-toggling mute, delay by this many seconds' - ' (useful for long track crossfades, default: %(default)s).') - parser.add_argument('--mute-type', - metavar='type', default='input-file', choices=MpvCtl.mute_types, - help='Way to mute the stream. Possible choices: stdin, pulse, input-file.' - ' "stdin" is the "old" way to do it, by writing "m"' - ' to mpv stdin, doesnt seem to work with newer mpv versions.' - ' "pulse" will use dbus to mute stream corresponding to' - ' mpv pid in pulseaudio - reliable, but only works with PA, obviously.' - ' "input-file" (default) will use fifo and --input-file option for mpv' - ' to pass "set mute" commands, probably the best way to do it.') - parser.add_argument('--mute-title-prefix', - nargs='?', default=False, metavar='prefix', - help=( - 'Add prefix (can be specified as optional arg,' - ' default: {}) to track titles that match --mute-regexp.' - ' If --line-format is used, passed to it as "mute" keyword.')\ - .format(mute_prefix_default)) - - parser.add_argument('-d', '--dst-file', - help='Path to a file to record all the stuff to. If omitted, stdout will be used.') - parser.add_argument('-f', '--line-format', - help='Format for each output line. Line itself is passed as the first arg.') - parser.add_argument('-t', '--timestamp', action='store_true', - help='Prepend timestamps to each track entry in the output.' - ' If --line-format is used, passed to it as "ts" keyword.') - parser.add_argument('--timestamp-format', - metavar='py_ts_format', default='[%Y-%m-%d %H:%M] ', - help='Format for timestamp-prefix to be prepended to each line (default: %(default)s).' - ' Should be compatible with pythons strftime() functions.') - - parser.add_argument('-e', '--recode-from', metavar='encoding', - help='Decode titles from specified encoding and encode to utf-8.') - - parser.add_argument('--passthrough', - action='store_true', help='Pass all mpv stdout lines to debug logging.') - parser.add_argument('--debug', action='store_true', help='Verbose operation mode.') - opts = parser.parse_args(sys.argv[1:] if args is None else args) - - logging.basicConfig(level=logging.DEBUG if opts.debug else logging.WARNING) - - mute_re = None - if opts.mute_regexp: - mute_re = opts.mute_regexp - if mute_re.endswith('$\n'): mute_re = mute_re.rstrip() # common yaml quirk - mute_re = re.compile(mute_re) - mute_title_prefix = opts.mute_title_prefix or None - if opts.mute_title_prefix is None: - opts.mute_title_prefix = mute_prefix_default - - mpvctl = MpvCtl( - dst_file=opts.dst_file, recode=opts.recode_from, - line_format=opts.line_format, - mute_delay=opts.mute_delay, mute_re=mute_re, - mute_type=opts.mute_type, mute_prefix=opts.mute_title_prefix, - ts_format=opts.timestamp and opts.timestamp_format ) - try: return mpvctl.track_dump_loop(opts) - finally: mpvctl.terminate() - -if __name__ == '__main__': sys.exit(main()) diff --git a/desktop/media/pa_modtoggle b/desktop/media/pa_modtoggle deleted file mode 100755 index 6fe39ce..0000000 --- a/desktop/media/pa_modtoggle +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python3 - -from pulsectl import Pulse -import os, sys - - -def main(args=None): - import argparse - parser = argparse.ArgumentParser( - description='Tool to toggle module loading/unloading with same one command.') - parser.add_argument('module', help='Module name to toggle.') - parser.add_argument('params', nargs='*', help='Module parameters, if any.') - parser.add_argument('-s', '--status', action='store_true', - help='Do not load/unload anything, only show whether module is loaded and exit.') - opts = parser.parse_args(sys.argv[1:] if args is None else args) - - with Pulse('pa-modtoggle') as pulse: - loaded = mod = None - for mod in pulse.module_list(): - if mod.name == opts.module: - loaded = True - break - else: loaded = False - - if opts.status: - print('Module {!r} status: {}'.format( - opts.module, ['disabled', 'enabled'][bool(loaded)] )) - else: - if loaded: - print('Unloaded: [{}] {} {}'.format(mod.index, mod.name, mod.argument)) - pulse.module_unload(mod.index) - else: - index = pulse.module_load(opts.module, opts.params) - print('Loaded: [{}] {} {}'.format(index, opts.module, ' '.join(opts.params))) - -if __name__ == '__main__': sys.exit(main()) diff --git a/desktop/media/pa_track_history b/desktop/media/pa_track_history deleted file mode 100755 index 066be71..0000000 --- a/desktop/media/pa_track_history +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- -from __future__ import print_function - -import os, sys, string, re, time - - -track_max_len = 16384 - -def get_bus(srv_addr=None): - import dbus - if srv_addr is None: - srv_addr = os.environ.get('PULSE_DBUS_SERVER') - if not srv_addr\ - and os.access('/run/pulse/dbus-socket', os.R_OK | os.W_OK): - # Well-known system-wide daemon socket - srv_addr = 'unix:path=/run/pulse/dbus-socket' - if not srv_addr: - srv_addr = dbus.SessionBus().get_object( - 'org.PulseAudio1', '/org/pulseaudio/server_lookup1')\ - .Get('org.PulseAudio.ServerLookup1', - 'Address', dbus_interface='org.freedesktop.DBus.Properties') - return dbus.connection.Connection(srv_addr) - -def dbus_bytes(dbus_arr, strip='\0' + string.whitespace): - return bytes(bytearray(dbus_arr).strip(strip)) - -def _track_for_pid(pid, srv_addr=None): - bus = get_bus(srv_addr=srv_addr) - streams = bus.get_object(object_path='/org/pulseaudio/core1')\ - .Get( 'org.PulseAudio.Core1', 'PlaybackStreams', - dbus_interface='org.freedesktop.DBus.Properties' ) - streams = list( bus.get_object(object_path=path)\ - .Get('org.PulseAudio.Core1.Stream', 'PropertyList') - for path in streams ) - if not streams: return None - for stream in streams: - stream_pid = int(dbus_bytes(stream['application.process.id'])) - if stream_pid == pid: return dbus_bytes(stream['media.name']) - else: log.warn('Failed to find pa stream for pid: {}'.format(pid)) - -child_ctl = None -def track_for_pid(pid, srv_addr=None, dbus_reuse=False): - if dbus_reuse: - return _track_for_pid(pid, srv_addr=srv_addr) - - # Forking here is to prevent dbus module from keeping state, - # which seem to prevent it from working on any pulseaudio hiccup - global child_ctl - if child_ctl: os.close(child_ctl) - (r1,w1), (r2,child_ctl) = os.pipe(), os.pipe() - child_pid, track = os.fork(), None - if child_pid: # parent - os.close(w1), os.close(r2) - try: - track = os.read(r1, track_max_len + 1) - os.close(r1) - os.write(child_ctl, 'x') - except (OSError, IOError): pass - try: os.waitpid(child_pid, 0) - except OSError: pass - else: # child - os.close(r1), os.close(child_ctl) - try: - track = _track_for_pid(pid, srv_addr=srv_addr) - os.write(w1, track or '\0') - assert os.read(r2, 1) == 'x' - except Exception as err: - log.warn('Failed to query pa via dbus: %s', err) - os._exit(0) - return track if track and track != '\0' else None - - -def proc_list(): - import glob - cmds = list() - for p in glob.glob('/proc/*/cmdline'): - try: pid = int(p.split('/', 3)[2]) - except ValueError: continue # e.g. "self" - try: - with open(p) as src: cmd = src.read() - except (OSError, IOError): continue - cmd = cmd.strip('\0') - if cmd: cmds.append((pid, cmd.split('\0'))) - return cmds - -def proc_match(regexp, procs=None): - if procs is None: procs = proc_list() - for pid, cmd in procs: - cmd = ' '.join(cmd) - if re.search(regexp, cmd): return pid - - -def track_dump_loop(opts): - track_last = None - if opts.dst_file: - dst = open(opts.dst_file, 'a+b') - dst.seek(max(0, os.fstat(dst.fileno()).st_size - (track_max_len + 2))) - last_line = dst.read() - if '\n' in last_line: - track_last = last_line.rstrip('\r\n').rsplit('\n', 1)[-1].strip() - else: dst = sys.stdout - - pid, ts = None, time.time() - while True: - if not opts.prog: - if not pid: pid = proc_match(opts.pgrep) if not opts.pgrep.isdigit() else int(opts.pgrep) - else: - player_poll = player.poll() if pid else None - if not pid or player_poll == 0: - global err_hook, devnull - from subprocess import Popen - player = dict() - if opts.quiet: - if not devnull: devnull = open(os.devnull, 'wb') - player.update(stdout=devnull, stderr=devnull) - player = Popen(opts.prog, **player) - pid, err_hook = player.pid, lambda p=player: p.poll() is None and p.terminate() - elif pid and player_poll is not None: - log.error('Player app failed (exit code: %s), exiting', player_poll) - return player_poll - - if pid is not None: - track = track_for_pid(pid, dbus_reuse=opts.once) - if track: - if opts.strip: track = re.sub(opts.strip, '', track) - if track and not (track_last and track_last.endswith(track)): - track_last = track - if opts.timestamp: prefix = time.strftime(opts.timestamp_format) - else: prefix = '' - dst.write('{}{}\n'.format(prefix, track)) - dst.flush() - - else: - log.error('Failed to get stream pid') - - if opts.once: break - ts, ts_to = time.time(), ts + opts.poll_interval - while ts_to <= ts: ts_to += opts.poll_interval - time.sleep(ts_to - ts) - ts = ts_to - -def main(args=None): - import argparse - parser = argparse.ArgumentParser( - description='Record whatever is playing in some pid via pulse to some file.' - ' Takes "media.name" parameter from PulseAudio Stream and records any changes to it.') - parser.add_argument('prog', nargs='*', help='Playback app to run.' - ' Use "--" to make sure its options wont get processed by this wrapper script.') - - parser.add_argument('-d', '--dst-file', - help='Path to a file to record all the stuff to. If omitted, stdout will be used.') - parser.add_argument('-t', '--timestamp', action='store_true', - help='Prepend timestamps to each track entry in the output.') - parser.add_argument('--timestamp-format', - metavar='py_ts_format', default='[%Y-%m-%d %H:%M] ', - help='Format for timestamp-prefix to be prepended to each line (default: %(default)s).' - ' Should be compatible with pythons strftime() functions.') - - parser.add_argument('-1', '--once', action='store_true', help='Sample once and exit.') - parser.add_argument('-i', '--poll-interval', - type=float, metavar='seconds', default=80, - help='Interval between sampling track name (default: %(default)s).') - parser.add_argument('-p', '--pgrep', - metavar='{pid | cmdline_regexp}', default=r'^mpv\s+', - help='Grep for this regexp in processes' - ' to find pid that is used as a player (default: %(default)s).' - ' If integer is specified, it will be used as a pid without any extra matching.' - ' Disregarded, if playback app is specified in args.' - ' Args in matched cmdlines are separated by spaces. Pid only gets matched on script start.') - parser.add_argument('-s', '--strip', - metavar='regexp', default=r'^mpv\s+-\s+', - help='Regexp for bits to strip from produced title (default: %(default)s).' - ' Can be set to empty string to not strip anything.') - - parser.add_argument('-q', '--quiet', - action='store_true', help='Supress all output from started player pid.') - parser.add_argument('--debug', action='store_true', help='Verbose operation mode.') - opts = parser.parse_args(sys.argv[1:] if args is None else args) - - global log, err_hook, devnull - import logging - logging.basicConfig(level=logging.DEBUG if opts.debug else logging.WARNING) - log = logging.getLogger() - err_hook = devnull = None - - try: return track_dump_loop(opts) - finally: - if err_hook is not None: err_hook() - -if __name__ == '__main__': sys.exit(main()) diff --git a/desktop/media/parec_from_flash b/desktop/media/parec_from_flash deleted file mode 100755 index 5fa9366..0000000 --- a/desktop/media/parec_from_flash +++ /dev/null @@ -1,79 +0,0 @@ -#!/bin/bash - - -### Process CLI - -idx= output= output_opts=() sink=wiretap - -while [[ -n "$1" ]]; do - case "$1" in - -h|--help) echo "Usage: $0 $(awk ' - func print_arg() { - if (!ac) ac=""; else ac=sprintf(" ...(%s)", ac) - if (ap) printf("[ %s%s ] ", ap, ac) } - /^\s+case\>/ {parse=1; next} - /^\s+esac\>/ {print_arg(); exit} - !parse {next} - match($0, /^\s*([\-|a-z]+)\)/, a) { print_arg(); ap=a[1]; ac=0 } - !match(ap,/\<-h|--help\>/)\ - {for (i=1;i/)) ac++}'\ - $0)" - exit 0 ;; - -d|--debug) set -x ;; - -s|--sink-name) shift; sink=$1 ;; - -o|--output) shift; output=$1 ;; - -i|--index) shift; idx=( $1 ) ;; - --) output_opts=true - [[ -z "$output" ]] && { echo >&2 "Need --output set to use oggenc"; exit 1; } ;; - *) - [[ -n "$output_opts" ]] && { output_opts=( "$@" ); break; } - echo "Unknown arg/option: $1" && exit 1 ;; - esac - shift -done - - -### Try to auto-guess index - -if [[ -z "$idx" ]]; then - idx=( $( - pacmd list-sink-inputs | - awk ' - match($0,/^\s*index: ([0-9]+)\s*$/,a) {idx=a[1]} - idx!="" && $1=="application.process.binary" &&\ - match($0,/\/) {print idx}' ) ) - [[ -z "$idx" ]] && { - echo >&2\ - 'Failed to get stream index from "pacmd list-sink-inputs",'\ - ' specify it on commandline or something' - exit 1 - } -fi - - -### Find existing sink with that name, or create one - -sink_idx=$( - pacmd list-sinks | - awk ' - match($0,/^\s*index: ([0-9]+)\s*$/,a)\ - {if (parse) print idx; parse=1} - parse && $1=="name:" &&\ - !match($2,/<\<'"$sink"'\>>/) {parse=0} - parse && $1=="module:" {idx=$2} - END {if (parse) print idx}' ) - -[[ -z "$sink_idx" ]] && sink_idx=$( - pactl load-module module-null-sink sink_name="$sink" ) -[[ -z "$sink_idx" ]] && { echo >&2 'Failed to get/open sink'; exit 1; } - -trap "pactl unload-module $sink_idx" EXIT - -for i in "${idx[@]}" -do pactl move-sink-input "$i" "$sink" -done - -if [[ -z "$output" ]] -then parec -d "$sink".monitor -else parec -d "$sink".monitor | oggenc -o "$output" --raw "${output_opts[@]}" - -fi diff --git a/desktop/media/radio b/desktop/media/radio deleted file mode 100755 index 44c63be..0000000 --- a/desktop/media/radio +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- -from __future__ import print_function - -import itertools as it, operator as op, functools as ft -from tempfile import NamedTemporaryFile -import os, sys, io, re, requests, yaml, subprocess, signal, logging - - -player_wrap = 'media.mpv_icy_track_history' # should be in the sampe repo -tracks_global = os.path.expanduser('~/tracks.txt') - -# --no-cache to prevent any audio/meta de-sync, so mute would work nicely -# Alternative: '--cache=1024', '--cache-initial=0' -mpv_args_base = ['--shuffle', '--loop=inf', '--no-cache', '--vo=null'] - -# radios_conf example (yaml): -# rmnrelax: -# url_pls: 'http://www.rmnrelax.de/aacplus/rmnrelax.m3u' -# args_wrapper: ['--mute-regexp=^\[RMN\]', '--recode-from=latin-1'] -# etnfm: -# url_pls: 'http://ch1relay1.etn.fm:8100/listen.pls?sid=1' -# soma: -# template: true -# url_pls: 'http://somafm.com/{}.pls' -# difm: -# # use as e.g. "difm.vocaltrance" (to template "vocaltrance" into urls) -# template: true -# #url_pls: 'http://listen.di.fm/public3/{}.pls?3e4c569' -# url: 'http://pub5.di.fm:80/di_{}?3e4c569' -# args_wrapper: -# - > -# --mute-regexp=^(There|More of the show after these messages|Choose -# premium for the best audio experience|Digitally Imported TSTAG_60 ADWTAG|Job -# Opportunity at DI!.*|There's more to Digitally Imported!)$ -# - --mute-title-prefix -radios_conf_default = '~/.radio.yaml' - - -_notify_init = False -def notify(title, body, critical=False, timeout=None, icon=None, fork=True): - global _notify_init - if fork and os.fork(): return - try: - import gi - gi.require_version('Notify', '0.7') - from gi.repository import Notify - if not _notify_init: - Notify.init('radio') - _notify_init = True - note = Notify.Notification.new(summary=title, body=body, icon=icon) - if critical: note.set_urgency(Notify.Urgency.CRITICAL) - if timeout is not None: note.set_timeout(timeout) - note.show() - except: pass - if fork: os._exit(0) - -def no_such_chan(err, chans): - p = ft.partial(print, file=sys.stderr) - p(err) - p('\nPossible matches:') - for chan in chans: p(' ' + chan) - sys.exit(1) - -def get_url(chan, pls=False): - url = chan.get('url' if not pls else 'url_pls') - if url and chan.get('template'): - assert re.search(r'\{(\d+(:.*)?)?\}', url), url - name = chan['name'].split('.', 1)[-1] - url = url.format(name) - return url - -def main(args=None): - import argparse - parser = argparse.ArgumentParser(description='Start radio playback.') - parser.add_argument('chan', - help='Radio channel (exact name or unique part of it) to tune into.') - parser.add_argument('-c', '--conf', - metavar='path', default=radios_conf_default, - help='Path to the configuration file with a list of radios.' - ' See head of this script for an example. Default: %(default)s') - parser.add_argument('--desktop-notification-props', - default='{fork: false, icon: amarok, critical: true}', metavar='yaml_data', - help='Desktop notification properties (keywords) to pass to notify() function (see code).' - ' "false" (bool value) can be passed here to disable desktop notifications entirely.' - ' Default: %(default)s') - parser.add_argument('--debug', action='store_true', help='Verbose operation mode.') - opts = parser.parse_args(sys.argv[1:] if args is None else args) - - logging.basicConfig(level=logging.DEBUG if opts.debug else logging.WARNING) - log = logging.getLogger('radio') - - files_cleanup = set() - radios = os.path.expanduser(opts.conf) - with io.open(radios) as src: radios = yaml.safe_load(src) - - chan = chan_name = opts.chan - if chan not in radios: - match = list(k for k in radios if k.startswith(chan)) - if not match: - chan_tpl_base = chan.split('.', 1)[0] - match = radios.get(chan_tpl_base) - if match: match = match.get('template') and [chan_tpl_base] - if not match: match = list(k for k in radios if chan in k) - if not match: no_such_chan('Unable to match {!r}'.format(chan), radios.keys()) - if len(match) > 1: no_such_chan('Unable to uniquely match {!r}'.format(chan), match) - chan, = match - - log.debug('Using chan: %s', chan) - chan = radios[chan] - chan.update(name=chan_name) - - url = get_url(chan, pls=True) - if url: - r = requests.get(url) - r.raise_for_status() - pls = NamedTemporaryFile(prefix='radio.pls.') # will be cleaned-up by child pid - files_cleanup.add(pls.name) - pls.write(r.text) - pls.flush() - url = '--playlist={}'.format(pls.name) - else: - url = get_url(chan) - assert url, chan - - tracks = tracks_global - if 'tracks' in chan: tracks = chan['tracks'] - if not os.path.exists(tracks): - with open(tracks, 'ab'): pass - - notify_kws = yaml.safe_load(opts.desktop_notification_props) - if notify_kws is not False: notify_kws = notify_kws or dict() - - pid_player = os.getpid() - pid = os.fork() - if not pid: ### child - os.setsid() - - tail = subprocess.Popen(['tail', '-n1', '-f', tracks], stdout=subprocess.PIPE) - - def player_check(sig=None, frm=None): - try: os.kill(pid_player, 0) - except OSError: - tail.terminate() - sys.exit(0) - signal.signal(signal.SIGALRM, player_check) - signal.alarm(5 *60) - - for line in iter(tail.stdout.readline, ''): - player_check() - line = line.strip() - if notify_kws is not False: - notify('mpv: radio {}'.format(chan['name']), line, **notify_kws) - print(line) - - sys.exit(0) - - else: ### parent (player) - args_wrapper, args = chan.get('args_wrapper', list()), chan.get('args', list()) - args = mpv_args_base + args - - if opts.debug: args_wrapper.extend(['--debug', '--passthrough']) - if tracks: args_wrapper.extend([ '-d', tracks, '-t', - '--line-format', '{{ts}}{{mute}}{{}} [{}]\n'.format(chan['name']) ]) - args.append(url) - - args = [player_wrap] + args_wrapper + ['--'] + args - log.debug('Running: %s', ' '.join(args)) - os.execvp(player_wrap, args) - -if __name__ == '__main__': sys.exit(main())