to quit 55 | 56 | Run in non-interactive mode. This is useful for integration with dmenu / rofi / conky. 57 | --non-interactive go Get customizable comma separated list of channel / game data 58 | --non-interactive kickstart <> Start channel directly without asking for confirmation 59 | 60 | ## Examples 61 | 62 | Using no argument while launching the script will check the status of every channel in the local database: 63 |  64 | 65 | Add channels to local database: 66 | 67 | $ twitchy -a bobross... 68 | $ twitchy -s 69 | 70 | Display all strings matching *obr*: 71 | 72 | $ twitchy obr 73 | Checking channels... 74 | Creative 75 | 1 bobross 80085 The Joy of Painting Monday Season 7 Marathon #painting... 76 | Sonic: Generations 77 | 2 mariobro 123 #WhereMuhPrincessAt? 78 | Wizard Poker 79 | 3 flatulentcobra 6969 Playing secret Paladin. Killing a puppy later. 80 | Channel number(s): 1-h 2-s 3-l 81 | 82 | Custom quality settings: Specify with hyphen next to channel number. 83 | E.g. <1-h 2-s 3-l> will simultaneously play 84 | channel 1 in high quality, 2 in source quality, and 3 in low quality. 85 | 86 | Watch specified channel(s) - Do not have to be in local database: 87 | 88 | $ twitchy -w northernlion cobaltstreak 89 | Checking channels... 90 | The Binding of Isaac: Afterbirth 91 | 1 northernlion 5757 Egg 92 | Channel number(s): 1 93 | 94 | ## Plugins 95 | 96 | Thanks to twitchy's `--non-interactive` flag, it is easy to integrate it 97 | with various tools, like the ones below. 98 | 99 | ### Albert 100 | 101 | Supports the excellent [Albert launcher](https://github.com/albertlauncher/albert) 102 | 103 |
105 | 106 | Move `twitchy_albert.py` in the `plugins` directory to `/usr/share/albert/org.albert.extension.python/modules` 107 | 108 | ### Py3status 109 | 110 | Supports the excellent [py3status](https://github.com/ultrabug/py3status) 111 |104 |
112 |
114 | 115 | Move `twitchy_py3status.py` in the `plugins` directory to somewhere 116 | in your py3status module paths as `twitchy.py`. 117 | 118 | ### Rofi 119 | 120 | Supports a custom [rofi](https://github.com/DaveDavenport/rofi) mode 121 |113 |
122 |
124 | 125 | Move `rofi-twitchy` in the `plugins` directory to somewhere on your 126 | `PATH`, and invoke it with: 127 | 128 | ``` 129 | $ rofi -modi twitchy:rofi-twitchy -show twitchy 130 | ``` 131 | 132 | Of course, you probably want to bind this command to a keyboard shortcut 133 | in your window manager. 134 | -------------------------------------------------------------------------------- /plugins/rofi-twitchy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ "$@" == "" ]] 4 | then 5 | twitchy --non-interactive | cut -d"," -f2,3 | sort | awk -F"," '{ print $2 "," $1}' | column -s\, -t 6 | else 7 | coproc ( echo 1 | twitchy -w $(echo "$@" | cut -f1) > /dev/null 2>&1) 8 | exit; 9 | fi 10 | -------------------------------------------------------------------------------- /plugins/twitchy_albert.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """twitchy plugin for albert 4 | Rules: 5 | 1. Configure --non-interactive to include at least 6 | three values. The first one forms the text, the 2nd and 3rd 7 | form the subtext. 8 | 2. The last value will have to be the channel name that is 9 | passed back to twitchy 10 | 3. Any other values will still be searched, but will be ignored 11 | for the display 12 | 13 | Goes into: 14 | /usr/share/albert/org.albert.extension.python/modules""" 15 | 16 | import os 17 | import subprocess 18 | from albertv0 import * 19 | 20 | __iid__ = "PythonInterface/v0.1" 21 | __prettyname__ = "Twitchy" 22 | __version__ = "1.0" 23 | __trigger__ = "tw " 24 | __author__ = "BasioMeusPuga" 25 | __dependencies__ = [] 26 | 27 | icon = iconLookup('gnome-twitch') 28 | if not icon: 29 | icon = ":python_module" 30 | 31 | image_location = os.path.expanduser('~') + '/.config/twitchy3/images/' 32 | 33 | def handleQuery(query): 34 | 35 | def get_channel_list(): 36 | # Gets triggered every time there is a change in the query 37 | # This will exhaust API calls fast 38 | channels = subprocess.Popen( 39 | 'twitchy --non-interactive go', 40 | shell=True, 41 | stdout=subprocess.PIPE, 42 | stderr=subprocess.PIPE) 43 | channels_stdout = channels.stdout.readlines() 44 | channels_list = [ 45 | i.decode('utf-8').replace('\n', '').split(',') for i in channels_stdout] 46 | return channels_list 47 | 48 | online_channels = get_channel_list() 49 | 50 | results = [] 51 | if query.isTriggered: 52 | args = query.string.split() 53 | args_num = len(args) 54 | 55 | if args_num > 1: 56 | item = Item(id=__prettyname__, icon=icon, completion=query.rawString) 57 | item.text = 'Too many arguments' 58 | item.subtext = 'Twitchy takes only one argument in this mode' 59 | results.append(item) 60 | else: 61 | matching = {} 62 | if args_num == 1: 63 | search_term = query.string 64 | elif args_num == 0: 65 | search_term = '' 66 | 67 | for i in online_channels: 68 | for j in i: 69 | if search_term.lower() in j.lower(): 70 | matching[i[-1]] = { 71 | 'text': i[0], 72 | 'subtext1': i[1], 73 | 'subtext2': i[2]} 74 | 75 | for k in matching.items(): 76 | 77 | my_text = k[1]['text'] 78 | my_subtext = k[1]['subtext1'] + " | " + k[1]['subtext2'] 79 | my_action = [ProcAction( 80 | text="ProcAction", 81 | commandline=["twitchy", "--non-interactive", "kickstart", k[0]], 82 | cwd="~")] 83 | item = Item( 84 | id=__prettyname__, 85 | icon=image_location + k[0], 86 | completion=query.rawString, 87 | text=my_text, 88 | subtext=my_subtext, 89 | actions=my_action) 90 | results.append(item) 91 | 92 | return results 93 | -------------------------------------------------------------------------------- /plugins/twitchy_py3status.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Display channels currently streaming on Twitch.tv. 4 | 5 | Configuration parameters: 6 | button_next: mouse button to display next channel (default 4) 7 | button_open: mouse button to open current channel (default 1) 8 | button_previous: mouse button to display previous channel (default 5) 9 | cache_timeout: refresh interval for this module (default 60) 10 | format: display format for this module 11 | (default '{format_channel}|No Twitchy') 12 | format_channel: display format for this channel 13 | *(default "{channelaltname} [\?color=violet {gamealtname}]" 14 | "[\?color=darkgray [ {viewers}][ {uptime}]]")* 15 | thresholds: specify color thresholds to use (default []) 16 | 17 | Format placeholders: 18 | {format_channel} format for channels 19 | {channel} total number of channels, eg 10 20 | 21 | format_channel placeholders: 22 | {gamename} eg py3status 23 | {gamealtname} eg py3status 24 | {channelname} eg ultrabug 25 | {channelaltname} eg Ultrabug 26 | {status} eg I love bugs. 27 | {viewers} eg 55 28 | {uptime} eg 2h25m 29 | 30 | See `NON-INTERACTIVE` in `twitchy.cfg` for a list of Twitchy 31 | placeholders to enable. Not all of placeholders will be enabled. 32 | 33 | Color thresholds: 34 | xxx: print a color based on the value of `xxx` placeholder 35 | 36 | Requires: 37 | twitchy: cli streamlink wrapper for twitch.tv 38 | https://github.com/BasioMeusPuga/twitchy 39 | 40 | @author lasers 41 | """ 42 | 43 | from os import path 44 | from time import time 45 | 46 | STRING_NOT_INSTALLED = "not installed" 47 | 48 | 49 | class Py3status: 50 | """ 51 | """ 52 | 53 | # available configuration parameters 54 | button_next = 4 55 | button_open = 1 56 | button_previous = 5 57 | cache_timeout = 60 58 | format = "{format_channel}|No Twitchy" 59 | format_channel = ( 60 | "{channelaltname} [\?color=violet {gamealtname}]" 61 | "[\?color=darkgray [ {viewers}][ {uptime}]]" 62 | ) 63 | thresholds = [] 64 | 65 | def post_config_hook(self): 66 | if not self.py3.check_commands("twitchy"): 67 | raise Exception(STRING_NOT_INSTALLED) 68 | 69 | self.active_index = 0 70 | self.button_refresh = 2 71 | self.channel_data = {} 72 | self.delimiter = "|DELIMITER|" 73 | self.idle_time = 0 74 | self.scrolling = False 75 | self.twitchy_command = [ 76 | "twitchy", 77 | "--non-interactive", 78 | "--delimiter", 79 | self.delimiter, 80 | ] 81 | self.empty_defaults = { 82 | x: None for x in self.py3.get_placeholders_list(self.format) 83 | } 84 | 85 | self.placeholders = [] 86 | with open(path.expanduser("~/.config/twitchy3/twitchy.cfg")) as f: 87 | for line in reversed(f.readlines()): 88 | if "DisplayScheme =" in line: 89 | placeholders = line.split("=")[-1].lower().split(",") 90 | self.placeholders = [x.strip() for x in placeholders] 91 | break 92 | 93 | self.thresholds_init = {} 94 | for name in ["format", "format_channel"]: 95 | self.thresholds_init[name] = self.py3.get_color_names_list( 96 | getattr(self, name) 97 | ) 98 | 99 | def _get_twitchy_data(self): 100 | try: 101 | return self.py3.command_output(self.twitchy_command).strip() 102 | except self.py3.CommandError: 103 | return "" 104 | 105 | def _manipulate(self, data): 106 | new_data = {} 107 | for index, line in enumerate(data.splitlines()): 108 | channel = dict(zip(self.placeholders, line.split(self.delimiter))) 109 | channel["index"] = index + 1 110 | if "uptime" in channel: 111 | channel["uptime"] = channel["uptime"].replace(" ", "") 112 | format_channel = self.py3.safe_format(self.format_channel, channel) 113 | self.py3.composite_update(format_channel, {"index": channel["channelname"]}) 114 | 115 | for x in self.thresholds_init["format_channel"]: 116 | if x in channel: 117 | self.py3.threshold_get_color(channel[x], x) 118 | new_data[index] = channel 119 | 120 | return new_data 121 | 122 | def twitchy(self): 123 | # refresh 124 | current_time = time() 125 | refresh = current_time >= self.idle_time 126 | 127 | # time 128 | if refresh: 129 | self.idle_time = current_time + self.cache_timeout 130 | cached_until = self.cache_timeout 131 | else: 132 | cached_until = self.idle_time - current_time 133 | 134 | # button 135 | if self.scrolling and not refresh: 136 | self.scrolling = False 137 | data = self.channel_data 138 | else: 139 | data = self._manipulate(self._get_twitchy_data()) 140 | self.channel_data = data 141 | 142 | if data: 143 | self.count_channels = len(data) 144 | channel = data.get(self.active_index, {}) 145 | format_channel = self.py3.safe_format(self.format_channel, channel) 146 | self.py3.composite_update(format_channel, {"index": channel["channelname"]}) 147 | 148 | twitchy_data = { 149 | "format_channel": format_channel, 150 | "channel": self.count_channels, 151 | } 152 | 153 | for x in self.thresholds_init["format"]: 154 | if x in twitchy_data: 155 | self.py3.threshold_get_color(twitchy_data[x], x) 156 | else: 157 | twitchy_data = self.empty_defaults 158 | 159 | return { 160 | "cached_until": self.py3.time_in(cached_until), 161 | "full_text": self.py3.safe_format(self.format, twitchy_data), 162 | } 163 | 164 | def on_click(self, event): 165 | button = event["button"] 166 | if button in [self.button_next, self.button_previous]: 167 | if self.channel_data: 168 | self.scrolling = True 169 | if button == self.button_next: 170 | self.active_index += 1 171 | elif button == self.button_previous: 172 | self.active_index -= 1 173 | self.active_index %= self.count_channels 174 | else: 175 | self.py3.prevent_refresh() 176 | elif button == self.button_refresh: 177 | self.idle_time = 0 178 | else: 179 | if button == self.button_open: 180 | index = event["index"] 181 | if not isinstance(index, int): 182 | command = "twitchy --non-interactive kickstart {name}" 183 | self.py3.command_run(command.format(name=index)) 184 | self.py3.prevent_refresh() 185 | 186 | 187 | if __name__ == "__main__": 188 | """ 189 | Run module in test mode. 190 | """ 191 | from py3status.module_test import module_test 192 | 193 | module_test(Py3status) 194 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | record=install.txt 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from setuptools import setup 3 | 4 | MAJOR_VERSION = '3' 5 | MINOR_VERSION = '4' 6 | MICRO_VERSION = '0' 7 | VERSION = "{}.{}.{}".format(MAJOR_VERSION, MINOR_VERSION, MICRO_VERSION) 8 | 9 | if sys.argv[-1] == 'test': 10 | from twitchy import twitchy_config 11 | twitchy_config.ConfigInit(True) 12 | 13 | setup(name='twitchy', 14 | version=VERSION, 15 | description="Command line streamlink wrapper for twitch.tv", 16 | url='https://github.com/BasioMeusPuga/twitchy', 17 | author='BasioMeusPuga', 18 | author_email='disgruntled.mob@gmail.com', 19 | license='GPLv3', 20 | packages=['twitchy'], 21 | classifiers=[ 22 | 'Environment :: Console', 23 | 'Intended Audience :: End Users/Desktop', 24 | 'Intended Audience :: Developers', 25 | 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 26 | 'Operating System :: MacOS :: MacOS X', 27 | 'Operating System :: Unix', 28 | 'Operating System :: POSIX', 29 | 'Programming Language :: Python', 30 | 'Programming Language :: Python :: 3.6', 31 | 'Development Status :: 5 - Production/Stable', 32 | 'Topic :: Multimedia :: Video :: Display' 33 | ], 34 | zip_safe=False, 35 | entry_points={'console_scripts': ['twitchy = twitchy.__main__:main']}, 36 | platforms='any') 37 | -------------------------------------------------------------------------------- /twitchy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BasioMeusPuga/twitchy/92e5176d1b66cc5f20c10ce298c849ebc4bb8c97/twitchy/__init__.py -------------------------------------------------------------------------------- /twitchy/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Requirements: streamlink, requests 3 | 4 | # Standard imports 5 | import sys 6 | import argparse 7 | 8 | # Exit if version requirements are not met 9 | if sys.version_info < (3, 6): 10 | print(' Python 3.6 or greater required.') 11 | exit(1) 12 | 13 | # Custom imports 14 | from twitchy import twitchy_config # This import also creates the path 15 | from twitchy.twitchy_config import Colors, YouAndTheHorseYouRodeInOn 16 | twitchy_config.ConfigInit() 17 | 18 | # Everything will error out unless 19 | # both the config and the database files exist 20 | from twitchy import twitchy_database 21 | twitchy_database.DatabaseInit() 22 | 23 | from twitchy import twitchy_api 24 | from twitchy import twitchy_display 25 | from twitchy import twitchy_play 26 | 27 | # All database functions are methods in database_instance 28 | database_instance = twitchy_database.DatabaseFunctions() 29 | 30 | Options = twitchy_config.Options() 31 | Options.parse_options() 32 | 33 | 34 | def channel_addition(option, channels): 35 | # option is either 'add' for -a 36 | # -a expects a list 37 | # OR 'sync' for -s 38 | # -s accepts only a string 39 | # Everything is converted to lowercase in the relevant function 40 | 41 | print(' ' + Colors.YELLOW + 'Additions to database:' + Colors.ENDC) 42 | 43 | # Get the numeric id of each channel that is to be added to the database 44 | if option == 'add': 45 | valid_channels = twitchy_api.name_id_translate( 46 | 'channels', 'id_from_name', [channels]) 47 | elif option == 'sync': 48 | valid_channels = twitchy_api.sync_from_id(channels) 49 | 50 | if not valid_channels: 51 | raise YouAndTheHorseYouRodeInOn(' No valid channels.') 52 | 53 | # Actual addition to the database takes place here 54 | added_channels = twitchy_database.DatabaseFunctions().add_channels( 55 | valid_channels) 56 | 57 | if not added_channels: 58 | raise YouAndTheHorseYouRodeInOn(' No valid channels.') 59 | else: 60 | for i in added_channels: 61 | print(' ' + i) 62 | 63 | 64 | def database_modification(option, database_search=None): 65 | # option is either 'delete' for -d 66 | # OR 'alternate_name' for -an 67 | 68 | try: 69 | table_wanted = input(' Modify (s)treamer or (g)ame name? ') 70 | except (KeyboardInterrupt, EOFError): 71 | exit(1) 72 | 73 | if table_wanted.lower() == 's': 74 | table_wanted = 'channels' 75 | elif table_wanted.lower() == 'g': 76 | table_wanted = 'games' 77 | 78 | if database_search: 79 | database_search = { 80 | 'Name': database_search, 81 | 'AltName': database_search} 82 | 83 | channel_data = database_instance.fetch_data( 84 | ('Name', 'TimeWatched', 'AltName'), 85 | table_wanted, 86 | database_search, 87 | 'LIKE') 88 | 89 | if not channel_data: 90 | raise YouAndTheHorseYouRodeInOn(' No matching records.') 91 | 92 | final_selection = twitchy_display.GenerateDatabaseTable( 93 | channel_data, table_wanted).begin() 94 | 95 | if option == 'delete': 96 | yes_default = ['y', 'Y', 'yes', 'YES'] 97 | confirmed_deletions = [] 98 | for i in final_selection: 99 | confirm_delete = input( 100 | f' Delete {Colors.YELLOW + i + Colors.ENDC} (y/N) ') 101 | 102 | if confirm_delete in yes_default: 103 | confirmed_deletions.append(i) 104 | database_instance.modify_data( 105 | 'delete', 106 | table_wanted, 107 | i) 108 | 109 | if confirmed_deletions: 110 | print( 111 | ' Deleted: ' + 112 | Colors.RED + ', '.join(confirmed_deletions) + 113 | Colors.ENDC) 114 | 115 | elif option == 'alternate_name': 116 | for i in final_selection: 117 | new_name = input( 118 | f' Alternate name for {Colors.YELLOW + i + Colors.ENDC} ') 119 | criteria_dict = { 120 | 'old_name': i, 121 | 'new_name': new_name} 122 | 123 | database_instance.modify_data( 124 | 'alternate_name', 125 | table_wanted, 126 | criteria_dict) 127 | 128 | 129 | def watch_channel(mode, database_search=None): 130 | if mode == 'watch': 131 | # database_search is expected to be a list 132 | # exact names of the channels the user wants to watch 133 | 134 | # Watch times are NOT tracked with -w 135 | # This greatly decreases the number of special conditions 136 | # that need to be accounted for 137 | twitchy_config.time_tracking = False 138 | 139 | id_string_list = [] 140 | not_in_database = [] 141 | 142 | database_search = [i.lower() for i in database_search] 143 | for i in database_search: 144 | search_criteria = { 145 | 'Name': i} 146 | 147 | channel_data = database_instance.fetch_data( 148 | ('ChannelID',), 149 | 'channels', 150 | search_criteria, 151 | 'EQUALS', 152 | True) 153 | 154 | if channel_data: 155 | # Channel data is expected as a string 156 | id_string_list.append(str(channel_data)) 157 | else: 158 | not_in_database.append(i) 159 | 160 | if not_in_database: 161 | get_ids_from_api = twitchy_api.name_id_translate( 162 | 'channels', 'id_from_name', [not_in_database]) 163 | 164 | ids_only = [i[1]['id'] for i in get_ids_from_api.items()] 165 | id_string_list.extend(ids_only) 166 | 167 | if not id_string_list: 168 | raise YouAndTheHorseYouRodeInOn(' No valid channels.') 169 | 170 | else: 171 | # This is the standard watch() function 172 | # It expects only one argument 173 | if database_search: 174 | database_search = { 175 | 'Name': database_search, 176 | 'AltName': database_search} 177 | 178 | channel_data = database_instance.fetch_data( 179 | ('ChannelID',), 180 | 'channels', 181 | database_search, 182 | 'LIKE') 183 | 184 | if channel_data: 185 | id_string_list = [str(i[0]) for i in channel_data] 186 | else: 187 | raise YouAndTheHorseYouRodeInOn(' Database query returned nothing.') 188 | 189 | print(' ' + Options.colors.numbers + 190 | f'Checking {len(id_string_list)} channel(s)...' + 191 | Colors.ENDC) 192 | 193 | channels_online = twitchy_api.GetOnlineStatus( 194 | id_string_list).check_channels() 195 | if not channels_online: 196 | raise YouAndTheHorseYouRodeInOn(' All channels offline.') 197 | 198 | final_selection = twitchy_display.GenerateWatchTable( 199 | channels_online).begin() 200 | print(' q / Ctrl + C to quit \n Now watching:') 201 | twitchy_play.play_instance_generator(final_selection) 202 | 203 | 204 | def watch_vods(channel_name): 205 | channel_data = twitchy_api.name_id_translate( 206 | 'channels', 'id_from_name', [channel_name]) 207 | 208 | try: 209 | channel_id = channel_data[channel_name[0]]['id'] 210 | display_name = channel_data[channel_name[0]]['display_name'] 211 | except KeyError: 212 | raise YouAndTheHorseYouRodeInOn(' Invalid name.') 213 | 214 | vod_list = twitchy_api.get_vods(channel_id) 215 | print(' ' + Options.colors.numbers + 216 | f'VODs for {display_name}:' + 217 | Colors.ENDC) 218 | 219 | if not vod_list: 220 | raise YouAndTheHorseYouRodeInOn(' No VODs found.') 221 | 222 | final_selection = { 223 | display_name: twitchy_display.GenerateVODTable(vod_list).begin()} 224 | twitchy_config.time_tracking = False 225 | twitchy_config.vod_mode = True 226 | 227 | print(' q / Ctrl + C to quit \n Now watching:') 228 | twitchy_play.play_instance_generator(final_selection) 229 | 230 | 231 | def non_interactive(mode, channel_name=None, delimiter=None): 232 | if mode == 'get_online': 233 | # Output format: 234 | # Game name, Game display name (if present)... 235 | # Channel name, Channel display name (always present) 236 | channel_data = database_instance.fetch_data( 237 | ('ChannelID',), 238 | 'channels', 239 | None, 240 | 'LIKE') 241 | 242 | # Database is empty and no output must be issued 243 | if not channel_data: 244 | return 245 | 246 | id_string_list = [str(i[0]) for i in channel_data] 247 | channels_online = twitchy_api.GetOnlineStatus( 248 | id_string_list).check_channels() 249 | 250 | # All standard channel parameters are available 251 | for i in channels_online.items(): 252 | return_list = [] 253 | config_correlate = { 254 | 'GameName': i[1]['game'], 255 | 'GameAltName': str(i[1]['game_display_name']), 256 | 'ChannelName': i[0], 257 | 'ChannelAltName': i[1]['display_name'], 258 | 'Status': i[1]['status'], 259 | 'Viewers': str(i[1]['viewers']), 260 | 'Uptime': twitchy_display.time_convert(i[1]['uptime'])} 261 | 262 | for j in Options.non_int_display_scheme: 263 | return_list.append(config_correlate[j]) 264 | 265 | if delimiter is None: 266 | delimiter = Options.non_int_delimiter 267 | 268 | print(delimiter.join(return_list)) 269 | 270 | if mode == 'kickstart': 271 | # Skip selection and just pass the channel name to the play module 272 | # All output is disabled except for error messages 273 | # Time tracking is disabled 274 | twitchy_config.print_to_stdout = False 275 | twitchy_config.time_tracking = False 276 | twitchy_config.non_interactive_mode = True 277 | 278 | if not channel_name: 279 | exit(1) 280 | 281 | try: 282 | api_reply = twitchy_api.name_id_translate( 283 | 'channels', 'id_from_name', [channel_name]) 284 | channel_id = api_reply[channel_name]['id'] 285 | 286 | channel_status = twitchy_api.GetOnlineStatus( 287 | [channel_id]).check_channels() 288 | # The video is started in default quality 289 | channel_status[channel_name]['quality'] = Options.video.default_quality 290 | twitchy_play.play_instance_generator(channel_status) 291 | except KeyError: 292 | exit() 293 | 294 | 295 | def nuke_it_from_orbit(): 296 | print('Are you sure you want to remove the database and start over?') 297 | confirmation = Colors.RED + 'KappaKeepoPogChamp' + Colors.ENDC 298 | confirm = input(f'Please type {confirmation} to continue: ') 299 | if confirm == 'KappaKeepoPogChamp': 300 | twitchy_database.DatabaseInit().remove_database() 301 | twitchy_config.ConfigInit().remove_config() 302 | print(' Done.') 303 | 304 | 305 | def main(): 306 | parser = argparse.ArgumentParser( 307 | description='Watch twitch.tv from your terminal. IT\'S THE FUTURE.', 308 | add_help=False) 309 | 310 | parser.add_argument( 311 | 'searchfor', type=str, nargs='?', 312 | help='Search for channel name in database', 313 | metavar='*searchstring*') 314 | 315 | parser.add_argument( 316 | '-h', '--help', 317 | help='This helpful message', action='help') 318 | 319 | parser.add_argument( 320 | '-a', type=str, nargs='+', 321 | help='Add channel name(s) to database', 322 | metavar='') 323 | 324 | parser.add_argument( 325 | '-an', type=str, nargs='?', const='Null', 326 | help='Set/Unset alternate names', 327 | metavar='*searchstring*') 328 | 329 | parser.add_argument( 330 | '--configure', action='store_true', help='Configure options') 331 | 332 | parser.add_argument( 333 | '-d', type=str, nargs='?', const='Null', 334 | help='Delete channel(s) from database', 335 | metavar='*searchstring*') 336 | 337 | parser.add_argument( 338 | '--hanselgretel', action='store_true', help='Abandon children') 339 | 340 | parser.add_argument( 341 | '--non-interactive', type=str, nargs='?', 342 | help='Generate parsable data for integration elsewhere', 343 | const='go', 344 | metavar='go / kickstart') 345 | 346 | parser.add_argument( 347 | '--delimiter', type=str, nargs='?', help=argparse.SUPPRESS) 348 | 349 | parser.add_argument( 350 | '--reset', action='store_true', help='Start over') 351 | 352 | parser.add_argument( 353 | '-s', type=str, 354 | help='Sync username\'s followed accounts to local database', 355 | metavar='username') 356 | 357 | parser.add_argument( 358 | '-v', type=str, nargs='+', 359 | help='Watch VODs', 360 | metavar='123 |
') 361 | 362 | parser.add_argument( 363 | '-w', type=str, nargs='+', 364 | help='Watch specified channel(s)', 365 | metavar=' ') 366 | 367 | args = parser.parse_args() 368 | 369 | if (args.s or args.v) and args.searchfor: 370 | parser.error('Only one argument allowed with -s') 371 | exit(1) 372 | 373 | if args.hanselgretel: 374 | twitchy_config.disown = True 375 | 376 | if args.a: 377 | channel_addition('add', args.a) 378 | 379 | elif args.an: 380 | arg = args.an 381 | if args.an == 'Null': 382 | arg = None 383 | database_modification('alternate_name', arg) 384 | 385 | elif args.configure: 386 | twitchy_config.ConfigInit().configure_options() 387 | 388 | elif args.d: 389 | arg = args.d 390 | if args.d == 'Null': 391 | arg = None 392 | database_modification('delete', arg) 393 | 394 | elif args.non_interactive: 395 | if args.non_interactive == 'go': 396 | non_interactive('get_online', delimiter=args.delimiter) 397 | elif args.non_interactive == 'kickstart': 398 | non_interactive('kickstart', args.searchfor) 399 | 400 | elif args.reset: 401 | nuke_it_from_orbit() 402 | 403 | elif args.s: 404 | channel_addition('sync', args.s) 405 | 406 | elif args.searchfor: 407 | watch_channel(None, args.searchfor) 408 | 409 | elif args.v: 410 | watch_vods(args.v) 411 | 412 | elif args.w: 413 | watch_channel('watch', args.w) 414 | 415 | else: 416 | watch_channel(None) 417 | 418 | 419 | if __name__ == '__main__': 420 | try: 421 | main() 422 | except KeyboardInterrupt: 423 | exit() 424 | -------------------------------------------------------------------------------- /twitchy/twitchy_api.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Twitch API interaction module 3 | 4 | import os 5 | import ast 6 | import time 7 | import datetime 8 | 9 | from twitchy import twitchy_database 10 | from twitchy.twitchy_config import YouAndTheHorseYouRodeInOn, location_prefix, Options 11 | 12 | Options = Options() 13 | Options.parse_options() 14 | 15 | try: 16 | import requests 17 | except ImportError: 18 | raise YouAndTheHorseYouRodeInOn(' requests not installed.') 19 | 20 | 21 | def api_call(url, params=None): 22 | try: 23 | headers = { 24 | 'Client-ID': 'guulhcvqo9djhuyhb2vi56wqnglc351'} 25 | 26 | def make_request(): 27 | r = requests.get( 28 | url, 29 | headers=headers, 30 | params=params) 31 | 32 | my_json = r.json() 33 | 34 | try: 35 | status = my_json['status'] 36 | if status == 429: 37 | # Wait for 2 seconds in case of an API overload 38 | # Hopefully the recursion doesn't end the universe 39 | time.sleep(2) 40 | make_request() 41 | except KeyError: 42 | return my_json 43 | 44 | return make_request() 45 | 46 | except requests.exceptions.ConnectionError: 47 | raise YouAndTheHorseYouRodeInOn(' Unable to connect to Twitch.') 48 | 49 | 50 | def name_id_translate(data_type, mode, data): 51 | # data is expected to be a list 52 | # data_type is either 'games' or 'channels' 53 | 54 | # All API calls are now based on the presence of 55 | # an id field. This needs to be derived first 56 | 57 | # The new API also returns the display_name value here 58 | # in case of channel names 59 | # This will have to be cached in the database 60 | 61 | # Returns a dictionary 62 | 63 | # Set the default value for params 64 | # This is for 'name_from_id' 65 | # for either data_type 66 | params = (('id', data),) 67 | 68 | if data_type == 'games': 69 | api_endpoint = 'https://api.twitch.tv/helix/games' 70 | if mode == 'id_from_name': 71 | params = (('name', data),) 72 | 73 | elif data_type == 'channels': 74 | api_endpoint = 'https://api.twitch.tv/helix/users' 75 | if mode == 'id_from_name': 76 | if len(data) > 1: 77 | data = [i[0].lower() for i in data] 78 | params = (('login', data),) 79 | 80 | stream_data = api_call( 81 | api_endpoint, 82 | params) 83 | 84 | if data_type == 'channels': 85 | return_dict = {} 86 | 87 | for i in stream_data['data']: 88 | channel_params = { 89 | 'id': i['id'], 90 | 'broadcaster_type': i['broadcaster_type'], 91 | 'display_name': i['display_name'], 92 | 'profile_image_url': i['profile_image_url']} 93 | 94 | return_dict[i['login']] = channel_params 95 | 96 | return return_dict 97 | 98 | if data_type == 'games': 99 | return_list = [] 100 | 101 | for i in stream_data['data']: 102 | game_params = { 103 | 'id': i['id'], 104 | 'name': i['name']} 105 | 106 | return_list.append( 107 | [game_params['id'], game_params['name']]) 108 | 109 | return return_list 110 | 111 | 112 | def sync_from_id(username): 113 | # username is expected to be a string in lowecase 114 | # Make sure this is set in the initiating function 115 | # Example: sync_from_id(' ') 116 | followed_channels = {} 117 | 118 | username_id = name_id_translate( 119 | 'channels', 'id_from_name', [username.lower()]) 120 | if username_id: 121 | username_id = username_id[username]['id'] 122 | else: 123 | # In case no id is returned by name_id_translate 124 | return 125 | 126 | followed_channels_ids = [] 127 | params = ( 128 | ('from_id', username_id), 129 | ('first', 100)) 130 | while True: 131 | results_expected = 100 132 | api_endpoint = 'https://api.twitch.tv/helix/users/follows' 133 | 134 | stream_data = api_call( 135 | api_endpoint, 136 | params) 137 | 138 | followed_channels_ids = [i['to_id'] for i in stream_data['data']] 139 | if not followed_channels_ids: 140 | return 141 | 142 | # Since we're nesting these API calls, no separate pagination is 143 | # required 144 | followed_channels_returned_dict = name_id_translate( 145 | 'channels', 'name_from_id', followed_channels_ids) 146 | 147 | for i in followed_channels_returned_dict.items(): 148 | followed_channels[i[0]] = i[1] 149 | 150 | results_acquired = len(stream_data['data']) 151 | if results_acquired < results_expected: 152 | break 153 | else: 154 | params = ( 155 | ('from_id', username_id), 156 | ('first', 100), 157 | ('after', stream_data['pagination']['cursor'])) 158 | 159 | return followed_channels 160 | 161 | 162 | def get_vods(channel_id): 163 | # Returns a list of VODS 164 | # Only the first 100 will ever be queried 165 | # so no need for pagination here 166 | 167 | params = ( 168 | ('user_id', channel_id), 169 | ('first', 100)) 170 | api_endpoint = 'https://api.twitch.tv/helix/videos' 171 | 172 | vod_data = api_call( 173 | api_endpoint, 174 | params) 175 | 176 | return_list = [] 177 | # Since the API returns a list of videos that are sorted 178 | # by date, additional sorting isn't required 179 | # All that is needed is conversion of the TZ string into 180 | # its corresponding date 181 | for i in vod_data['data']: 182 | creation_time = datetime.datetime.strptime( 183 | i['created_at'], 184 | '%Y-%m-%dT%H:%M:%SZ') 185 | 186 | creation_date = creation_time.strftime('%d %B %Y') 187 | 188 | vod_title = i['title'].replace('\n', '') 189 | if len(vod_title) > Options.display.truncate_status: 190 | vod_title = vod_title[:Options.display.truncate_status] + '...' 191 | 192 | return_list.append([ 193 | creation_date, 194 | vod_title, 195 | i['url']]) 196 | 197 | return_list = return_list[::-1] 198 | return return_list 199 | 200 | 201 | def get_profile_image(channel_names): 202 | # channel_names is a list 203 | 204 | # Download the profile image (logo) of the channel 205 | # This is currently only for the albert plugin 206 | # If this function is being called, assume the 207 | # requisite image does not exist and query for it 208 | # from the API 209 | 210 | link_dict = {} 211 | while channel_names: 212 | followed_channels_returned_dict = name_id_translate( 213 | 'channels', 'id_from_name', [channel_names[:100]]) 214 | 215 | for i in followed_channels_returned_dict.items(): 216 | link_dict[i[0]] = i[1]['profile_image_url'] 217 | 218 | del channel_names[:100] 219 | 220 | for i in link_dict.items(): 221 | image_path = location_prefix + 'images/' + i[0] 222 | r = requests.get(i[1], stream=True) 223 | with open(image_path, 'wb') as image_file: 224 | for chunk in r.iter_content(1024): 225 | image_file.write(chunk) 226 | 227 | 228 | class GetOnlineStatus: 229 | def __init__(self, channels): 230 | # Again, channels is expected to be a tuple 231 | # containing the _id as a string 232 | # More than 100 channels will be paginated 233 | # Example: 234 | # channels = GetOnlineStatus(['22588033', '26610234']) 235 | # channels.check_channels() 236 | # print(channels.online_channels) 237 | self.channels = channels 238 | self.no_profile_images = [] 239 | self.online_channels = {} 240 | 241 | def parse_uptime(self, start_time): 242 | # Uptime is returned in seconds 243 | # We'll be using twitchy_display.time_convert() 244 | # to... convert this into what will be 245 | # displayed according to the sort order 246 | datetime_start_time = datetime.datetime.strptime( 247 | start_time, 248 | '%Y-%m-%dT%H:%M:%SZ') 249 | stream_uptime_seconds = ( 250 | datetime.datetime.utcnow() - 251 | datetime_start_time).seconds 252 | 253 | return stream_uptime_seconds 254 | 255 | def get_game(self, game_id): 256 | # The game_id is expected to be an integer 257 | # The idea is to check the database for said integer 258 | # and return data accordingly 259 | # If nothing is found, create a new entry within the database 260 | # and put them newly discovered details here 261 | # Whoever thought of 2 endpoints for this 262 | # can walk on broken glass 263 | try: 264 | game_name = twitchy_database.DatabaseFunctions().fetch_data( 265 | ('Name', 'AltName'), 266 | 'games', 267 | {'GameID': game_id}, 268 | 'EQUALS')[0] 269 | return game_name 270 | except TypeError: 271 | # Implies the game is not in the database 272 | # Its name will have to be fetched from the API 273 | # It will then be added to the database to prevent 274 | # repeated API calls 275 | # This means that all games EVER seen will be in the database now 276 | # Fuck whoever thought of this 277 | try: 278 | game_details = name_id_translate('games', 'name_from_id', (game_id,)) 279 | game_name = game_details[0][1].replace("'", "") 280 | twitchy_database.DatabaseFunctions().add_games(game_name, game_id) 281 | return (game_name, None) 282 | except IndexError: 283 | # In the event the streamer gets lazy and does not set a game 284 | return ('No game set', None) 285 | 286 | def check_channels(self): 287 | # The API imposes an upper limit of 100 channels 288 | # checked at once. Pagination is required, as usual. 289 | while self.channels: 290 | api_endpoint = 'https://api.twitch.tv/helix/streams' 291 | 292 | params = ( 293 | ('first', 100), 294 | ('user_id', self.channels[:100])) 295 | 296 | del self.channels[:100] 297 | stream_data = api_call( 298 | api_endpoint, 299 | params) 300 | 301 | # The API currently does NOT return the game_name 302 | # It does return a game_id. In case you intend to go 303 | # forward with that at this time, the game_id will have 304 | # to be cached in the database along with its name 305 | 306 | # The stream data dictionary is 307 | # Key: name 308 | # Value: as below 309 | # Partner status will have to come from another endpoint 310 | # Time watched is a database function - See if it 311 | # needs to be integrated here 312 | 313 | for i in stream_data['data']: 314 | 315 | user_id = i['user_id'] 316 | try: 317 | channel_details = twitchy_database.DatabaseFunctions().fetch_data( 318 | ('Name', 'DisplayName', 'AltName', 'IsPartner'), 319 | 'channels', 320 | {'ChannelID': user_id}, 321 | 'EQUALS')[0] 322 | 323 | channel_name = channel_details[0] 324 | # Set the display name to a preset AltName if possible 325 | # Or go back to the display name set by the channel 326 | channel_display_name = channel_details[2] 327 | if not channel_display_name: 328 | channel_display_name = channel_details[1] 329 | 330 | # Partner status is returned as string True 331 | # This is clearly unacceptable for anyone who 332 | # doesn't sleep in a dumpster 333 | is_partner = ast.literal_eval(channel_details[3]) 334 | 335 | # Download images in case they aren't found 336 | # No extension is needed 337 | # Will only really be slow the first time 338 | profile_image = location_prefix + 'images/' + channel_name 339 | if not os.path.exists(profile_image): 340 | self.no_profile_images.append(channel_name) 341 | 342 | except TypeError: 343 | # Implies that the channel is not present in the database 344 | # and its details will have to be queried from the API 345 | # This should only get triggered in case of -w 346 | # This will *really* slow down if multiple non database channels 347 | # are put into -w since all of them are checked individually 348 | # A pox upon thee, Twitch API developers 349 | channel_details = name_id_translate( 350 | 'channels', 'name_from_id', [user_id]) 351 | 352 | for j in channel_details.items(): 353 | channel_name = j[0] 354 | channel_display_name = j[1]['display_name'] 355 | is_partner = False 356 | if j[1]['broadcaster_type'] == 'partner': 357 | is_partner = True 358 | 359 | uptime = self.parse_uptime(i['started_at']) 360 | 361 | # Game name and any alternate names will have to be correlated 362 | # to the game_id that's returned by the API 363 | # Whoever thought this was a good idea can sit on it and rotate 364 | 365 | game_id = i['game_id'] 366 | if not game_id or game_id == '': 367 | game_name = game_display_name = 'IDK' 368 | else: 369 | game_data = self.get_game(game_id) 370 | game_name = game_display_name = game_data[0] 371 | if game_data[1]: 372 | game_display_name = game_data[1] 373 | 374 | self.online_channels[channel_name] = { 375 | 'game': game_name, 376 | 'game_id': game_id, 377 | 'game_display_name': game_display_name, 378 | 'status': i['title'].replace('\n', ''), 379 | 'viewers': i['viewer_count'], 380 | 'display_name': channel_display_name, 381 | 'uptime': uptime, 382 | 'is_partner': is_partner} 383 | 384 | get_profile_image(self.no_profile_images) 385 | return self.online_channels 386 | -------------------------------------------------------------------------------- /twitchy/twitchy_config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Configuration and settings module 3 | 4 | import os 5 | import sys 6 | import shutil 7 | import configparser 8 | import collections 9 | 10 | location_prefix = os.path.expanduser('~') + '/.config/twitchy3/' 11 | os.makedirs(location_prefix, exist_ok=True) 12 | os.makedirs(location_prefix + 'images', exist_ok=True) 13 | 14 | # Runtime Options 15 | # Putting these inside a class makes them not work 16 | # So control the OCPD 17 | print_to_stdout = True 18 | time_tracking = True 19 | vod_mode = False 20 | non_interactive_mode = False 21 | disown = False 22 | 23 | 24 | class YouAndTheHorseYouRodeInOn(Exception): 25 | def __init__(self, message): 26 | self.message = message 27 | self.print_error() 28 | 29 | def print_error(self): 30 | # This could be extended to include logging 31 | print(Colors.RED + self.message + Colors.ENDC) 32 | exit(1) 33 | 34 | 35 | class Colors: 36 | CYAN = '\033[96m' 37 | YELLOW = '\033[93m' 38 | GREEN = '\033[92m' 39 | RED = '\033[91m' 40 | WHITE = '\033[97m' 41 | ENDC = '\033[0m' 42 | 43 | 44 | class ConfigInit: 45 | def __init__(self, for_test=False): 46 | # Create database and config files 47 | self.config_path = location_prefix + 'twitchy.cfg' 48 | 49 | self.player = self.default_quality = self.truncate_status_at = None 50 | self.mt_chat = self.check_interval = None 51 | 52 | if for_test: 53 | self.write_to_config_file(True) 54 | 55 | if not os.path.exists(self.config_path): 56 | self.configure_options() 57 | else: 58 | with open(self.config_path, 'r') as current_config: 59 | first_line = current_config.readlines()[0] 60 | if first_line == '# TEST CONFIG FILE\n' and not sys.argv[0] == 'setup.py': 61 | os.remove(self.config_path) 62 | self.configure_options() 63 | 64 | def configure_options(self): 65 | try: 66 | # Turns out, we don't actually need a no_default 67 | yes_default = ['y', 'Y', 'yes', 'YES'] 68 | 69 | print(Colors.CYAN + ' Configure:' + Colors.ENDC) 70 | 71 | self.player = input(' Media player [mpv]: ') 72 | if not shutil.which(self.player): 73 | if shutil.which('mpv'): 74 | self.player = 'mpv' 75 | else: 76 | print(Colors.RED + ' ' + self.player + Colors.ENDC + 77 | ' is not in $PATH. Please check if this is what you want.') 78 | raise KeyboardInterrupt 79 | 80 | self.default_quality = input(' Default stream quality [low/medium/HIGH/source]: ') 81 | if (self.default_quality == '' or 82 | self.default_quality not in ['low', 'medium', 'source']): 83 | self.default_quality = 'high' 84 | 85 | try: 86 | self.truncate_status_at = int(input(' Truncate stream status at [AUTO]: ')) 87 | except ValueError: 88 | self.truncate_status_at = 0 89 | 90 | try: 91 | self.check_interval = int( 92 | input(' Interval (seconds) in between channel status checks [60]: ')) 93 | except ValueError: 94 | self.check_interval = 60 95 | 96 | print('\n' + Colors.CYAN + ' Current Settings:' + Colors.ENDC) 97 | penultimate_check = ( 98 | f' Media player: {Colors.YELLOW + self.player + Colors.ENDC}\n' 99 | f' Default Quality: {Colors.YELLOW + self.default_quality + Colors.ENDC}\n' 100 | f' Truncate status at: {Colors.YELLOW + str(self.truncate_status_at) + Colors.ENDC}\n' 101 | f' Check interval: {Colors.YELLOW + str(self.check_interval) + Colors.ENDC}') 102 | print(penultimate_check) 103 | 104 | do_we_like = input(' Does this look correct to you? [Y/n]: ') 105 | if do_we_like == '' or do_we_like in yes_default: 106 | self.write_to_config_file() 107 | 108 | else: 109 | raise KeyboardInterrupt 110 | 111 | except KeyboardInterrupt: 112 | try: 113 | final_decision = input( 114 | Colors.RED + 115 | ' Do you wish to restart? [y/N]: ' + 116 | Colors.ENDC) 117 | if final_decision in yes_default: 118 | print() 119 | self.configure_options() 120 | else: 121 | exit(1) 122 | except KeyboardInterrupt: 123 | exit(1) 124 | 125 | except EOFError: 126 | print('\n', Colors.RED + 'Writing default values' + Colors.ENDC) 127 | self.player = 'mpv' 128 | self.default_quality = 'source' 129 | self.check_interval = 60 130 | self.truncate_status_at = 0 131 | self.write_to_config_file() 132 | 133 | def write_to_config_file(self, test_config=False): 134 | if test_config: 135 | self.player = 'test_player' 136 | self.default_quality = 'source' 137 | self.truncate_status_at = 60 138 | self.check_interval = 0 139 | config_header = '# TEST CONFIG FILE\n' 140 | else: 141 | config_header = '# Twitchy configuration file\n' 142 | 143 | config_string = ( 144 | '# OPTIONS ARE CASE SENSITIVE\n' 145 | '\n' 146 | '[VIDEO]\n' 147 | f'Player = {self.player}\n' 148 | '# This is only valid if using mpv.\n' 149 | '# Valid options are: False, \n' 150 | '# Valid methods are: vaapi (Intel), vdpau (Nvidia) etc.\n' 151 | 'MPVHardwareAcceleration = False\n' 152 | '# Valid options are: low, mid, high, source\n' 153 | f'DefaultQuality = {self.default_quality}\n' 154 | '\n' 155 | '\n' 156 | '[COLUMNS]\n' 157 | '# Valid options are: ChannelName, Viewers, Uptime, StreamStatus, GameName\n' 158 | '# Columns do not auto resize so be gentle with what you use for Column 2\n' 159 | 'Column1 = ChannelName\n' 160 | 'Column2 = Viewers\n' 161 | 'Column3 = StreamStatus\n' 162 | '\n' 163 | '\n' 164 | '[DISPLAY]\n' 165 | '# Valid options are: 1, 2, 3 or GameName\n' 166 | 'SortBy = GameName\n' 167 | '# Shows the name of each column in case sorting is not by GameName\n' 168 | 'ColumnNames = True\n' 169 | '# Set to 0 for auto truncation. Any other positive integer for manual control\n' 170 | f'TruncateStatus = {self.truncate_status_at}\n' 171 | f'CheckInterval = {self.check_interval}\n' 172 | '\n' 173 | '\n' 174 | '[COLORS]\n' 175 | '# Valid colors are: black, gray, white\n' 176 | '# AND dark(red, green, yellow, blue, magenta, cyan)\n' 177 | 'Numbers = yellow\n' 178 | 'GameName = cyan\n' 179 | 'Column1 = green\n' 180 | 'Column2 = white\n' 181 | 'Column3 = green\n' 182 | '\n' 183 | '\n' 184 | '[CHAT]\n' 185 | 'Enable = True\n' 186 | '\n' 187 | '\n' 188 | '[NON-INTERACTIVE]\n' 189 | '# Valid options are:\n' 190 | '# GameName, GameAltName, ChannelName, ChannelAltName, Status, Viewers, Uptime\n' 191 | 'DisplayScheme = GameAltName, GameName, ChannelAltName, ChannelName\n' 192 | 'Delimiter = ,\n') 193 | 194 | with open(self.config_path, 'w') as config_file: 195 | config_file.write(config_header + config_string) 196 | 197 | if not test_config: 198 | print(Colors.CYAN + 199 | f' Config written to {self.config_path}. Read for additional settings.' + 200 | Colors.ENDC) 201 | exit() 202 | 203 | def remove_config(self): 204 | os.remove(self.config_path) 205 | 206 | 207 | class Options: 208 | def __init__(self): 209 | self.config_path = location_prefix + 'twitchy.cfg' 210 | 211 | # The class attributes are mostly dictionaries as declared below 212 | self.video = self.columns = self.display = None 213 | self.colors = self.chat = self.conky_run = self.quality_map = None 214 | self.non_int_display_scheme = self.non_int_delimiter = None 215 | 216 | def parse_options(self): 217 | config = configparser.ConfigParser() 218 | config.read(self.config_path) 219 | 220 | # Video options 221 | video_section = config['VIDEO'] 222 | 223 | player = video_section.get('Player', 'mpv') 224 | # Treating this as a string so as to 225 | # not need another line for the kind of hw. decoding 226 | hw_accel = video_section.get('MPVHardwareAcceleration', 'false').lower() 227 | hw_accel_valid = ['false', 'vaapi', 'vdpau'] 228 | if hw_accel not in hw_accel_valid: 229 | hw_accel = 'false' 230 | 231 | player_final = player 232 | if player == 'mpv': 233 | if hw_accel != 'false': 234 | player_final = 'mpv --hwdec={0} --cache=auto'.format(hw_accel) 235 | else: 236 | player_final = 'mpv --cache=auto' 237 | 238 | default_quality = video_section.get('DefaultQuality', 'high') 239 | if default_quality not in ['low', 'medium', 'high', 'source']: 240 | default_quality = 'high' 241 | 242 | # Which columns to display 243 | columns_section = config['COLUMNS'] 244 | 245 | # Display options 246 | display_section = config['DISPLAY'] 247 | sort_by = display_section.get('SortBy', 'GameName') 248 | if sort_by not in ['1', '2', '3', 'GameName']: 249 | sort_by = 'GameName' 250 | 251 | truncate_status_at = display_section.getint('TruncateStatus', 0) 252 | if truncate_status_at == 0: 253 | truncate_status_at = shutil.get_terminal_size().columns - 44 254 | 255 | # Color selection 256 | colors_section = config['COLORS'] 257 | numbers = colors_section.get('Numbers', 'yellow') 258 | game_name = colors_section.get('GameName', 'cyan') 259 | column1 = colors_section.get('Column1', 'green') 260 | column2 = colors_section.get('Column2', 'green') 261 | column3 = colors_section.get('Column3', 'green') 262 | 263 | # Escape codes per color 264 | escape_codes = { 265 | 'black': '\033[30m', 266 | 'darkgray': '\033[90m', 267 | 'darkred': '\033[31m', 268 | 'red': '\033[91m', 269 | 'darkgreen': '\033[32m', 270 | 'green': '\033[92m', 271 | 'darkyellow': '\033[33m', 272 | 'yellow': '\033[93m', 273 | 'darkblue': '\x1b[0;34;40m', 274 | 'blue': '\033[94m', 275 | 'darkmagenta': '\033[35m', 276 | 'magenta': '\033[95m', 277 | 'darkcyan': '\033[36m', 278 | 'cyan': '\033[96m', 279 | 'gray': '\033[37m', 280 | 'white': '\033[97m', 281 | 'end': '\033[0m'} 282 | 283 | # When to display chat 284 | chat_section = config['CHAT'] 285 | 286 | # How to display data in non-interactive operation 287 | non_interactive = config['NON-INTERACTIVE'] 288 | display_scheme = non_interactive.get( 289 | 'DisplayScheme', ('GameName', 'GameAltName', 'ChannelName', 'ChannelAltName')) 290 | display_scheme = display_scheme.replace(' ', '').split(',') 291 | delimiter = non_interactive.get('Delimiter', ',') 292 | 293 | try: 294 | Columns = collections.namedtuple( 295 | 'Columns', 296 | ['column1', 'column2', 'column3']) 297 | self.columns = Columns( 298 | columns_section.get('Column1', 'ChannelName'), 299 | columns_section.get('Column2', 'Viewers'), 300 | columns_section.get('Column3', 'StreamStatus')) 301 | 302 | Video = collections.namedtuple( 303 | 'Video', 304 | ['default_quality', 'player_final']) 305 | self.video = Video( 306 | default_quality, 307 | player_final) 308 | 309 | Display = collections.namedtuple( 310 | 'Display', 311 | ['sort_by', 'column_names', 'truncate_status', 'check_interval']) 312 | self.display = Display( 313 | sort_by, 314 | display_section.getboolean('ColumnNames', False), 315 | truncate_status_at, 316 | display_section.getint('CheckInterval', 60)) 317 | 318 | Colors = collections.namedtuple( 319 | 'Colors', 320 | ['numbers', 'game_name', 'column1', 'column2', 'column3']) 321 | self.colors = Colors( 322 | escape_codes[numbers], 323 | escape_codes[game_name], 324 | escape_codes[column1], 325 | escape_codes[column2], 326 | escape_codes[column3]) 327 | 328 | Chat = collections.namedtuple( 329 | 'Chat', 330 | ['enable']) 331 | self.chat = Chat( 332 | chat_section.getboolean('Enable', True)) 333 | 334 | # Required only at runtime in case values for a conky instance are needed 335 | self.conky_run = False 336 | 337 | self.quality_map = { 338 | 'low': '360p', 339 | 'medium': '480p', 340 | 'high': '720p', 341 | 'source': 'best'} 342 | 343 | self.non_int_display_scheme = display_scheme 344 | self.non_int_delimiter = delimiter 345 | 346 | except KeyError: 347 | print(Colors.RED + 348 | ' Error getting options. Running --configure:' + 349 | Colors.ENDC) 350 | gen_config = ConfigInit() 351 | gen_config.configure_options() 352 | -------------------------------------------------------------------------------- /twitchy/twitchy_database.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Database manipulation module 3 | 4 | import os 5 | import sqlite3 6 | 7 | from twitchy import twitchy_api 8 | from twitchy.twitchy_config import Colors, YouAndTheHorseYouRodeInOn 9 | 10 | location_prefix = os.path.expanduser('~') + '/.config/twitchy3/' 11 | 12 | 13 | class DatabaseInit: 14 | def __init__(self): 15 | self.database_path = location_prefix + 'twitchy.db' 16 | self.database_path_old = os.path.expanduser('~') + '/.config/twitchy/twitchy.db' 17 | 18 | if is_test(): 19 | return 20 | 21 | if not os.path.exists(self.database_path): 22 | if not os.path.exists(self.database_path_old): 23 | print( 24 | Colors.CYAN + 25 | ' Creating database: Add channels with -a or -s' + 26 | Colors.ENDC) 27 | self.create_database() 28 | exit() 29 | else: 30 | print( 31 | Colors.CYAN + 32 | ' Found old database. Rebuilding from existing values.' + 33 | Colors.ENDC) 34 | self.create_database() 35 | self.rebuild_database() 36 | exit() 37 | 38 | def create_database(self): 39 | database = sqlite3.connect(self.database_path) 40 | 41 | database.execute( 42 | "CREATE TABLE channels \ 43 | (id INTEGER PRIMARY KEY, Name TEXT, ChannelID INTEGER, TimeWatched INTEGER,\ 44 | DisplayName TEXT, AltName TEXT, IsPartner TEXT)") 45 | database.execute( 46 | "CREATE TABLE games \ 47 | (id INTEGER PRIMARY KEY, Name TEXT, GameID INTEGER, TimeWatched INTEGER,\ 48 | AltName TEXT)") 49 | 50 | def rebuild_database(self): 51 | database_new = sqlite3.connect(self.database_path) 52 | database_new.execute(f"ATTACH '{self.database_path_old}' as DBOLD") 53 | 54 | # Copy the channels and games tables 55 | for i in ['games', 'channels']: 56 | database_new.execute( 57 | f"INSERT INTO main.{i} (Name,TimeWatched,AltName) " 58 | f"SELECT Name,TimeWatched,AltName FROM DBOLD.{i}") 59 | database_new.commit() 60 | 61 | # Iterate over the new tables and fill in missing values 62 | def fill_in_the_blanks(table): 63 | name_data = database_new.execute(f"SELECT Name FROM {table}").fetchall() 64 | valid_entries = twitchy_api.name_id_translate(table, 'id_from_name', name_data) 65 | 66 | if table == 'games': 67 | # In this case, valid_entries is a list 68 | # that contains 2 entires 69 | # 0 is the id number 70 | # 1 is the game name 71 | for i in valid_entries: 72 | game_id = i[0] 73 | game_name = i[1] 74 | sql_command = ( 75 | f"UPDATE games SET GameID = '{game_id}' WHERE Name = '{game_name}'") 76 | database_new.execute(sql_command) 77 | 78 | database_new.execute( 79 | f"DELETE FROM games WHERE GameID is NULL") 80 | 81 | elif table == 'channels': 82 | # In this case, valid_entires is a dictionary 83 | # That contains broadcaster_type, display_name, and id 84 | # couples to each channel name 85 | for i in valid_entries.items(): 86 | channel_name = i[0] 87 | display_name = i[1]['display_name'] 88 | channel_id = i[1]['id'] 89 | is_partner = False 90 | if i[1]['broadcaster_type'] == 'partner': 91 | is_partner = True 92 | 93 | sql_command = ( 94 | f"UPDATE channels SET " 95 | f"ChannelID = '{channel_id}', DisplayName = '{display_name}', " 96 | f"IsPartner = '{is_partner}' " 97 | f"WHERE Name = '{channel_name}'") 98 | database_new.execute(sql_command) 99 | 100 | database_new.execute( 101 | f"DELETE FROM channels WHERE ChannelID is NULL") 102 | 103 | database_new.commit() 104 | 105 | fill_in_the_blanks('channels') 106 | fill_in_the_blanks('games') 107 | 108 | def remove_database(self): 109 | os.remove(self.database_path) 110 | 111 | 112 | class DatabaseFunctions: 113 | def __init__(self): 114 | if is_test(): 115 | return 116 | 117 | self.database_path = location_prefix + 'twitchy.db' 118 | self.database = sqlite3.connect(self.database_path) 119 | 120 | def add_channels(self, data): 121 | # Used for -a and -s 122 | # That's addition and syncing 123 | # data is a dictionary that will be iterated upon 124 | added_channels = [] 125 | for channel_data in data.items(): 126 | 127 | channel_name = channel_data[0] 128 | channel_id = channel_data[1]['id'] 129 | display_name = channel_data[1]['display_name'] 130 | is_partner = True 131 | if channel_data[1]['broadcaster_type'] != 'partner': 132 | is_partner = False 133 | 134 | sql_command_exist = f"SELECT Name FROM channels WHERE Name = '{channel_name}'" 135 | does_it_exist = self.database.execute( 136 | sql_command_exist).fetchone() 137 | 138 | if not does_it_exist: 139 | sql_command_add = ( 140 | f"INSERT INTO channels " 141 | f"(Name,ChannelID,TimeWatched,DisplayName,IsPartner) VALUES " 142 | f"('{channel_name}',{channel_id},0,'{display_name}','{is_partner}')") 143 | 144 | self.database.execute(sql_command_add) 145 | added_channels.append(channel_name) 146 | 147 | self.database.commit() 148 | return added_channels 149 | 150 | def add_games(self, game_name, game_id): 151 | # Used for addition of a game that is seen at initial listing 152 | # Does not need to return anything 153 | # Checking is no longer required either because it is now 154 | # taken care of in the twitchy_api module 155 | sql_command_add = ( 156 | f"INSERT INTO games (Name,GameID,Timewatched) VALUES ('{game_name}',{game_id},0)") 157 | self.database.execute(sql_command_add) 158 | self.database.commit() 159 | 160 | def fetch_data(self, columns, table, selection_criteria, equivalence, fetch_one=False): 161 | # columns is a tuple that will be passed as a comma separated list 162 | # table is a string that will be used as is 163 | # selection_criteria is a dictionary which contains the name of a column linked 164 | # to a corresponding value for selection 165 | 166 | # Example: 167 | # Name and AltName are expected to be the same 168 | # sel_dict = { 169 | # 'Name': 'sav', 170 | # 'AltName': 'sav' 171 | # } 172 | # data = DatabaseFunctions().fetch_data(('Name',), 'channels', sel_dict) 173 | try: 174 | column_list = ','.join(columns) 175 | sql_command_fetch = f"SELECT {column_list} FROM {table}" 176 | if selection_criteria: 177 | sql_command_fetch += " WHERE" 178 | 179 | if equivalence == 'EQUALS': 180 | for i in selection_criteria.keys(): 181 | search_parameter = selection_criteria[i] 182 | sql_command_fetch += f" {i} = '{search_parameter}' OR" 183 | 184 | elif equivalence == 'LIKE': 185 | for i in selection_criteria.keys(): 186 | search_parameter = "'%" + selection_criteria[i] + "%'" 187 | sql_command_fetch += f" {i} LIKE {search_parameter} OR" 188 | 189 | sql_command_fetch = sql_command_fetch[:-3] # Truncate the last OR 190 | 191 | # channel data is returned as a list of tuples 192 | channel_data = self.database.execute(sql_command_fetch).fetchall() 193 | 194 | if channel_data: 195 | # Because this is the result of a fetchall(), we need an 196 | # ugly hack (tm) to get correct results for anything that 197 | # isn't a database id search 198 | # This will cause issues in case someone wants to refer to 199 | # streamers and games as digits. We don't need that shit here. 200 | 201 | # Another consideration is returns for time watched 202 | # In that case, just go 0 of 0 203 | if fetch_one: 204 | return channel_data[0][0] 205 | 206 | return channel_data 207 | else: 208 | return None 209 | 210 | except sqlite3.OperationalError: 211 | raise YouAndTheHorseYouRodeInOn(' Database error.') 212 | 213 | def modify_data(self, mode, table, criteria): 214 | sql_commands = [] 215 | 216 | if mode == 'alternate_name': 217 | # criteria in this case is expected to be a dictionary containing 218 | # new_name and old_name keys corresponding to their values 219 | old_name = criteria['old_name'] 220 | new_name = criteria['new_name'] 221 | if not new_name or new_name == '': 222 | # NULL is the sql equivalent of NoneType 223 | new_name = 'NULL' 224 | else: 225 | # Encapsulate in single quotes to form valid SQL 226 | new_name = f"'{new_name}'" 227 | 228 | sql_commands.append( 229 | f"UPDATE {table} SET AltName = {new_name} WHERE Name = '{old_name}'") 230 | 231 | if mode == 'delete': 232 | # In this case, criteria is a single string 233 | sql_commands.append( 234 | f"DELETE FROM {table} WHERE Name = '{criteria}'") 235 | 236 | if mode == 'update_time': 237 | # In this case, criteria is expected to be a dictionary containing 238 | # the new time_watched values for both the channel and the game 239 | # Therefore, table is None 240 | channel_name = criteria['channel_name'] 241 | channel_time = criteria['new_time_channel'] 242 | game_name = criteria['game_name'] 243 | game_time = criteria['new_time_game'] 244 | 245 | sql_commands.append( 246 | f"UPDATE channels SET TimeWatched = {channel_time} WHERE Name = '{channel_name}'") 247 | sql_commands.append( 248 | f"UPDATE games SET TimeWatched = {game_time} WHERE Name = '{game_name}'") 249 | 250 | for i in sql_commands: 251 | self.database.execute(i) 252 | self.database.commit() 253 | self.database.execute('VACUUM') 254 | 255 | 256 | def is_test(): 257 | config_path = location_prefix + os.sep + 'twitchy.cfg' 258 | with open(config_path) as current_config: 259 | first_line = current_config.readlines()[0] 260 | 261 | if first_line == '# TEST CONFIG FILE\n': 262 | return True 263 | return False 264 | -------------------------------------------------------------------------------- /twitchy/twitchy_display.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Table display and selection module 3 | 4 | import locale 5 | import random 6 | 7 | from twitchy import twitchy_config 8 | from twitchy.twitchy_config import Colors 9 | 10 | Options = twitchy_config.Options() 11 | Options.parse_options() 12 | 13 | # Set locale for comma placement 14 | locale.setlocale(locale.LC_ALL, '') 15 | 16 | 17 | # Display template mapping for extra spicy output 18 | def template_mapping(called_from): 19 | 20 | third_column = 1 21 | # Preceding specification is pointless as long as it's non zero 22 | # If however, it exceeds the column number of the terminal, 23 | # we'll get the unenviable free line breaks. That's just silly. 24 | 25 | if called_from == 'list': 26 | first_column = 30 27 | second_column = 40 28 | elif called_from == 'gameslist': 29 | first_column = 50 30 | second_column = 55 31 | elif called_from == 'watch': 32 | first_column = 25 33 | second_column = 20 34 | elif called_from == 'vods': 35 | first_column = 25 36 | second_column = 80 37 | 38 | template = '{0:%s}{1:%s}{2:%s}' % ( 39 | first_column, second_column, third_column) 40 | return template 41 | 42 | 43 | # Convert time in seconds to a more human readable format 44 | def time_convert(seconds): 45 | seconds = int(seconds) 46 | m, s = divmod(seconds, 60) 47 | h, m = divmod(m, 60) 48 | d, h = divmod(h, 24) 49 | 50 | # FizzBuzz 51 | time_converted = '' 52 | if d > 0: 53 | time_converted += f'{d}d ' 54 | if h > 0: 55 | time_converted += f'{h}h ' 56 | if m > 0: 57 | time_converted += f'{m}m' 58 | else: 59 | time_converted = f'{s}s' 60 | 61 | return time_converted 62 | 63 | 64 | # Emotes - Kappa? Kappa. 65 | def emote(): 66 | kappa = ( 67 | ' ░░░░░░░░░░░░░░░░░░░░\n' 68 | ' ░░░░▄▀▀▀▀▀█▀▄▄▄▄░░░░\n' 69 | ' ░░▄▀▒▓▒▓▓▒▓▒▒▓▒▓▀▄░░\n' 70 | ' ▄▀▒▒▓▒▓▒▒▓▒▓▒▓▓▒▒▓█░\n' 71 | ' █▓▒▓▒▓▒▓▓▓░░░░░░▓▓█░\n' 72 | ' █▓▓▓▓▓▒▓▒░░░░░░░░▓█░\n' 73 | ' ▓▓▓▓▓▒░░░░░░░░░░░░█░\n' 74 | ' ▓▓▓▓░░░░▄▄▄▄░░░▄█▄▀░\n' 75 | ' ░▀▄▓░░▒▀▓▓▒▒░░█▓▒▒░░\n' 76 | ' ▀▄░░░░░░░░░░░░▀▄▒▒█░\n' 77 | ' ░▀░▀░░░░░▒▒▀▄▄▒▀▒▒█░\n' 78 | ' ░░▀░░░░░░▒▄▄▒▄▄▄▒▒█░\n' 79 | ' ░░░▀▄▄▒▒░░░░▀▀▒▒▄▀░░\n' 80 | ' ░░░░░▀█▄▒▒░░░░▒▄▀░░░\n' 81 | ' ░░░░░░░░▀▀█▄▄▄▄▀░░░░') 82 | 83 | print(kappa) 84 | 85 | 86 | def get_selection(mode, table_max_val): 87 | # Returns whatever the user selects at the relevant prompt 88 | # Modes are 'online_channels' and 'database' 89 | 90 | try: 91 | channel_selection = input(' Number? ') 92 | 93 | # Whatever is selected is passed to this list 94 | # This is then iterated upon to get the list of parameters 95 | # that will be passed back to the parent function 96 | final_selection = [] 97 | 98 | entered_numbers = channel_selection.split() 99 | if not entered_numbers: 100 | # Everything should error out in case a null selection is 101 | # made when we're operating purely with database values 102 | # Otherwise, we're going for a random selection 103 | # with default quality video 104 | if mode == 'database': 105 | raise ValueError 106 | elif mode == 'online_channels': 107 | final_selection = [[ 108 | random.randrange(0, table_max_val), 109 | Options.video.default_quality]] 110 | emote() 111 | return final_selection 112 | 113 | else: 114 | quality_dict = { 115 | 'l': 'low', 116 | 'm': 'medium', 117 | 'h': 'high', 118 | 's': 'source'} 119 | 120 | entered_numbers = [i.split('-') for i in entered_numbers] 121 | for i in entered_numbers: 122 | if int(i[0]) > table_max_val: 123 | raise IndexError 124 | 125 | try: 126 | selected_quality = quality_dict[i[1]] 127 | except (KeyError, IndexError): 128 | # Anything that has a valid digit that prefixes it 129 | # is started at the default_quality 130 | selected_quality = Options.video.default_quality 131 | 132 | final_selection.append([ 133 | int(i[0]) - 1, 134 | selected_quality]) 135 | 136 | return final_selection 137 | 138 | except (IndexError, ValueError): 139 | print(Colors.RED + ' Invalid input.' + Colors.ENDC) 140 | exit(1) 141 | except (KeyboardInterrupt, EOFError): 142 | print() 143 | exit(1) 144 | 145 | 146 | class GenerateWatchTable(): 147 | # Applies to the watch functions 148 | # Will wait for an input and return it to the function 149 | # that called it 150 | 151 | def __init__(self, table_data_incoming): 152 | self.table_data_incoming = table_data_incoming 153 | self.item_count = None 154 | self.display_list = None 155 | 156 | def table_display(self, display_list): 157 | # Accepts a list containg the first, second, and third 158 | # columns. These are shown according to formatting guidance 159 | # from the other functions in this class 160 | # The self.display_list also has a [3] that corresponds to relational 161 | # parameters that will be used at the time of selection 162 | 163 | # It's worth remembering that sorting is case sensitive 164 | # Goodbye, one little bit of my sanity 165 | 166 | self.display_list = display_list 167 | if Options.display.sort_by == 'GameName': 168 | # The lambda function is of critical importance since 169 | # it decides how the display will actually go 170 | # The - in the lambda implies sorting is by reverse 171 | # A negative value is only applicable to integers 172 | self.display_list.sort( 173 | key=lambda x: (x[3]['game_display_name'].lower(), -x[3]['viewers'])) 174 | previous_game = None 175 | 176 | else: 177 | sorting_column_index = int(Options.display.sort_by) - 1 178 | self.display_list.sort( 179 | key=lambda x: x[sorting_column_index].lower()) 180 | 181 | template = template_mapping('watch') 182 | list_digits = len(str(len(display_list))) 183 | 184 | for count, i in enumerate(self.display_list): 185 | 186 | # If sorting is by GameName, print only the 187 | # name of every game that's mentioned for the first time 188 | if Options.display.sort_by == 'GameName': 189 | current_game = i[3]['game_display_name'] 190 | if previous_game != current_game: 191 | previous_game = current_game 192 | print( 193 | ' ' + 194 | Options.colors.game_name + 195 | current_game) 196 | 197 | print( 198 | ' ' + 199 | Options.colors.numbers + str(count + 1).rjust(list_digits) + 200 | ' ' + 201 | template.format( 202 | Options.colors.column1 + i[0], 203 | Options.colors.column2 + i[1].rjust(8), 204 | Options.colors.column3 + i[2]) + 205 | Colors.ENDC) 206 | 207 | def begin(self): 208 | # self.table_data_incoming is the bog standard dictionary that 209 | # will be iterated upon 210 | # Since columns are selectable, the table will have to be built 211 | # for each channel here 212 | # Valid options are: ChannelName, Viewers, Uptime, StreamStatus, GameName 213 | final_columns = [] 214 | for i in self.table_data_incoming.items(): 215 | 216 | game_display_name = i[1]['game_display_name'] 217 | display_columns = [] 218 | for j in Options.columns: 219 | 220 | if j == 'ChannelName': 221 | add_this = i[1]['display_name'] 222 | 223 | elif j == 'Viewers': 224 | # Convert the number of viewers into a string 225 | # formatted by an appropriately placed comma 226 | add_this = str(format(i[1]['viewers'], 'n')) 227 | 228 | elif j == 'Uptime': 229 | # Convert the uptime into H:M:S 230 | add_this = time_convert(i[1]['uptime']) 231 | 232 | elif j == 'StreamStatus': 233 | add_this = i[1]['status'] 234 | if len(add_this) > Options.display.truncate_status: 235 | add_this = add_this[:Options.display.truncate_status] + '...' 236 | 237 | elif j == 'GameName': 238 | if game_display_name: 239 | add_this = game_display_name 240 | 241 | display_columns.append(add_this) 242 | 243 | # At [3] in display_columns, is a dictionary containg both 244 | # the (alternate) game_name as well as the real channel name 245 | if not game_display_name: 246 | game_display_name = i[1]['game'] 247 | relational_params = { 248 | 'name': i[0], 249 | 'viewers': i[1]['viewers'], 250 | 'game_display_name': game_display_name} 251 | 252 | display_columns.append(relational_params) 253 | final_columns.append(display_columns) 254 | 255 | self.table_display(final_columns) 256 | final_selection = get_selection( 257 | 'online_channels', len(self.table_data_incoming)) 258 | 259 | # Generate the final selection dictionary 260 | # Its keys are the names of the channels 261 | # Corresponding values are channel params 262 | # Channel quality is inserted as a value on the basis 263 | # of its selection from the relevant function 264 | selected_channels = {} 265 | for i in final_selection: 266 | channel_name = self.display_list[i[0]][3]['name'] 267 | selected_channels[channel_name] = self.table_data_incoming[channel_name] 268 | selected_channels[channel_name]['quality'] = i[1] 269 | 270 | return selected_channels 271 | 272 | 273 | class GenerateDatabaseTable: 274 | # Applies to functions that deal with the database 275 | def __init__(self, table_data_incoming, table): 276 | self.table_data_incoming = table_data_incoming 277 | self.table = table 278 | 279 | def table_display(self, display_list): 280 | # self.table_data_incoming is a list of tuples 281 | # Indices 282 | # 0: Channel name 283 | # 1: Time Watched 284 | # 2: Alt Name 285 | 286 | if self.table == 'channels': 287 | template = template_mapping('list') 288 | elif self.table == 'games': 289 | template = template_mapping('gameslist') 290 | 291 | # Sort by Time watched and then by channel name 292 | display_list.sort(key=lambda x: x[0]) 293 | 294 | list_digits = len(str(len(display_list))) 295 | for count, i in enumerate(display_list): 296 | 297 | # Display colors in case of specific value ranges only 298 | row_color2 = Colors.ENDC 299 | 300 | if i[1] == 0: 301 | time_watched = Colors.RED + ' Unwatched' 302 | else: 303 | time_watched = time_convert(i[1]).rjust(10) 304 | 305 | alt_name = i[2] 306 | if alt_name: 307 | row_color2 = Colors.CYAN 308 | time_watched = ' ' + time_watched 309 | 310 | print( 311 | ' ' + 312 | Options.colors.numbers + str(count + 1).rjust(list_digits) + 313 | Colors.ENDC + 314 | ' ' + 315 | template.format( 316 | i[0], 317 | row_color2 + str(alt_name), 318 | Colors.ENDC + time_watched) + 319 | Colors.ENDC) 320 | 321 | def begin(self): 322 | 323 | self.table_display(self.table_data_incoming) 324 | final_selection = get_selection( 325 | 'database', len(self.table_data_incoming)) 326 | 327 | # A double 0 index is required because we're reusing the 328 | # get_selection function. This also returns the default quality 329 | # setting in case of selection of a list of numbers 330 | return_list = [ 331 | self.table_data_incoming[i[0]][0] for i in final_selection] 332 | 333 | return return_list 334 | 335 | class GenerateVODTable: 336 | def __init__(self, vod_data): 337 | self.vod_data = vod_data 338 | 339 | def table_display(self, display_list): 340 | template = template_mapping('vods') 341 | list_digits = len(str(len(display_list))) 342 | for count, i in enumerate(display_list): 343 | 344 | print( 345 | ' ' + 346 | Options.colors.numbers + str(count + 1).rjust(list_digits) + 347 | Colors.ENDC + 348 | ' ' + 349 | template.format(i[0], i[1], '')) 350 | 351 | def begin(self): 352 | self.table_display(self.vod_data) 353 | final_selection = get_selection( 354 | 'database', len(self.vod_data)) 355 | 356 | return_list = [ 357 | [self.vod_data[i[0]][1], self.vod_data[i[0]][2]] for i in final_selection] 358 | 359 | return return_list 360 | -------------------------------------------------------------------------------- /twitchy/twitchy_play.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Playtime class generator module 3 | 4 | import os 5 | import sys 6 | import time 7 | import shlex 8 | import select 9 | import subprocess 10 | import webbrowser 11 | 12 | from twitchy import twitchy_database 13 | from twitchy import twitchy_config 14 | from twitchy.twitchy_config import Colors 15 | from twitchy.twitchy_display import time_convert 16 | 17 | Options = twitchy_config.Options() 18 | Options.parse_options() 19 | 20 | 21 | class Playtime: 22 | def __init__(self, channel_name, channel_params): 23 | # channel_name will come from the dictionary returned 24 | # from calling the api 25 | self.channel_name = channel_name 26 | self.channel_params = channel_params 27 | self.player_process = None 28 | self.start_time = None 29 | 30 | def play(self): 31 | 32 | # Display chat in a fresh browser window as a popup 33 | if Options.chat.enable: 34 | chat_url = f'http://www.twitch.tv/{self.channel_name}/chat?popout=' 35 | try: 36 | webbrowser.get('chromium').open_new(f'--app={chat_url}') 37 | except webbrowser.Error: 38 | webbrowser.open_new(chat_url) 39 | except TypeError: 40 | webbrowser.get('chromium').open_new(f'--app={chat_url}') # WTF? 41 | 42 | # Insert the name of only started games into the database 43 | # This keeps the database from getting too cluttered 44 | display_name = self.channel_params['display_name'] 45 | player = Options.video.player_final 46 | if player[:3] == 'mpv': 47 | player += f' --title={display_name}' 48 | quality = Options.quality_map[self.channel_params['quality']] 49 | 50 | # The following prints to the console 51 | # If ever there is going to be a curses version 52 | # it will need to be suppressed 53 | if twitchy_config.print_to_stdout: 54 | print(' ' + Colors.WHITE + 55 | self.channel_params['display_name'] + Colors.ENDC + 56 | ' | ' + Colors.WHITE + 57 | self.channel_params['quality'].title() + Colors.ENDC) 58 | 59 | args_to_subprocess = ( 60 | f"streamlink twitch.tv/{self.channel_name} {quality} --player '{player}'") 61 | hls_settings = ' --hls-segment-threads 3' 62 | args_to_subprocess = shlex.split(args_to_subprocess + hls_settings) 63 | 64 | # Get the time when the stream starts 65 | self.start_time = time.time() 66 | 67 | if twitchy_config.non_interactive_mode: 68 | self.player_process = subprocess.Popen( 69 | args_to_subprocess, 70 | preexec_fn=os.setpgrp, 71 | stdout=subprocess.DEVNULL, 72 | stderr=subprocess.DEVNULL) 73 | exit(0) 74 | else: 75 | self.player_process = subprocess.Popen( 76 | args_to_subprocess, 77 | stdout=subprocess.PIPE, 78 | stderr=subprocess.PIPE, 79 | preexec_fn=os.setpgrp) 80 | 81 | def time_tracking(self): 82 | end_time = time.time() 83 | time_watched = end_time - self.start_time 84 | database_instance = twitchy_database.DatabaseFunctions() 85 | 86 | def fetch_time_data(): 87 | # Even for a non watched channel, the database 88 | # always has a 0 value associated 89 | # Therefore, there will be no None returns 90 | time_watched_channel = database_instance.fetch_data( 91 | ('TimeWatched',), 92 | 'channels', 93 | {'Name': self.channel_name}, 94 | 'EQUALS', 95 | True) 96 | 97 | time_watched_game = database_instance.fetch_data( 98 | ('TimeWatched',), 99 | 'games', 100 | {'Name': self.channel_params['game']}, 101 | 'EQUALS', 102 | True) 103 | 104 | return time_watched_channel, time_watched_game 105 | 106 | time_data = fetch_time_data() 107 | 108 | time_values = { 109 | 'channel_name': self.channel_name, 110 | 'new_time_channel': time_data[0] + time_watched, 111 | 'game_name': self.channel_params['game'], 112 | 'new_time_game': time_data[1] + time_watched} 113 | 114 | database_instance.modify_data( 115 | 'update_time', 116 | None, 117 | time_values) 118 | 119 | time_data_new = fetch_time_data() 120 | game_display_name = self.channel_params['game_display_name'] 121 | if not game_display_name: 122 | game_display_name = self.channel_params['game'] 123 | 124 | channel_rank = get_rank_data('channels', self.channel_name) 125 | if channel_rank: 126 | channel_rank = ' (' + channel_rank + ')' 127 | 128 | game_rank = get_rank_data('games', self.channel_params['game']) 129 | if game_rank: 130 | game_rank = ' (' + game_rank + ')' 131 | 132 | # Consider shfting this to the main module 133 | if twitchy_config.print_to_stdout: 134 | print( 135 | ' ' + Colors.WHITE + 136 | self.channel_params['display_name'] + ': ' + Colors.ENDC + 137 | time_convert(time_data_new[0]) + channel_rank + 138 | ' | ' + Colors.WHITE + 139 | game_display_name + ': ' + Colors.ENDC + 140 | time_convert(time_data_new[1]) + game_rank) 141 | 142 | 143 | class VOD: 144 | def __init__(self, display_name, vod_title, vod_url): 145 | self.display_name = display_name 146 | self.vod_title = vod_title 147 | self.vod_url = vod_url 148 | self.player_process = None 149 | 150 | def play(self): 151 | player = Options.video.player_final + f' --title={self.display_name}' 152 | args_to_subprocess = ( 153 | f"streamlink {self.vod_url} best --player '{player}'") 154 | hls_settings = ' --hls-segment-threads 3 --player-passthrough=hls' 155 | args_to_subprocess = shlex.split(args_to_subprocess + hls_settings) 156 | 157 | if twitchy_config.print_to_stdout: 158 | print(' ' + Colors.WHITE + 159 | self.display_name + Colors.ENDC + 160 | ' | ' + Colors.WHITE + 161 | self.vod_title + Colors.ENDC) 162 | 163 | self.player_process = subprocess.Popen( 164 | args_to_subprocess, 165 | stdout=subprocess.DEVNULL, 166 | stderr=subprocess.DEVNULL) 167 | 168 | 169 | def play_instance_generator(incoming_dict): 170 | playtime_instance = {} 171 | 172 | if not twitchy_config.vod_mode: 173 | # Create instances of the Playtime class 174 | for count, i in enumerate(incoming_dict.items()): 175 | playtime_instance[count] = Playtime(i[0], i[1]) 176 | playtime_instance[count].play() 177 | 178 | else: 179 | for i in incoming_dict.items(): 180 | display_name = i[0] 181 | for count, j in enumerate(i[1]): 182 | playtime_instance[count] = VOD(display_name, j[0], j[1]) 183 | playtime_instance[count].play() 184 | 185 | if twitchy_config.disown: 186 | exit(0) 187 | 188 | # Time tracking switch 189 | time_tracking = twitchy_config.time_tracking 190 | 191 | total_streams = count + 1 192 | playing_streams = [i for i in range(total_streams)] 193 | while playing_streams: 194 | for i in playing_streams: 195 | # process_returncode returns None in case the process is still running 196 | # It returns 0 in case the process exits without error 197 | playtime_instance[i].player_process.poll() 198 | process_returncode = playtime_instance[i].player_process.returncode 199 | 200 | if process_returncode is not None: 201 | if process_returncode == 1: 202 | stream_stdout = playtime_instance[i].player_process.stdout.read().decode( 203 | 'utf-8').split('\n') 204 | stream_stderr = playtime_instance[i].player_process.stderr.read().decode( 205 | 'utf-8').split('\n') 206 | 207 | all_error = stream_stdout + stream_stderr 208 | error_message = [er for er in all_error if 'error:' in er] 209 | print(' ' + 210 | Colors.RED + playtime_instance[i].channel_params['display_name'] + 211 | Colors.ENDC, 212 | ':', 213 | error_message) 214 | elif process_returncode == 0: 215 | if time_tracking: 216 | playtime_instance[i].time_tracking() 217 | playing_streams.remove(i) 218 | 219 | try: 220 | # The 0.8 is the polling interval for the streamlink process 221 | # The o, e is needed for reasons I don't completely understand 222 | keypress, o, e = select.select([sys.stdin], [], [], 0.8) 223 | if keypress: 224 | keypress_made = sys.stdin.readline().strip() 225 | if keypress_made == "q": 226 | raise KeyboardInterrupt 227 | except KeyboardInterrupt: 228 | for i in playing_streams: 229 | if time_tracking: 230 | playtime_instance[i].time_tracking() 231 | playtime_instance[i].player_process.terminate() 232 | playing_streams.clear() 233 | 234 | 235 | def get_rank_data(table, name): 236 | # Returns the rank of the requisite channel or game 237 | # as a string 238 | database_instance = twitchy_database.DatabaseFunctions() 239 | 240 | time_and_name = database_instance.fetch_data( 241 | ('TimeWatched', 'Name'), 242 | table, 243 | None, 244 | 'EQUALS') 245 | 246 | time_and_name.sort(reverse=True) 247 | names_only = [i[1] for i in time_and_name] 248 | 249 | try: 250 | rank = str(names_only.index(name) + 1) 251 | except ValueError: 252 | # In case the provided name is not in the database 253 | return 254 | 255 | return rank 256 | --------------------------------------------------------------------------------