diff --git a/debian/compat b/debian/compat index 7ed6ff82..f599e28b 100644 --- a/debian/compat +++ b/debian/compat @@ -1 +1 @@ -5 +10 diff --git a/gmusicbrowser_songs.pm b/gmusicbrowser_songs.pm index 3d84a476..b7a90779 100644 --- a/gmusicbrowser_songs.pm +++ b/gmusicbrowser_songs.pm @@ -1151,6 +1151,24 @@ our %timespan_menu= editsubmenu=>0, category=>'extra', }, +# https://github.com/carl-di-ortus/gmusicbrowser/issues/3 +# https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html +# musicbrainz_trackid => +# { name => _"MBID", +# id3v2 => 'TXXX;MusicBrainz Release Track Id;%v', vorbis => 'musicbrainz_releasetrackid', ape => 'MUSICBRAINZ_RELEASETRACKID', ilst => "----:com.apple.iTunes:MusicBrainz Release Track Id", 'id3v2.3' => 'TXXX;MusicBrainz Release Track Id;%v', +# flags => 'fgaescpi', +# type => 'string', +# category=>'extra', +# postread=> sub { my $v=shift; warn "V $v"; }, +# }, +# acoustid => +# { name => _"AcoustID", +# id3v2 => 'TXXX;Acoustid Id;%v', vorbis => 'ACOUSTID_ID', ape => 'ACOUSTID_ID', ilst => "----:com.apple.iTunes:Acoustid Id", +# flags => 'fgaescpi', +# type => 'string', +# category=>'extra', +# postread=> sub { my $v=shift }, +# }, style => { name => _"Styles", width => 180, flags => 'fgaescpil', type => 'flags', diff --git a/plugins/listenbrainz.pm b/plugins/listenbrainz.pm new file mode 100644 index 00000000..01c6dcf0 --- /dev/null +++ b/plugins/listenbrainz.pm @@ -0,0 +1,257 @@ +# Copyright (C) 2024 Carl di Ortus +# +# This file is part of Gmusicbrowser. +# Gmusicbrowser is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, as +# published by the Free Software Foundation + +=for gmbplugin LISTENBRAINZ +name listenbrainz +title listenbrainz.org plugin +desc Submit played songs to listenbrainz +=cut + + +package GMB::Plugin::LISTENBRAINZ; +use strict; +use warnings; +use JSON; +use List::Util qw(max); +use constant +{ CLIENTID => 'gmb', VERSION => '0.1', + OPT => 'PLUGIN_LISTENBRAINZ_', #used to identify the plugin's options + SAVEFILE => 'listenbrainz.queue', #file used to save unsent data +}; +require $::HTTP_module; + +our $ignore_current_song; + +my $self=bless {},__PACKAGE__; +my @ToSubmit; my @NowPlaying; my $NowPlayingID; +my $interval=10; my ($timeout,$waiting); +my ($Stop); +my $Log= Gtk3::ListStore->new('Glib::String'); + +sub Start +{ ::Watch($self,PlayingSong=> \&SongChanged); + ::Watch($self,Played => \&Played); + $self->{on}=1; + Sleep(); + SongChanged() if $::TogPlay; + $Stop=undef; +} +sub Stop +{ + @NowPlaying=undef; + $waiting->abort if $waiting; + Glib::Source->remove($timeout) if $timeout; + $timeout=$waiting=undef; + ::UnWatch($self,$_) for qw/PlayingSong Played/; + $self->{on}=undef; + $interval=10; +} + +sub prefbox +{ my $vbox= Gtk3::VBox->new(::FALSE, 2); + my $sg1= Gtk3::SizeGroup->new('horizontal'); + my $sg2= Gtk3::SizeGroup->new('horizontal'); + my $entry2=::NewPrefEntry(OPT.'TOKEN',_"Token :", cb => \&Stop, sizeg1 => $sg1,sizeg2=>$sg2, hide => 1); + my $label2= Gtk3::Button->new(_"(see https://listenbrainz.org)"); + $label2->set_relief('none'); + $label2->signal_connect(clicked => sub + { my $url='https://listenbrainz.org'; + my $user=$::Options{OPT.'USER'}; + $url.="/user/$user/" if defined $user && $user ne ''; + ::openurl($url); + }); + my $ignore= Gtk3::CheckButton->new(_"Don't submit current song"); + $ignore->signal_connect(toggled=>sub { return if $_[0]->{busy}; $ignore_current_song= $_[0]->get_active ? $::SongID : undef; ::HasChanged('Listenbrainz_ignore_current'); }); + ::Watch($ignore,Listenbrainz_ignore_current => sub { $_[0]->{busy}=1; $_[0]->set_active(defined $ignore_current_song); delete $_[0]->{busy}; } ); + my $queue= Gtk3::Label->new; + my $sendnow= Gtk3::Button->new(_"Send now"); + $sendnow->signal_connect(clicked=> \&SendNow); + my $qbox= ::Hpack($queue,$sendnow); + $vbox->pack_start($_,::FALSE,::FALSE,0) for $label2,$entry2,$ignore,$qbox; + $vbox->add( ::LogView($Log) ); + $qbox->{label}=$queue; + $qbox->{button}=$sendnow; + $qbox->show_all; + update_queue_label($qbox); + $qbox->set_no_show_all(1); + ::Watch($qbox,Listenbrainz_state_change=>\&update_queue_label); + return $vbox; +} +sub update_queue_label +{ my $qbox=shift; + my $label= $qbox->{label}; + if (@ToSubmit && (!$waiting && (!$timeout || $interval>10))) + { $label->set_text(::__n("song waiting to be sent")); + $label->get_parent->show; + $qbox->{button}->set_sensitive(!$waiting); + } + else { $label->get_parent->hide } +} + +sub SongChanged +{ + @NowPlaying=undef; + if (defined $ignore_current_song) + { return if defined $::SongID && $::SongID == $ignore_current_song; + $ignore_current_song=undef; ::HasChanged('Listenbrainz_ignore_current'); + } + my ($title,$artist,$album)= Songs::Get($::SongID,qw/title artist album/); + return if $title eq '' || $artist eq ''; + @NowPlaying= ( $artist, $title, $album ); + $NowPlayingID=$::SongID; + $interval=10; + SendNow(); +} + +sub Played +{ my (undef,$ID,undef,$start_time,$seconds,$coverage)=@_; + return if $ignore_current_song; + return unless $seconds>10; + my $length= Songs::Get($ID,'length'); + if ($length>=30 && ($seconds >= 240 || $coverage >= .5) ) + { my ($title,$artist,$album)= Songs::Get($ID,qw/title artist album/); + return if $title eq '' || $artist eq ''; + @ToSubmit= ( $artist, $title, $album ); + $interval=10; + Sleep(); + ::QHasChanged('Listenbrainz_state_change'); + } +} + +sub Submit +{ + my $i=0; + my $url= 'https://api.listenbrainz.org/1/submit-listens'; + my $listen_type; + my $listened_at; + my @payload; + if (@ToSubmit) + { @payload= @ToSubmit; + $listen_type= "single"; + $listened_at= time(); + } + elsif (@NowPlaying) + { if (!defined $::PlayingID || $::PlayingID!=$NowPlayingID) { @NowPlaying=undef; return } + @payload= @NowPlaying; + $listen_type= "playing_now"; + $listened_at= undef; + } + else { return; } + my $post= { + listen_type => $listen_type, + payload => [ + { + #listened_at => $listened_at, + track_metadata => { + artist_name => $payload[0], + track_name => $payload[1] + #release_name => $payload[2] + } + } + ] + }; + $post->{payload}[0]->{listened_at} = $listened_at if $listened_at; + $post->{payload}[0]->{track_metadata}->{release_name} = $payload[2] if $payload[2]; + my $response_cb=sub + { + my ($response,@lines)=@_; + my $error; + if (!defined $response) {$error=_"connection failed";} + elsif ($response eq '{"status":"ok"}') + { unlink $::HomeDir.SAVEFILE; + if (@ToSubmit) { + Log( _("Submit OK ") . + ::__x( _"{song} by {artist}", song=> $payload[1], artist => $payload[0]) ); + undef @ToSubmit; + undef $waiting; + $interval=10; + return + }; + if (@NowPlaying) { + Log( _("NowPlaying OK ") . + ::__x( _"{song} by {artist}", song=> $payload[1], artist => $payload[0]) ); + $interval=60; + undef $waiting; + return + }; + } + elsif ($response eq 'BADSESSION') + { $error=_"Bad session"; + } + elsif ($response=~m/^FAILED (.*)$/) + { $error=$1; + } + else {$error=_"unknown error";} + + if (defined $error) + { Log(_("Submit failed : ").$error); + Log(_("Response : ").$response) if $response; + $interval*=2; + $interval=max($interval, 300); + } + }; + + my $authtoken=$::Options{OPT.'TOKEN'}; + Save($post); + Send($response_cb,$url,$::HomeDir.SAVEFILE,$authtoken); +} + +sub SendNow +{ $interval=10; + $Stop=undef; + Awake(); +} + +sub Sleep +{ return unless $self->{on}; + ::QHasChanged('Listenbrainz_state_change'); + return if $Stop || $waiting || $timeout; + $timeout=Glib::Timeout->add(1000*$interval,\&Awake) if @ToSubmit || @NowPlaying; +} + +sub Awake +{ Glib::Source->remove($timeout) if $timeout; + $timeout=undef; + return 0 if !$self->{on} || $waiting; + Submit(); + Sleep(); + return 0; +} + +sub Send +{ my ($response_cb,$url,$post,$authtoken)=@_; + my $cb=sub + { my @response=(defined $_[0])? split "\012",$_[0] : (); + $waiting=undef; + &$response_cb(@response); + Sleep(); + }; + + $waiting=Simple_http::get_with_cb(cb => $cb,url => $url,post => $post,authtoken => $authtoken); + ::QHasChanged('Listenbrainz_state_change'); +} + +sub Log +{ my $text=$_[0]; + $Log->set( $Log->prepend,0, localtime().' '.$text ); + warn "$text\n" if $::debug; + if (my $iter=$Log->iter_nth_child(undef,50)) { $Log->remove($iter); } +} + +sub Save +{ my $savebody=$_[0]; + unless ($savebody) + { unlink $::HomeDir.SAVEFILE; return } + my $fh; + unless (open $fh,'>:utf8',$::HomeDir.SAVEFILE) + { warn "Error creating '$::HomeDir".SAVEFILE."' : $!\nUnsent listenbrainz.org data will be lost.\n"; return; } + my $json=(to_json $savebody); + print $fh $json; + close $fh; +} + +1; diff --git a/simple_http.pm b/simple_http.pm index a4e67cfb..a03e8b6a 100644 --- a/simple_http.pm +++ b/simple_http.pm @@ -28,7 +28,7 @@ sub get_with_cb my %params=@_; $self->{params}=\%params; delete $params{cache} unless $UseCache; - my ($callback,$url,$post)=@params{qw/cb url post/}; + my ($callback,$url,$post,$authtoken)=@params{qw/cb url post authtoken/}; if (my $cached= $params{cache} && GMB::Cache::get($url)) { warn "cached result\n" if $::debug; Glib::Timeout->add(10,sub { $callback->( ${$cached->{data}}, type=>$cached->{type}, filename=>$cached->{filename}, ); 0}); @@ -109,11 +109,16 @@ sub connecting_cb print $socket "Host: $host:$port".EOL; print $socket "User-Agent: $useragent".EOL; print $socket "Referer: $params->{referer}".EOL if $params->{referer}; + print $socket "Authorization: Token $params->{authtoken}".EOL if $params->{authtoken}; print $socket "Accept: $accept".EOL; print $socket "Accept-Encoding: gzip".EOL if $gzip_ok; #print $socket "Connection: Keep-Alive".EOL; if (defined $post) - { print $socket 'Content-Type: application/x-www-form-urlencoded; charset=utf-8'.EOL; + { if ($params->{authtoken}) { + print $socket 'Content-Type: application/json'.EOL; + } else { + print $socket 'Content-Type: application/x-www-form-urlencoded; charset=utf-8'.EOL; + } print $socket "Content-Length: ".length($post).EOL.EOL; print $socket $post.EOL; } diff --git a/simple_http_wget.pm b/simple_http_wget.pm index 3e19ebea..d71eb154 100644 --- a/simple_http_wget.pm +++ b/simple_http_wget.pm @@ -22,7 +22,7 @@ sub get_with_cb { my $self=bless {}; my %params=@_; $self->{params}=\%params; - my ($callback,$url,$post)=@params{qw/cb url post/}; + my ($callback,$url,$post,$authtoken)=@params{qw/cb url post authtoken/}; delete $params{cache} unless $UseCache; if (my $cached= $params{cache} && GMB::Cache::get($url)) { warn "cached result\n" if $::debug; @@ -36,13 +36,16 @@ sub get_with_cb : $orig_proxy; $ENV{http_proxy}=$proxy; - my $useragent= $params{user_agent} || 'Mozilla/5.0'; - my $accept= $params{'accept'} || ''; - my $gzip= $gzip_ok ? '--header=Accept-Encoding: gzip' : ''; - my @cmd_and_args= (qw/wget --timeout=40 -S -O -/, $gzip, "--header=Accept: $accept", "--user-agent=$useragent"); - push @cmd_and_args, "--referer=$params{referer}" if $params{referer}; - push @cmd_and_args, '--post-data='.$post if $post; #FIXME not sure if I should escape something - push @cmd_and_args, '--',$url; + my $cmd_and_args= 'wget --timeout=40 -S -O -'; + $cmd_and_args.= " -U ".($params{user_agent} || "'Mozilla/5.0'"); + $cmd_and_args.= " --header='Accept-Encoding: gzip'" if $gzip_ok; + $cmd_and_args.= " --header='Authorization: Token ".$authtoken."'" if $authtoken; + $cmd_and_args.= " --header='Content-Type: application/json'" if $authtoken; + $cmd_and_args.= " --referer=$params{referer}" if $params{referer}; + $cmd_and_args.= " --post-file='".$post."'" if $post; + $cmd_and_args.= " -- '$url'"; + #warn "$cmd_and_args\n"; + pipe my($content_fh),my$wfh; pipe my($error_fh),my$ewfh; my $pid=fork; @@ -52,7 +55,7 @@ sub get_with_cb open my($olderr), ">&", \*STDERR; open \*STDOUT,'>&='.fileno $wfh; open \*STDERR,'>&='.fileno $ewfh; - exec @cmd_and_args or print $olderr "launch failed (@cmd_and_args) : $!\n"; + exec $cmd_and_args or print $olderr "launch failed ($cmd_and_args) : $!\n"; POSIX::_exit(1); } close $wfh; close $ewfh;