File Coverage

blib/lib/WWW/Twitch.pm
Criterion Covered Total %
statement 21 72 29.1
branch 0 14 0.0
condition 0 6 0.0
subroutine 7 12 58.3
pod 4 5 80.0
total 32 109 29.3


line stmt bran cond sub pod time code
1             package WWW::Twitch;
2 1     1   977 use Moo 2;
  1         9227  
  1         5  
3 1     1   1206 use feature 'signatures';
  1         3  
  1         70  
4 1     1   4 no warnings 'experimental::signatures';
  1         2  
  1         24  
5              
6 1     1   4 use Carp 'croak';
  1         1  
  1         34  
7              
8 1     1   584 use HTTP::Tiny;
  1         39428  
  1         37  
9 1     1   555 use JSON 'encode_json', 'decode_json';
  1         6744  
  1         4  
10 1     1   117 use POSIX 'strftime';
  1         1  
  1         6  
11              
12             our $VERSION = '0.02';
13              
14             =head1 NAME
15              
16             WWW::Twitch - automate parts of Twitch without the need for an API key
17              
18             =head1 SYNOPSIS
19              
20             use 5.012; # say
21             use WWW::Twitch;
22              
23             my $channel = 'corion_de';
24             my $twitch = WWW::Twitch->new();
25             my $info = $twitch->live_stream($channel);
26             if( $info ) {
27             my $id = $info->{id};
28              
29             opendir my $dh, '.'
30             or die "$!";
31              
32             # If we have stale recordings, maybe our network went down
33             # in between
34             my @recordings = grep { /\b$id\.mp4(\.part)?$/ && -M $_ < 30/24/60/60 }
35             readdir $dh;
36              
37             if( ! @recordings ) {
38             say "$channel is live (Stream $id)";
39             say "Launching youtube-dl";
40             exec "youtube_dl", '-q', "https://www.twitch.tv/$channel";
41             } else {
42             say "$channel is recording (@recordings)";
43             };
44              
45             } else {
46             say "$channel is offline";
47             }
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             HTTP::Tiny->new( verify_SSL => 1 ),
102             };
103              
104 0     0 0   sub fetch_gql( $self, $query ) {
  0            
  0            
  0            
105 0           my $res = $self->ua->post( 'https://gql.twitch.tv/gql', {
106             content => encode_json( $query ),
107             headers => {
108             # so far we need no headers
109             "Client-ID" => $self->client_id,
110             },
111             });
112 0 0         if( $res->{content}) {
113 0           $res = decode_json( $res->{content} );
114             } else {
115             return
116 0           }
117             }
118              
119             =head2 C<< ->schedule( $channel ) >>
120              
121             my $schedule = $twitch->schedule( 'somechannel', %options );
122              
123             Fetch the schedule of a channel
124              
125             =cut
126              
127 0     0 1   sub schedule( $self, $channel, %options ) {
  0            
  0            
  0            
  0            
128 0   0       $options{ start_at } //= strftime '%Y-%m-%dT%H:%M:%SZ', gmtime(time);
129 0   0       $options{ end_at } //= strftime '%Y-%m-%dT%H:%M:%SZ', gmtime(time+24*7*3600);
130 0           warn $options{ start_at };
131 0           warn $options{ end_at };
132             my $res =
133             $self->fetch_gql( [{"operationName" => "StreamSchedule",
134             "variables" => { "login" => $channel,
135             "startingWeekday" => "MONDAY",
136             "utcOffsetMinutes" => 120,
137             "startAt" => $options{ start_at },
138             "endAt" => $options{ end_at }
139             },
140 0           "extensions" => {
141             "persistedQuery" => {
142             "version" => 1,
143             "sha256Hash" => "d495cb17a67b6f7a8842e10297e57dcd553ea17fe691db435e39a618fe4699cf"
144             }
145             }
146             }]
147             );
148             #use Data::Dumper;
149             #warn Dumper $res;
150 0           return $res->[0]->{data}->{user}->{channel}->{schedule};
151             };
152              
153             =head2 C<< ->is_live( $channel ) >>
154              
155             if( $twitch->is_live( 'somechannel' ) ) {
156             ...
157             }
158              
159             Check whether a stream is currently live on a channel
160              
161             =cut
162              
163 0     0 1   sub is_live( $self, $channel ) {
  0            
  0            
  0            
164 0           my $res =
165             $self->fetch_gql([{"operationName" => "WithIsStreamLiveQuery",
166             "extensions" => {
167             "persistedQuery" => {
168             "version" => 1,
169             "sha256Hash" => "04e46329a6786ff3a81c01c50bfa5d725902507a0deb83b0edbf7abe7a3716ea"
170             }
171             }
172             },
173             #{"operationName" => "ChannelPollContext_GetViewablePoll",
174             # "variables" => {"login" => "papaplatte"},
175             # "extensions" => {"persistedQuery" => {"version" => 1,"sha256Hash" => "d37a38ac165e9a15c26cd631d70070ee4339d48ff4975053e622b918ce638e0f"}}}
176             ]
177             #"Client-Version": "9ea2055a-41f0-43b7-b295-70885b40c41c",
178             );
179 0 0         if( $res ) {
180 0           return $res->[0]->{data};
181             } else {
182             return
183 0           }
184             }
185              
186             =head2 C<< ->stream_playback_access_token( $channel ) >>
187              
188             my $tok = $twitch->stream_playback_access_token( 'somechannel' );
189             say $tok->{channel_id};
190              
191             Internal method to fetch the stream playback access token
192              
193             =cut
194              
195 0     0 1   sub stream_playback_access_token( $self, $channel ) {
  0            
  0            
  0            
196 0           my $retries = 10;
197 0           my $error;
198 0           while( $retries -->0 ) {
199 0           my $res =
200             $self->fetch_gql([{"operationName" => "PlaybackAccessToken_Template",
201             "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 }}',
202             "variables" => {"isLive" => $JSON::true,"login" => "$channel","isVod" => $JSON::false,"vodID" => "","playerType" => "site"}},
203             ]);
204 0 0         if ( $res ) {
205 0 0         if( my $v = $res->[0]->{data}->{streamPlaybackAccessToken}->{value} ) {
    0          
206 0           return decode_json( $v )
207             } elsif( $error = $res->{errors} ) {
208             # ...
209             }
210             }
211             }
212 0           croak $error
213             };
214              
215             =head2 C<< ->live_stream( $channel ) >>
216              
217             my $tok = $twitch->live_stream( 'somechannel' );
218              
219             Internal method to fetch information about a stream on a channel
220              
221             =cut
222              
223 0     0 1   sub live_stream( $self, $channel ) {
  0            
  0            
  0            
224 0           my $id = $self->stream_playback_access_token( $channel );
225 0           my $res;
226 0 0         if( $id ) {
227 0           $id = $id->{channel_id};
228 0           $res =
229             $self->fetch_gql(
230             [{"operationName" => "WithIsStreamLiveQuery","variables" => {"id" => "$id"},
231             "extensions" => {"persistedQuery" => {"version" => 1,"sha256Hash" => "04e46329a6786ff3a81c01c50bfa5d725902507a0deb83b0edbf7abe7a3716ea"}}},
232             ]);
233             };
234              
235 0 0         if( $res ) {
236 0           return $res->[0]->{data}->{user}->{stream};
237             } else {
238             return
239 0           }
240             }
241              
242             #curl 'https://gql.twitch.tv/gql#origin=twilight'
243             # -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:90.0) Gecko/20100101 Firefox/90.0'
244             # -H 'Accept: */*'
245             # -H 'Accept-Language: de-DE'
246             # --compressed
247             # -H 'Referer: https://www.twitch.tv/'
248             # -H 'Client-Id: kimne78kx3ncx6brgo4mv6wki5h1ko'
249             # -H 'X-Device-Id: WQS1BrvLDgmo6QcdpHY7M3d4eMRjf6ji'
250             # -H 'Client-Version: 2be2ebe0-0a30-4b77-b67e-de1ee11bcf9b'
251             # -H 'Content-Type: text/plain;charset=UTF-8'
252             # -H 'Origin: https://www.twitch.tv'
253             # -H 'DNT: 1'
254             # -H 'Connection: keep-alive'
255             # -H 'Sec-Fetch-Dest: empty'
256             # -H 'Sec-Fetch-Mode: cors'
257             # -H 'Sec-Fetch-Site: same-site'
258             # --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"}}}]'
259              
260             1;