summarylogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.SRCINFO19
-rw-r--r--.gitignore5
-rw-r--r--Makefile21
-rw-r--r--PKGBUILD27
-rwxr-xr-xrofi-twitch202
-rw-r--r--rofi-twitch.desktop7
-rw-r--r--rofi-twitch.pngbin0 -> 892 bytes
7 files changed, 281 insertions, 0 deletions
diff --git a/.SRCINFO b/.SRCINFO
new file mode 100644
index 00000000000..e601e1e4a32
--- /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 00000000000..730c90d4520
--- /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 00000000000..c4d24c4120a
--- /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 00000000000..e2ede96d3e3
--- /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 00000000000..7a538e9a937
--- /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 00000000000..0721d0210be
--- /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
new file mode 100644
index 00000000000..f752c43e630
--- /dev/null
+++ b/rofi-twitch.png
Binary files differ