diff options
-rw-r--r-- | .SRCINFO | 19 | ||||
-rw-r--r-- | .gitignore | 5 | ||||
-rw-r--r-- | Makefile | 21 | ||||
-rw-r--r-- | PKGBUILD | 27 | ||||
-rwxr-xr-x | rofi-twitch | 202 | ||||
-rw-r--r-- | rofi-twitch.desktop | 7 | ||||
-rw-r--r-- | rofi-twitch.png | bin | 0 -> 892 bytes |
7 files changed, 281 insertions, 0 deletions
diff --git a/.SRCINFO b/.SRCINFO new file mode 100644 index 000000000000..e601e1e4a32a --- /dev/null +++ b/.SRCINFO @@ -0,0 +1,19 @@ +pkgbase = rofi-twitch + pkgdesc = rofi-based launcher for Twitch streams + pkgver = 1.0.0 + pkgrel = 1 + arch = any + license = MIT + depends = rofi + depends = streamlink + depends = python-requests + options = !strip + source = rofi-twitch + source = rofi-twitch.desktop + source = rofi-twitch.png + sha1sums = 30fd3bb07d37917d7d9d2f1709344525d91801e3 + sha1sums = 97bafd654648421e7437d7ee113e5411e5f79212 + sha1sums = ca4eeea9f1b3671fe231853639672bffa9be7d82 + +pkgname = rofi-twitch + diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000000..730c90d4520f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.tar.xz +twitch.svg +/pkg/ +/src/ + diff --git a/Makefile b/Makefile new file mode 100644 index 000000000000..c4d24c4120ab --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +PY = rofi-twitch +ICON = $(PY).png +DESKTOP = $(PY).desktop + +pkg: .SRCINFO + makepkg --check --noconfirm -sf + +.SRCINFO: PKGBUILD + makepkg --check --printsrcinfo > $@ + +PKGBUILD: format pngopt $(DESKTOP) + updpkgsums + +format: $(PY) + yapf -i $< + +pngopt: $(ICON) + optipng $< + +.PHONY: format pngopt +.PRECIOUS: $(PY) $(ICON) $(DESKTOP) PKGBUILD diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 000000000000..e2ede96d3e35 --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,27 @@ +# Maintainer: Alexandre Macabies <web+oss@zopieux.com> +pkgname=rofi-twitch +pkgver=1.0.0 +pkgrel=1 +pkgdesc="rofi-based launcher for Twitch streams" +arch=('any') +license=('MIT') +depends=('rofi' + 'streamlink' + 'python-requests') +source=("${pkgname}" + "${pkgname}.desktop" + "${pkgname}.png") +sha1sums=('30fd3bb07d37917d7d9d2f1709344525d91801e3' + '97bafd654648421e7437d7ee113e5411e5f79212' + 'ca4eeea9f1b3671fe231853639672bffa9be7d82') +options=(!strip) + +package() { + cd "${srcdir}" + install -Dm755 "${srcdir}/${pkgname}" \ + "${pkgdir}/usr/bin/${pkgname}" + install -Dm644 "${srcdir}/${pkgname}.desktop" \ + "${pkgdir}/usr/share/applications/${pkgname}.desktop" + install -Dm644 "${srcdir}/${pkgname}.png" \ + "${pkgdir}/usr/share/pixmaps/${pkgname}.png" +} diff --git a/rofi-twitch b/rofi-twitch new file mode 100755 index 000000000000..7a538e9a937c --- /dev/null +++ b/rofi-twitch @@ -0,0 +1,202 @@ +#!/usr/bin/env python + +from collections import defaultdict +import requests +import subprocess +import sys + +s = requests.session() +s.headers['Client-ID'] = 'jnx6yo007d0ota439ikx5g8v26qzr8' +s.headers['User-agent'] = 'rofi-twitch v1.0 (compatible)' + +MANY_RESULTS = 100 +FEW_RESULTS = 10 + +CACHE = defaultdict(list) + + +def rofi(*args, body=None, prompt='', msg=''): + if prompt: + prompt += ' ' + args = [ + 'rofi', '-dmenu', '-i', '-no-auto-select', '-p', prompt + '❯', '-mesg', + msg, *args + ] + p = subprocess.run( + args, input=body or None, stdout=subprocess.PIPE, encoding='utf-8') + return p.returncode, p.stdout.strip() + + +def message(msg): + subprocess.run(['rofi', '-e', "rofi-twitch\n\n" + msg]) + + +def req(url, **kwargs): + p = s.get('https://api.twitch.tv/' + url, params=kwargs) + p.raise_for_status() + return p.json() + + +def cached(key, getter, *args): + key += '\0'.join(map(str, args)) + data = CACHE[key] + if data: + return data + data = CACHE[key] = getter(*args) + return data + + +def game_list(): + def getter(): + return req('helix/games/top', first=MANY_RESULTS)['data'] + + games = cached('games', getter) + if not games: + message("No games found. No connectivity? Twitch API broken?") + return exit, 1 + + gamestr = '\n'.join(g['name'] for g in games) + rc, gid = rofi( + '-no-custom', + '-format', + 'i', + '-kb-custom-1', + 'F2', + '-kb-custom-2', + 'F3', + body=gamestr, + msg="F2: search games ⋅ F3: search streams") + if rc == 10: + return game_search, + if rc == 11: + return stream_search, + if rc != 0: + return + try: + game = games[int(gid)] + return stream_list, game['id'], game['name'] + except (KeyError, ValueError): + pass + + +def game_search(): + rc, query = rofi(prompt="Search games") + if rc != 0: + return + + def getter(query): + return req( + 'kraken/search/games', + query=query, + type='suggest', + limit=FEW_RESULTS)['games'] + + games = cached('gsearch', getter, query) + if not games: + message(f"No game matching “{query}”.") + return game_search, + + gamestr = '\n'.join(g['localized_name'] for g in games) + rc, gid = rofi('-no-custom', '-format', 'i', body=gamestr) + if rc != 0: + return + try: + game = games[int(gid)] + return stream_list, game['_id'], game['localized_name'] + except (KeyError, ValueError): + pass + + +def stream_search(input=''): + rc, query = rofi(prompt="Search streams") + if rc != 0: + return + + def getter(query): + return req( + 'kraken/search/streams', + type='suggest', + query=query, + limit=FEW_RESULTS)['streams'] + + streams = cached('ssearch', getter, query) + if not streams: + message(f"No stream matching “{query}”.") + return stream_search, + + streamstr = '\n'.join(f"{s['viewers']:>7,d} " + f"{s['channel']['display_name']:<22s} " + f"⋅ {s['game']:<22s} " + f"⋅ {s['channel']['status']}" for s in streams) + rc, sid = rofi('-no-custom', '-format', 'i', body=streamstr) + if rc != 0: + return + try: + return launch_stream, streams[int(sid)]['channel']['name'] + except (KeyError, ValueError): + pass + + +def stream_list(game_id, game_name): + def getter(game_id): + return req( + 'helix/streams', type='live', game_id=game_id, + first=MANY_RESULTS)['data'] + + streams = cached('streams', getter, game_id) + if not streams: + message(f"No stream for {game_name}.") + return + + def user_getter(ids): + return req('helix/users', id=ids)['data'] + + ids = [s['user_id'] for s in streams] + users = {u['id']: u for u in cached('users', user_getter, ids)} + sstr = '\n'.join(f"{s['viewer_count']:>7,d} " + f"{users[s['user_id']]['display_name']:<22s} " + f"⋅ {s['title']}" for s in streams) + rc, sid = rofi( + '-no-custom', + '-format', + 'i', + body=sstr, + msg=f"Top streams for {game_name}") + if rc != 0: + return + try: + return launch_stream, users[streams[int(sid)]['user_id']]['login'] + except (KeyError, ValueError): + pass + + +def launch_stream(user): + link = f'https://www.twitch.tv/{user}' + streamlink = ['streamlink', link, 'best'] + subprocess.run(['i3-msg', 'exec', ' '.join(streamlink)]) + message(f"Starting streamlink for {link}. Enjoy!") + return exit, + + +def exit(code=0): + sys.exit(code) + + +def main(): + state = [(game_list, )] + + while state: + func, *args = state[-1] + ret = func(*args) + if ret is None: + # back + state.pop(-1) + else: + newfunc, *newargs = ret + # don't stack the same func twice + if newfunc != func: + state.append(ret) + + +if __name__ == '__main__': + main() diff --git a/rofi-twitch.desktop b/rofi-twitch.desktop new file mode 100644 index 000000000000..0721d0210be9 --- /dev/null +++ b/rofi-twitch.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Name=Twitch +Comment=rofi-based launcher for Twitch streams +Exec=/usr/bin/rofi-twitch +Icon=rofi-twitch +Type=Application + diff --git a/rofi-twitch.png b/rofi-twitch.png Binary files differnew file mode 100644 index 000000000000..f752c43e6308 --- /dev/null +++ b/rofi-twitch.png |