Group
Extension

WWW-Twitch/lib/WWW/Twitch.pm

package WWW::Twitch;
use 5.020;
use Moo 2;
use experimental 'signatures';

use Carp 'croak';

use JSON 'encode_json', 'decode_json';
use POSIX 'strftime';
use Future::Utils 'repeat';
use Future::HTTP;

our $VERSION = '0.03';

=head1 NAME

WWW::Twitch - automate parts of Twitch without the need for an API key

=head1 SYNOPSIS

  use 5.012; # say
  use WWW::Twitch;

  my $channel = 'corion_de';
  my $twitch = WWW::Twitch->new();
  my $info = $twitch->live_stream($channel);
  if( $info ) {
      my $id = $info->{id};

      opendir my $dh, '.'
          or die "$!";

      # If we have stale recordings, maybe our network went down
      # in between
      my @recordings = grep { /\b$id\.mp4(\.part)?$/ && -M $_ < 30/24/60/60 }
                       readdir $dh;

      if( ! @recordings ) {
          say "$channel is live (Stream $id)";
          say "Launching youtube-dl";
          exec "youtube_dl", '-q', "https://www.twitch.tv/$channel";
      } else {
          say "$channel is recording (@recordings)";
      };

  } else {
      say "$channel is offline";
  }

=cut

=head1 METHODS

=head2 C<< ->new >>

  my $twitch = WWW::Twitch->new();

Creates a new Twitch client

=over 4

=item B<device_id>

Optional device id. If missing, a hardcoded
device id will be used.

=item B<client_id>

Optional client id. If missing, a hardcoded
client id will be used.

=item B<client_version>

Optional client version. If missing, a hardcoded
client version will be used.

=item B<ua>

Optional HTTP user agent. If missing, a L<HTTP::Tiny>
object will be constructed.

=back

=cut

has 'device_id' => (
    is => 'ro',
    default => 'WQS1BrvLDgmo6QcdpHY7M3d4eMRjf6ji'
);
has 'client_id' => (
    is => 'ro',
    default => 'kimne78kx3ncx6brgo4mv6wki5h1ko'
);
has 'client_version' => (
    is => 'ro',
    default => '2be2ebe0-0a30-4b77-b67e-de1ee11bcf9b',
);
has 'ua' =>
    is => 'lazy',
    default => sub {
    #Future::HTTP->new( verify_SSL => 1 ),
    Future::HTTP->new(),
};

sub fetch_gql_f( $self, $query ) {
    my $f = $self->ua->http_request( POST => 'https://gql.twitch.tv/gql',
        body => encode_json( $query ),
        headers => {
            # so far we need no headers
            "Client-ID" => $self->client_id,
        },
    )->then(sub( $body, $headers ) {
        my $res;
        if( $body ) {
            $res = decode_json( $body );
        } else {
            return Future->done()
        }
        return Future->done($res)
    });
    return $f
}

sub fetch_gql( $self, $query ) {
    $self->fetch_gql_f( $query )->get
}

=head2 C<< ->schedule( $channel ) >>

  my $schedule = $twitch->schedule( 'somechannel', %options );

Fetch the schedule of a channel

=cut

sub schedule( $self, $channel, %options ) {
    $options{ start_at } //= strftime '%Y-%m-%dT%H:%M:%SZ', gmtime(time);
    $options{ end_at }   //= strftime '%Y-%m-%dT%H:%M:%SZ', gmtime(time+24*7*3600);
    warn $options{ start_at };
    warn $options{ end_at };
    my $res =
        $self->fetch_gql( [{"operationName" => "StreamSchedule",
                            "variables" => { "login" => $channel,
                                             "startingWeekday" => "MONDAY",
                                             "utcOffsetMinutes" => 120,
                                             "startAt" => $options{ start_at },
                                             "endAt"  =>  $options{ end_at }
                                             },
                             "extensions" => {
                                 "persistedQuery" => {
                                    "version" => 1,
                                    "sha256Hash" => "d495cb17a67b6f7a8842e10297e57dcd553ea17fe691db435e39a618fe4699cf"
                                 }
                             }
                            }]
        );
    #use Data::Dumper;
    #warn Dumper $res;
    return $res->[0]->{data}->{user}->{channel}->{schedule};
};

=head2 C<< ->is_live( $channel ) >>

  if( $twitch->is_live( 'somechannel' ) ) {
      ...
  }

Check whether a stream is currently live on a channel

=cut

sub is_live_f( $self, $channel ) {
    my $f =
        $self->fetch_gql_f([{"operationName" => "WithIsStreamLiveQuery",
                            "extensions" => {
                                                "persistedQuery" => {
                                                    "version" => 1,
                                                    "sha256Hash" => "04e46329a6786ff3a81c01c50bfa5d725902507a0deb83b0edbf7abe7a3716ea"
                                                }
                                            }
                            },
                            #{"operationName" => "ChannelPollContext_GetViewablePoll",
                            #    "variables" => {"login" => "papaplatte"},
                            #    "extensions" => {"persistedQuery" => {"version" => 1,"sha256Hash" => "d37a38ac165e9a15c26cd631d70070ee4339d48ff4975053e622b918ce638e0f"}}}
        ]
        #"Client-Version": "9ea2055a-41f0-43b7-b295-70885b40c41c",
        )
    ->then(sub($res) {
        if( $res ) {
            return Future->done( $res->[0]->{data} );
        } else {
            return Future->done()
        }
    });
    return $f
}

sub is_live( $self, $channel ) {
    return $self->is_live_f($channel)->get
}

=head2 C<< ->stream_playback_access_token( $channel ) >>

  my $tok = $twitch->stream_playback_access_token( 'somechannel' );
  say $tok->{channel_id};

Internal method to fetch the stream playback access token

=cut

sub stream_playback_access_token_f( $self, $channel, %options ) {
    my $retries = $options{ retries } // 10;
    my $sleep   = $options{ sleep } // 1;
    my $error;
    my $res = repeat {
        my $r =
            $self->fetch_gql_f([{"operationName" => "PlaybackAccessToken_Template",
                "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  }}',
                "variables" => {"isLive" => $JSON::true,"login" => "$channel","isVod" => $JSON::false,"vodID" => "","playerType" => "site"}},
            ]);
        return $r
    } while => sub( $c ) {
        # Should we offer a retry callback?!
        !$c->get and $retries --> 0
    };
    return $res->then( sub( $res ) {

        if ( $res ) {
            if( my $v = $res->[0]->{data}->{streamPlaybackAccessToken}->{value} ) {
                return Future->done( decode_json( $v ))
            } elsif( $error = $res->{errors} ) {
                # ...
                return Future->fail( $error );
            } else {
                return Future->done
            }
        }
    })->catch(sub(@err) {
        use Data::Dumper;
        warn Dumper \@err;
    });
};

sub stream_playback_access_token( $self, $channel, %options ) {
    $self->stream_playback_access_token_f( $channel, %options )->get()
}

=head2 C<< ->live_stream( $channel ) >>

  my $tok = $twitch->live_stream( 'somechannel' );

Internal method to fetch information about a stream on a channel

=cut

sub live_stream_f( $self, $channel ) {
    my $res = $self->stream_playback_access_token_f( $channel )->then(sub( $id ) {
        if( $id ) {
            $id = $id->{channel_id};
            return $self->fetch_gql_f(
            [{"operationName" => "WithIsStreamLiveQuery","variables" => {"id" => "$id"},
                "extensions" => {"persistedQuery" => {"version" => 1,"sha256Hash" => "04e46329a6786ff3a81c01c50bfa5d725902507a0deb83b0edbf7abe7a3716ea"}}},
            ])->then(sub( $res ) {
                if( $res ) {
                    return Future->done( $res->[0]->{data}->{user}->{stream});
                } else {
                    return Future->done
                }
            })
        } else {
            return Future->done
        }

    #})->on_ready(sub($s) {
    #    say "<$channel> Live info ready";
    });
}

sub live_stream( $self, $channel ) {
    return $self->live_stream_f( $channel )->get();
}

#curl 'https://gql.twitch.tv/gql#origin=twilight'
#    -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:90.0) Gecko/20100101 Firefox/90.0'
#    -H 'Accept: */*'
#    -H 'Accept-Language: de-DE'
#    --compressed
#    -H 'Referer: https://www.twitch.tv/'
#    -H 'Client-Id: kimne78kx3ncx6brgo4mv6wki5h1ko'
#    -H 'X-Device-Id: WQS1BrvLDgmo6QcdpHY7M3d4eMRjf6ji'
#    -H 'Client-Version: 2be2ebe0-0a30-4b77-b67e-de1ee11bcf9b'
#    -H 'Content-Type: text/plain;charset=UTF-8'
#    -H 'Origin: https://www.twitch.tv'
#    -H 'DNT: 1'
#    -H 'Connection: keep-alive'
#    -H 'Sec-Fetch-Dest: empty'
#    -H 'Sec-Fetch-Mode: cors'
#    -H 'Sec-Fetch-Site: same-site'
#    --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"}}}]'

=head2 C<< ->stream_status( $channel ) >>

  my $status = $twitch->stream_status_f( 'somechannel', 'another_channel' );
  for my $channel ($status->get) {
      say $status->{channel}, $status->{status};
  }

Fetches the status of multiple channels

=cut

sub stream_status_f( $self, @channels ) {
    fmap_scalar(sub($channel) {
        return { channel => $channel, status => $self->is_live_f( $channel ) }
    })
}

sub stream_status( $self, @channels ) {
    $self->stream_status_f(@channels)
    ->get
}

1;


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.