File Coverage

blib/lib/WWW/Twitch.pm
Criterion Covered Total %
statement 26 142 18.3
branch 0 16 0.0
condition 0 10 0.0
subroutine 9 28 32.1
pod 5 11 45.4
total 40 207 19.3


line stmt bran cond sub pod time code
1             package WWW::Twitch;
2 1     1   974 use 5.020;
  1         4  
3 1     1   893 use Moo 2;
  1         10383  
  1         7  
4 1     1   2738 use experimental 'signatures';
  1         2073  
  1         7  
5              
6 1     1   256 use Carp 'croak';
  1         3  
  1         81  
7              
8 1     1   2184 use JSON 'encode_json', 'decode_json';
  1         14755  
  1         9  
9 1     1   871 use POSIX 'strftime';
  1         8957  
  1         8  
10 1     1   3045 use Future::Utils 'repeat';
  1         20920  
  1         136  
11 1     1   703 use Future::HTTP;
  1         901  
  1         1618  
12              
13             our $VERSION = '0.03';
14              
15             =head1 NAME
16              
17             WWW::Twitch - automate parts of Twitch without the need for an API key
18              
19             =head1 SYNOPSIS
20              
21             use 5.012; # say
22             use WWW::Twitch;
23              
24             my $channel = 'corion_de';
25             my $twitch = WWW::Twitch->new();
26             my $info = $twitch->live_stream($channel);
27             if( $info ) {
28             my $id = $info->{id};
29              
30             opendir my $dh, '.'
31             or die "$!";
32              
33             # If we have stale recordings, maybe our network went down
34             # in between
35             my @recordings = grep { /\b$id\.mp4(\.part)?$/ && -M $_ < 30/24/60/60 }
36             readdir $dh;
37              
38             if( ! @recordings ) {
39             say "$channel is live (Stream $id)";
40             say "Launching youtube-dl";
41             exec "youtube_dl", '-q', "https://www.twitch.tv/$channel";
42             } else {
43             say "$channel is recording (@recordings)";
44             };
45              
46             } else {
47             say "$channel is offline";
48             }
49              
50             =cut
51              
52             =head1 METHODS
53              
54             =head2 C<< ->new >>
55              
56             my $twitch = WWW::Twitch->new();
57              
58             Creates a new Twitch client
59              
60             =over 4
61              
62             =item B
63              
64             Optional device id. If missing, a hardcoded
65             device id will be used.
66              
67             =item B
68              
69             Optional client id. If missing, a hardcoded
70             client id will be used.
71              
72             =item B
73              
74             Optional client version. If missing, a hardcoded
75             client version will be used.
76              
77             =item B
78              
79             Optional HTTP user agent. If missing, a L
80             object will be constructed.
81              
82             =back
83              
84             =cut
85              
86             has 'device_id' => (
87             is => 'ro',
88             default => 'WQS1BrvLDgmo6QcdpHY7M3d4eMRjf6ji'
89             );
90             has 'client_id' => (
91             is => 'ro',
92             default => 'kimne78kx3ncx6brgo4mv6wki5h1ko'
93             );
94             has 'client_version' => (
95             is => 'ro',
96             default => '2be2ebe0-0a30-4b77-b67e-de1ee11bcf9b',
97             );
98             has 'ua' =>
99             is => 'lazy',
100             default => sub {
101             #Future::HTTP->new( verify_SSL => 1 ),
102             Future::HTTP->new(),
103             };
104              
105 0     0 0   sub fetch_gql_f( $self, $query ) {
  0            
  0            
  0            
106 0           my $f = $self->ua->http_request( POST => 'https://gql.twitch.tv/gql',
107             body => encode_json( $query ),
108             headers => {
109             # so far we need no headers
110             "Client-ID" => $self->client_id,
111             },
112 0     0     )->then(sub( $body, $headers ) {
  0            
  0            
113 0           my $res;
114 0 0         if( $body ) {
115 0           $res = decode_json( $body );
116             } else {
117 0           return Future->done()
118             }
119 0           return Future->done($res)
120 0           });
121 0           return $f
122             }
123              
124 0     0 0   sub fetch_gql( $self, $query ) {
  0            
  0            
  0            
125 0           $self->fetch_gql_f( $query )->get
126             }
127              
128             =head2 C<< ->schedule( $channel ) >>
129              
130             my $schedule = $twitch->schedule( 'somechannel', %options );
131              
132             Fetch the schedule of a channel
133              
134             =cut
135              
136 0     0 1   sub schedule( $self, $channel, %options ) {
  0            
  0            
  0            
  0            
137 0   0       $options{ start_at } //= strftime '%Y-%m-%dT%H:%M:%SZ', gmtime(time);
138 0   0       $options{ end_at } //= strftime '%Y-%m-%dT%H:%M:%SZ', gmtime(time+24*7*3600);
139 0           warn $options{ start_at };
140 0           warn $options{ end_at };
141             my $res =
142             $self->fetch_gql( [{"operationName" => "StreamSchedule",
143             "variables" => { "login" => $channel,
144             "startingWeekday" => "MONDAY",
145             "utcOffsetMinutes" => 120,
146             "startAt" => $options{ start_at },
147             "endAt" => $options{ end_at }
148             },
149 0           "extensions" => {
150             "persistedQuery" => {
151             "version" => 1,
152             "sha256Hash" => "d495cb17a67b6f7a8842e10297e57dcd553ea17fe691db435e39a618fe4699cf"
153             }
154             }
155             }]
156             );
157             #use Data::Dumper;
158             #warn Dumper $res;
159 0           return $res->[0]->{data}->{user}->{channel}->{schedule};
160             };
161              
162             =head2 C<< ->is_live( $channel ) >>
163              
164             if( $twitch->is_live( 'somechannel' ) ) {
165             ...
166             }
167              
168             Check whether a stream is currently live on a channel
169              
170             =cut
171              
172 0     0 0   sub is_live_f( $self, $channel ) {
  0            
  0            
  0            
173 0           my $f =
174             $self->fetch_gql_f([{"operationName" => "WithIsStreamLiveQuery",
175             "extensions" => {
176             "persistedQuery" => {
177             "version" => 1,
178             "sha256Hash" => "04e46329a6786ff3a81c01c50bfa5d725902507a0deb83b0edbf7abe7a3716ea"
179             }
180             }
181             },
182             #{"operationName" => "ChannelPollContext_GetViewablePoll",
183             # "variables" => {"login" => "papaplatte"},
184             # "extensions" => {"persistedQuery" => {"version" => 1,"sha256Hash" => "d37a38ac165e9a15c26cd631d70070ee4339d48ff4975053e622b918ce638e0f"}}}
185             ]
186             #"Client-Version": "9ea2055a-41f0-43b7-b295-70885b40c41c",
187             )
188 0     0     ->then(sub($res) {
  0            
189 0 0         if( $res ) {
190 0           return Future->done( $res->[0]->{data} );
191             } else {
192 0           return Future->done()
193             }
194 0           });
195 0           return $f
196             }
197              
198 0     0 1   sub is_live( $self, $channel ) {
  0            
  0            
  0            
199 0           return $self->is_live_f($channel)->get
200             }
201              
202             =head2 C<< ->stream_playback_access_token( $channel ) >>
203              
204             my $tok = $twitch->stream_playback_access_token( 'somechannel' );
205             say $tok->{channel_id};
206              
207             Internal method to fetch the stream playback access token
208              
209             =cut
210              
211 0     0 0   sub stream_playback_access_token_f( $self, $channel, %options ) {
  0            
  0            
  0            
  0            
212 0   0       my $retries = $options{ retries } // 10;
213 0   0       my $sleep = $options{ sleep } // 1;
214 0           my $error;
215             my $res = repeat {
216 0     0     my $r =
217             $self->fetch_gql_f([{"operationName" => "PlaybackAccessToken_Template",
218             "query" => 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}',
219             "variables" => {"isLive" => $JSON::true,"login" => "$channel","isVod" => $JSON::false,"vodID" => "","playerType" => "site"}},
220             ]);
221 0           return $r
222 0     0     } while => sub( $c ) {
  0            
  0            
223             # Should we offer a retry callback?!
224 0 0         !$c->get and $retries --> 0
225 0           };
226 0     0     return $res->then( sub( $res ) {
  0            
  0            
227              
228 0 0         if ( $res ) {
229 0 0         if( my $v = $res->[0]->{data}->{streamPlaybackAccessToken}->{value} ) {
    0          
230 0           return Future->done( decode_json( $v ))
231             } elsif( $error = $res->{errors} ) {
232             # ...
233 0           return Future->fail( $error );
234             } else {
235 0           return Future->done
236             }
237             }
238 0     0     })->catch(sub(@err) {
  0            
  0            
239 1     1   773 use Data::Dumper;
  1         10561  
  1         942  
240 0           warn Dumper \@err;
241 0           });
242             };
243              
244 0     0 1   sub stream_playback_access_token( $self, $channel, %options ) {
  0            
  0            
  0            
  0            
245 0           $self->stream_playback_access_token_f( $channel, %options )->get()
246             }
247              
248             =head2 C<< ->live_stream( $channel ) >>
249              
250             my $tok = $twitch->live_stream( 'somechannel' );
251              
252             Internal method to fetch information about a stream on a channel
253              
254             =cut
255              
256 0     0 0   sub live_stream_f( $self, $channel ) {
  0            
  0            
  0            
257 0     0     my $res = $self->stream_playback_access_token_f( $channel )->then(sub( $id ) {
  0            
  0            
258 0 0         if( $id ) {
259 0           $id = $id->{channel_id};
260             return $self->fetch_gql_f(
261             [{"operationName" => "WithIsStreamLiveQuery","variables" => {"id" => "$id"},
262             "extensions" => {"persistedQuery" => {"version" => 1,"sha256Hash" => "04e46329a6786ff3a81c01c50bfa5d725902507a0deb83b0edbf7abe7a3716ea"}}},
263             ])->then(sub( $res ) {
264 0 0         if( $res ) {
265 0           return Future->done( $res->[0]->{data}->{user}->{stream});
266             } else {
267 0           return Future->done
268             }
269             })
270 0           } else {
271 0           return Future->done
272             }
273              
274             #})->on_ready(sub($s) {
275             # say "<$channel> Live info ready";
276 0           });
277             }
278              
279 0     0 1   sub live_stream( $self, $channel ) {
  0            
  0            
  0            
280 0           return $self->live_stream_f( $channel )->get();
281             }
282              
283             #curl 'https://gql.twitch.tv/gql#origin=twilight'
284             # -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:90.0) Gecko/20100101 Firefox/90.0'
285             # -H 'Accept: */*'
286             # -H 'Accept-Language: de-DE'
287             # --compressed
288             # -H 'Referer: https://www.twitch.tv/'
289             # -H 'Client-Id: kimne78kx3ncx6brgo4mv6wki5h1ko'
290             # -H 'X-Device-Id: WQS1BrvLDgmo6QcdpHY7M3d4eMRjf6ji'
291             # -H 'Client-Version: 2be2ebe0-0a30-4b77-b67e-de1ee11bcf9b'
292             # -H 'Content-Type: text/plain;charset=UTF-8'
293             # -H 'Origin: https://www.twitch.tv'
294             # -H 'DNT: 1'
295             # -H 'Connection: keep-alive'
296             # -H 'Sec-Fetch-Dest: empty'
297             # -H 'Sec-Fetch-Mode: cors'
298             # -H 'Sec-Fetch-Site: same-site'
299             # --data-raw '[{"operationName":"StreamSchedule","variables":{"login":"bootiemashup","startingWeekday":"MONDAY","utcOffsetMinutes":120,"startAt":"2021-07-25T22:00:00.000Z","endAt":"2021-08-01T21:59:59.059Z"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"e9af1b7aa4c4eaa1655a3792147c4dd21aacd561f608e0933c3c5684d9b607a6"}}}]'
300              
301             =head2 C<< ->stream_status( $channel ) >>
302              
303             my $status = $twitch->stream_status_f( 'somechannel', 'another_channel' );
304             for my $channel ($status->get) {
305             say $status->{channel}, $status->{status};
306             }
307              
308             Fetches the status of multiple channels
309              
310             =cut
311              
312 0     0 0   sub stream_status_f( $self, @channels ) {
  0            
  0            
  0            
313 0     0     fmap_scalar(sub($channel) {
  0            
  0            
314 0           return { channel => $channel, status => $self->is_live_f( $channel ) }
315             })
316 0           }
317              
318 0     0 1   sub stream_status( $self, @channels ) {
  0            
  0            
  0            
319 0           $self->stream_status_f(@channels)
320             ->get
321             }
322              
323             1;