terminal app to soundcloud personal stream (play, pause, forward, like, unlike, open in browser..). uses mpv
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env perl | |
use utf8; use strict; use warnings; | |
use Getopt::Std 'getopt'; | |
use HTTP::Tiny; use JSON::PP; | |
use IO::Uncompress::Gunzip 'gunzip'; | |
use Socket qw'AF_UNIX SOCK_STREAM PF_UNSPEC'; | |
use List::Util qw'min max'; | |
use Encode::Locale; | |
use POSIX 'setsid'; | |
die 'needs ssl support' unless HTTP::Tiny->can_ssl; | |
$0 = 'soundcloud-term'; | |
binmode $_, ':encoding(console_out)' for *STDOUT, *STDERR; | |
use constant debug => $ENV{debug}; | |
getopt(st => \ my %o); | |
die 'specify token' unless my $token = $o{t}; # look it up in 'oauth_token' cookie | |
my $api = 'https://api-v2.soundcloud.com/'; | |
my $ua = HTTP::Tiny->new( | |
default_headers => +{qw'Accept-Encoding gzip authorization', 'OAuth '.$token}, | |
timeout => 10, agent => ''); | |
$^F += 1; # this should be before any other sock ops, (mpv will listen unnamed sock) | |
die $! unless socketpair my $sc, my $sp, AF_UNIX, SOCK_STREAM, PF_UNSPEC; | |
$\ = "\n"; | |
my %me = map { %$_{qw'id username'} } get('me'); | |
my @stream = | |
map { | |
my $asset = $_->{my $type = $_->{type} =~ '^playlist' ? 'playlist' : 'track'}; | |
+{ | |
type => $type, | |
%$asset{qw'id permalink_url title'}, | |
(map {; aid => $_->{id}, aname => $_->{username} } $asset->{user}), | |
(map {; pid => $_->{id}, pname => $_->{username} } $_->{user}), | |
} } | |
do { | |
my ($limit, $offset, @r) = ($o{s}, 0); | |
while () { | |
my $r = get('stream'.($limit ? '?limit='.$limit : '').($offset ? '&offset='.$offset : '')); | |
last unless my @t = @{$r->{collection}}; | |
push @r, @t; | |
last if !$limit || @r >= $limit; | |
last unless $r->{next_href}; | |
last unless $offset = ($r->{next_href}=~/offset=(\d+)/a)[0] } | |
@r }; | |
my (@cmd, %cur) = (qw'list10 play'); | |
$SIG{INT} = sub { print "got int"; @cmd = 'exit' }; | |
$SIG{CHLD} = sub { | |
print "got chld" if debug; | |
1 while wait > 0; | |
push @cmd, 'next '.$cur{idx} if %cur; | |
undef %cur }; | |
$SIG{USR1} = sub { push @cmd, 'toggle' }; | |
$SIG{USR2} = sub { push @cmd, 'next' }; | |
my $last_cmd; | |
while () { | |
{ ; | |
$_ = shift @cmd and last if @cmd; | |
local $SIG{ALRM} = sub { die }; | |
undef $_; | |
eval { alarm 1; $_ = readline; alarm 0 }; | |
redo unless defined && length; | |
chomp; | |
$_ = $last_cmd unless length; | |
$last_cmd = $_ } | |
if (/^p(?:lay)?(?:\s*(\d+))?$/a) { | |
my $idx = $1 // 0; | |
push @cmd, 'stop', 'play '.$idx and next if %cur; | |
if (my $pid = fork) { @cur{qw'pid idx fh'} = ($pid, $idx, $sp) } # parent | |
elsif (defined $pid) { # child | |
close $sp; | |
printf qq(playin %d %s "%s"\n), $idx, @{$stream[$idx]}{qw'type title'}; | |
$SIG{INT} = uc'ignore'; | |
die $! unless defined(my $fd = fileno $sc); | |
exec mpv => qw'--no-terminal', '--input-ipc-client=fd://'.$fd, $stream[$idx]{permalink_url}; | |
die 'exec fail:'.$! } | |
else { die 'fork fail' } } | |
elsif (/^l(?:ike)?(?:\s*(\d+))?$/) { | |
my $i = $stream[$1 // $cur{idx}]; | |
printf "like: %s\n", lc put(sprintf 'users/%d/%s_likes/%d', $me{id}, @$i{qw'type id'}) } | |
elsif (/^u(?:nlike)?(?:\s*(\d+))?$/) { | |
my $i = $stream[$1 // $cur{idx}]; | |
printf "unlike: %s\n", lc del(sprintf 'users/%d/%s_likes/%d', $me{id}, @$i{qw'type id'}) } | |
elsif (/^s(?:top)?$/) { | |
next unless %cur; | |
local $SIG{CHLD} = uc'default'; | |
kill uc'term', $cur{pid}; | |
1 while wait > 0; | |
undef %cur } | |
elsif (/^t(?:oggle)?$/) { | |
next unless %cur; | |
local $SIG{PIPE} = sub { print "broken pipe: $!" }; | |
syswrite $cur{fh}, "cycle pause\n" } | |
elsif (/^i(?:nfo)?(x)?$/) { | |
next unless %cur; | |
printf "track: %s\nurl: %s\n", @{$stream[$cur{idx}]}{qw'title permalink_url'}; | |
{ ; # url to clipbaord | |
local $SIG{CHLD} = uc'default'; | |
open my $fh, '|-', qw'xclip -i -selection clipboard'; | |
printf $fh "%s\n", $stream[$cur{idx}]{permalink_url} } | |
{ ; # open link in browser | |
last unless $1; | |
local $SIG{CHLD} = uc'default'; | |
last if fork; | |
$SIG{INT} = uc'default'; | |
setsid or die $!; | |
exit if fork; | |
open STDOUT, '>', '/dev/null'; open STDERR, '>&', STDOUT; close STDIN; | |
exec 'xdg-open', $stream[$cur{idx}]{permalink_url} or die $! } | |
# report position and length | |
local $SIG{PIPE} = sub { print "broken pipe: $!" }; | |
for my $prop (qw'time-pos duration') { | |
warn 'write fail' unless syswrite $cur{fh}, encode_json(+{ | |
command => ['get_property', $prop], request_id => my $req_id = int rand 1e3})."\n"; | |
local $/ = "\n"; | |
while (my $line = readline $cur{fh}) { | |
chomp $line; | |
warn 'fail to decode' unless my $reply = eval { decode_json $line }; | |
printf "%s: %s\n", $prop, $reply->{data} // $reply->{error} and last if | |
exists($reply->{request_id}) && $reply->{request_id} == $req_id } } } | |
elsif (/^j(?:ump)?(?:\s*([+-])?(\d+))$/) { # jump to position absolutely/relatevely | |
my ($how, $n) = ($1, $2); | |
local $SIG{PIPE} = sub { print "broken pipe: $!" }; | |
warn 'write fail' unless syswrite $cur{fh}, | |
sprintf "seek %s%s %s\n", $how//'', $n, $how ? 'relative' : 'absolute' } | |
elsif (/^n(?:ext)?(?:\s*(\d+))?$/) { | |
my $nxt = (($1 // (%cur ? $cur{idx} : -1)) + 1) % @stream; | |
push @cmd, %cur ? 'stop' : (), 'play '.$nxt } | |
elsif (/^li?st?\s*(\d+)?$/) { | |
for my $idx (0..min($1 ? $1 - 1 : $#stream, $#stream)) { | |
my $t = $stream[$idx]; | |
printf qq(%2d%s%s by %s via %s\n), $idx, | |
$t->{type} =~ /^playlist/ ? '⯒' : ' ', | |
@$t{qw'title aname pname'} } } | |
elsif (/^(?:q(?:uit)?|ex(?:it)?)$/) { | |
exit 0 unless %cur; | |
push @cmd, qw'stop exit' } | |
elsif (/^h(?:elp)?$/) { | |
print <<~\txt; | |
available commands: | |
lN listN list N items, N is optional, all by default | |
pN playN play N item, N is optional, first by default | |
i info show item info, copies current item url to clipboard | |
ix infox ... and opens browser | |
t toggle toggle playback (also SIGUSR1) | |
s stop stop playback | |
lN like like current or N track | |
uN unlike unlike current or N track | |
nN nextN forward 1 or N track (also SIGUSR2) | |
jN jumpN jump to N sec absolutely | |
j+N jump+N jump to +N sec relatevely | |
j-N jump-N jump to -N sec relatevely | |
q quit | |
ex exit | |
*empty command repeats last command | |
txt | |
} | |
else { printf "unknown command %s, try 'help'\n", $_ } } | |
sub get { unshift @_, 'get'; goto &req } | |
sub put { unshift @_, 'put'; goto &req } | |
sub del { unshift @_, 'delete'; goto &req } | |
sub req { | |
my ($method, $what) = @_; | |
my $r = $ua->$method($api.$what); | |
printf ">> %s: %s\n", $method, $api.$what if debug; | |
$r->{success} | |
? $r->{headers}{'content-length'} | |
? decode_json( | |
($r->{headers}{'content-encoding'}//'') eq 'gzip' | |
? do { gunzip(\$r->{content}, \ my $buf); $buf } | |
: $r->{content}) | |
: 'code:'.$r->{status} | |
: die sprintf "couldn't %s: %s\n", $method, $r->{reason} } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment