diff options
-rwxr-xr-x | bm | 231 | ||||
-rwxr-xr-x | download_yt_playlist | 28 | ||||
-rwxr-xr-x | dups | 56 | ||||
-rwxr-xr-x | mdk | 69 | ||||
-rwxr-xr-x | multiplayerctl | 57 | ||||
-rwxr-xr-x | playingnow | 32 | ||||
-rwxr-xr-x | yt | 144 |
7 files changed, 617 insertions, 0 deletions
@@ -0,0 +1,231 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +use File::Basename qw<basename dirname>; +use File::Find qw<find>; +use File::Glob qw<:bsd_glob :nocase>; +use File::Path qw<make_path>; +use File::Which qw<which>; +use IPC::Open2 qw<open2>; +use List::Util qw<all>; + +use JSON qw<decode_json>; + +my $BMDIR = "$ENV{HOME}/.bm"; +mkdir $BMDIR || die $!; + +my $YT = which('yt'); +my $GIT = "git -C '$BMDIR'" if which('git') && -d "$BMDIR/.git"; + +sub is_uri { + my $text = shift; + return $text =~ /^http/; +} + +sub all_contents { + my @files; + my $pre_len = length $BMDIR; + find({wanted => sub { + if (substr($_, 0, 1) eq '.' && $File::Find::name ne $BMDIR) { + # Ignore hidden files/dirs (except $BMDIR) + $File::Find::prune = 1; + } elsif (-f) { + push @files, $File::Find::name; + } + }}, + $BMDIR); + @files = sort { $a cmp $b } @files; + return ([], \@files); +} + +sub dir_contents { + my $dir = shift; + + my @dirs = $dir eq $BMDIR ? () : ('..'); + my @files; + + foreach (glob "$dir/*") { + if (-d) { + push @dirs, $_; + } elsif (-f) { + push @files, $_; + } + } + + return (\@dirs, \@files); +} + +sub read_url { + my $file = shift; + open(my $fh, '<', $file) || die $!; + my $url = <$fh>; + return unless $url && is_uri($url); + $url =~ s/\s*$//g; + return $url; +} + +sub open_bm { + my $url = shift; + if ($YT && $url =~ /youtube\.com\/watch/) { + `yt $url`; + return; + } + my $focused_name = `xdotool getwindowfocus getwindowname`; + if ($focused_name =~ / (Firefox|Chrom(e|ium))$/) { + `xdotool - <<<"key ctrl+t\ntype --delay 1ms $url\nsleep 0.2\nkey Return Tab"`; + } else { + `xdg-open $url`; + } +} + +sub url_domain { + my $url = shift; + my ($domain) = $url =~ /^[a-z]+:\/\/([^\/]+)/; + return $domain; +} + +sub notify { + my ($urgency, $text) = @_; + `notify-send -u '$urgency' '$text'`; +} + +sub save_bm { + my ($basedir, $text) = @_; + my ($uri, $title); + if (is_uri($text)) { + $uri = $text; + $title = `rofi -dmenu -p "Set title" -no-fixed-num-lines` or return; + $title =~ s/\n//g; + } else { + $uri = `rofi -dmenu -p "Set URL" -no-fixed-num-lines` or return; + $uri =~ s/\n//g; + if (is_uri($uri)) { + $title = $text; + } else { + notify('critical', 'Not an URL; skipping'); + return; + } + } + my $path = "$basedir/$title"; + make_path(dirname($path)); + open(my $fh, '>',$path); + print $fh "$uri\n"; + if ($GIT) { + `$GIT add '$path'`; + my $basename = basename($path); + `$GIT commit -m "Add bookmark '$basename'"`; + } + notify('normal', "Bookmark saved"); +} + +sub sanitize_rofi { + return shift =~ s/&/&/gr; +} + +sub draw_dir_entry { + my $dir = shift; + return "<b>" . sanitize_rofi(basename($dir)) . "</b>"; +} + +sub draw_file_entry { + my $file = shift; + my $url = read_url($file); + unless ($url) { + print "$file does not seem to contain a valid URL; skipping\n"; + return; + } + return sanitize_rofi(basename($file)) . " <u><tt>" . url_domain($url) . "</tt></u>"; +} + +sub select_bm { + my $basedir = $BMDIR; + my $recursive = 0; + while (-d $basedir) { + my ($dirs, $files) = $recursive ? all_contents() : dir_contents($basedir); + + my $msg = $recursive ? "All bookmarks" : "Open bookmark"; + open2(my $outh, my $inh, "rofi -sync -p '$msg' -dmenu -multi-select -i -markup-rows -lines 40 -width 80 -format 'i,f'"); + print $inh "$_\n" foreach (map { draw_dir_entry($_) } @$dirs); + print $inh "$_\n" foreach (map { draw_file_entry($_) } @$files); + close $inh; + + # Get all output lines from rofi + my @out = map { [/^(-?\d+),(.*)$/] } <$outh> or die 'cancelled'; + + # If there are sereval selections, all of them must be files + if (all { $_->[0] > $#$dirs } @out) { + foreach my $out (@out) { + my ($idx, $input) = @$out; + my $url = read_url($files->[$idx - @$dirs]); + open_bm($url); + } + return; + } elsif (@out == 1) { + my ($idx, $input) = @{$out[0]}; + if ($idx == -1) { + # User entered something which was not among the options + if ($input) { + # If there was something in the search bar, save it + save_bm($basedir, $input); + return; + } else { + # Otherwise, take it as the recursive toggle (C-Ret) + $recursive = ! $recursive; + $basedir = $BMDIR; + } + } elsif ($idx <= $#$dirs) { + # User selected a directory + my $dir = $dirs->[$idx]; + if ($dir eq '..') { + $basedir = dirname($basedir); + } else { + $basedir .= '/' . basename($dir); + } + } + } + } +} + +sub sanitize_path { + my $path = shift; + $path =~ s/\//-/g; + $path = substr($path, 0, 100); + #$path =~ s/\(/-/g; + return $path; +} + +sub import_firefox { + my ($basedir, $json) = @_; + if ($json->{type} eq "text/x-moz-place-container") { + my $title = sanitize_path($json->{title}); + my $newdir = "$basedir/$title"; + mkdir $newdir; + import_firefox($newdir, $_) foreach (@{$json->{children}}); + } elsif ($json->{type} eq "text/x-moz-place") { + my $uri = $json->{uri}; + my $title = sanitize_path($json->{title}); + my $newfile = "$basedir/$title"; + open(my $fh, '>', $newfile) || print "Unable to open $newfile\n"; + print $fh $uri; + } +} + +if (@ARGV == 0) { + select_bm(); +} elsif ($ARGV[0] eq "-h") { + die "Usage: $0 [--import-firefox <bookmarks.json>] [git <git command ...>]\n"; +} elsif ($ARGV[0] eq "git") { + if ($GIT) { + shift; + exec 'git', '-C', $BMDIR, @ARGV; + } else { + die which('git') ? "$BMDIR is not a git repository\n" : "git is not installed\n"; + } +} elsif ($ARGV[0] eq "--import-firefox") { + open(my $fh, '<', $ARGV[1]); + my $json = decode_json(<$fh>); + close($fh); + import_firefox($BMDIR, $json); +} diff --git a/download_yt_playlist b/download_yt_playlist new file mode 100755 index 0000000..32b50eb --- /dev/null +++ b/download_yt_playlist @@ -0,0 +1,28 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +use JSON; + +my $ENDPOINT = "https://www.googleapis.com/youtube/v3"; +my $YT_API_KEY = $ENV{'YOUTUBE_API_KEY'} + || die "Error: You need to define the env variable 'YOUTUBE_API_KEY'\n"; + +sub decode_json_utf8 { + my $json = join "", @_; + utf8::encode($json); + return decode_json($json); +} + +sub api_playlist_videos { + my $id = shift; + my $videos = (decode_json_utf8 `curl -s '$ENDPOINT/playlistItems?part=snippet&maxResults=1000&playlistId=$id&key=$YT_API_KEY'`)->{items}; + return map { $_->{snippet}{resourceId}{videoId} } @$videos; +} + +@ARGV == 1 or die "Usage: $0 <playlist_id>\n"; +my $pl_id = $ARGV[0]; +my @videos = map { "https://www.youtube.com/watch?v=" . $_ } api_playlist_videos($pl_id); + +`mkdir $pl_id && cd $pl_id && youtube-dl @videos\n`; @@ -0,0 +1,56 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +use File::Find qw<find>; +use IPC::System::Simple qw<capture>; + +@ARGV ge 1 || die "Usage: $0 <dir1> [<dir2> ...]\n"; + +my %sizes; +my %md5s; + +sub fill_sizes { + return if -d "$_"; # Skip directories + + my $size = capture("stat", "--printf=%s", $_); + $sizes{$size} ||= []; + push @{$sizes{$size}}, $File::Find::name; +} + +print STDERR "Collecting file sizes...\n"; +find(\&fill_sizes, $_) foreach (@ARGV); + +print STDERR "Computing md5s of files with same size...\n"; +my @progress = (0, 0, scalar(keys(%sizes))); +foreach my $size (keys(%sizes)) { + $progress[0]++; + $progress[1] = 0; + my @same_size_files = @{$sizes{$size}}; + next unless @same_size_files gt 1; # Discard unique sizes + + + foreach my $file (@same_size_files) { + $progress[1]++; + print STDERR "$progress[0].$progress[1] / $progress[2]\r"; + STDERR->flush(); + + my ($md5) = capture("md5sum", "-z", $file) =~ m/^([^ ]+)/; + $md5s{$md5} ||= []; + push @{$md5s{$md5}}, $file; + } +} + +foreach my $md5 (keys(%md5s)) { + my @same_md5_files = @{$md5s{$md5}}; + next unless @same_md5_files gt 1; # Discard unique hashes + + print "Found duplicate files:\n"; + foreach my $file (@same_md5_files) { + print "\t$file\n"; + } + printf "\n"; +} + +printf STDERR "Done!\n"; @@ -0,0 +1,69 @@ +#!/bin/bash +# +# MDK (Move / Delete / Keep) +################################################################################ + +if [ "$#" -ge 2 ]; then + cmd=$1 + shift +else + echo "Usage: $0 <command> <file1> [<file2> ...]"; + exit 0; +fi + + +move() { + file="$1" + + read -e -p "Where? " movedir + mkdir -p "$movedir" + + mv "$file" "$movedir/" || exit 1 + + echo "[+] Moved to '$movedir'" +} + +delete() { + file="$1" + + mkdir -p .trash + echo ".trash generated, delete it manually if you want" + mv "$file" .trash/ || exit 1 + + echo "[+] Deleted" +} + +keep() { + echo "[+] Kept" +} + +for file in "$@"; do + key="" + while [ -z "$key" ]; do + echo -e "==============================\n[+] Opening '$file'..." + $cmd "$file" + echo -e "\n==============================" + prompt="[M]ove / [D]elete / [K]eep / [A]gain (${file}) > " + read -n1 -p "$prompt" key + while [[ ! "$key" =~ m|d|k|a ]]; do + echo -e "\nWhat?" + read -n1 -p "$prompt" key + done + echo + case "$key" in + m) + move "$file" + ;; + d) + delete "$file" + ;; + k) + keep + ;; + a) + key="" + ;; + esac + done +done + diff --git a/multiplayerctl b/multiplayerctl new file mode 100755 index 0000000..011c7bc --- /dev/null +++ b/multiplayerctl @@ -0,0 +1,57 @@ +#!/usr/bin/env perl + +my $TMPFILE = '/tmp/multiplayerctl'; + +sub players { + my $players = {}; + for my $playerline (`bash -c "paste -d '|' <(playerctl -l) <(playerctl -a status)"`) { + chomp $playerline; + my ($player, $status) = split /\|/, $playerline; + $players->{$status} = [] unless $players->{$status}; + push @{$players->{$status}}, $player; + } + return $players; +} + +sub wrap { + my $player = shift; + + print " player=$player\n"; + + open(my $fd, '>', $TMPFILE) or die $?; + print $fd $player; + + my @args; + push @playerargs, ('-p', $player) if ($player); + exec 'playerctl', @playerargs, @ARGV; +} + +my $players = players(); +my @playing = @{$players->{'Playing'}}; +my @paused = @{$players->{'Paused'}}; + +print "Playing=(@playing) Paused=(@paused)"; + +my $last; +if (open(my $fd, '<', $TMPFILE)) { + $last = <$fd>; +} +print " last=$last"; + +if (grep /^$last$/, @playing) { + # If the last known player is playing, stop it first + wrap($last); +} elsif (@playing) { + # Take the first of the playing players + wrap($playing[0]); +} else { + # Nothing is currently playing + if (grep /^$last$/, @paused) { + # The last known player is paused; take it + print " matched=$last"; + wrap($last); + } elsif (@paused) { + # Take the first of the paused players + wrap($paused[0]); + } +} diff --git a/playingnow b/playingnow new file mode 100755 index 0000000..ea6c07e --- /dev/null +++ b/playingnow @@ -0,0 +1,32 @@ +#!/usr/bin/env perl + +sub show_player_meta { + my $player = shift; + + my ($artist, $album, $title, $url) = split /\|/, + `playerctl -p $player metadata --format '{{xesam:artist}}|{{xesam:album}}|{{xesam:title}}|{{xesam:url}}'`; + + $url = "" if $url =~ /^file:|spotify/; + + my $song = $album ? "$title - <i>$album</i>" : $title; + + my @lines; + push @lines, $artist if $artist; + push @lines, $song if $song; + push @lines, $url if $url; + + my @args = qw"-t 4000 -u low"; + push @args, shift @lines; + push @args, join("\n", @lines); + + system "notify-send", @args; +} + +sub players { + map { chomp $_; [split /\|/, $_] } `bash -c "paste -d '|' <(playerctl -l) <(playerctl -a status)"` +} + +for my $playerline (players()) { + my ($player, $status) = @$playerline; + show_player_meta($player) if ($status eq "Playing"); +} @@ -0,0 +1,144 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +use IPC::Open2 qw<open2>; +use JSON; + +my $ENDPOINT = "https://www.googleapis.com/youtube/v3"; +my $YT_API_KEY = $ENV{'YOUTUBE_API_KEY'} + || die "Error: You need to define the env variable 'YOUTUBE_API_KEY'\n"; + +my $MAX_RESULTS = 30; + +# Currently unused +my $graphical; + +sub decode_json_utf8 { + my $json = join "", @_; + utf8::encode($json); + return decode_json($json); +} + +sub api_search { + my $query = shift; + # TODO sanitize query properly... + $query =~ s/'/'\\''/g; + return (decode_json_utf8 `curl -s '$ENDPOINT/search?part=snippet&q=$query&maxResults=$MAX_RESULTS&key=$YT_API_KEY'`)->{items}; +} + +sub api_videos { + my $ids = join(',', @_); + return (decode_json_utf8 `curl -s '$ENDPOINT/videos?part=snippet,statistics,contentDetails&id=$ids&key=$YT_API_KEY'`)->{items}; +} + +sub api_channel_videos { + use Data::Dumper; + my $id = shift; + my $uploads = (decode_json_utf8 `curl -s '$ENDPOINT/channels?part=contentDetails&id=$id&key=$YT_API_KEY'`)->{items}[0]{contentDetails}{relatedPlaylists}{uploads}; + my $videos = (decode_json_utf8 `curl -s '$ENDPOINT/playlistItems?part=snippet&maxResults=$MAX_RESULTS&playlistId=$uploads&key=$YT_API_KEY'`)->{items}; + return map { $_->{snippet}{resourceId}{videoId} } @$videos; +} + +sub play { + my $url = shift; + `mpv --really-quiet $url`; +} + +sub ask_query { + my $query = `rofi -dmenu -no-fixed-num-lines -p "Youtube search" | sed 's: :+:g'`; + $query =~ s/\s*$//g; + $graphical = 1; + return $query; +} + +sub sanitize_rofi { + return shift =~ s/&/&/gr; +} + +sub select_entry { + my ($channels, $video_ids) = @_; + + my @entries; + + open2(my $outh, my $inh, 'rofi -i -markup-rows -dmenu -format i -p "Select video"'); + foreach my $chan (@$channels) { + my $id = $chan->{channelId}; + push @entries, $id; + + print $inh sanitize_rofi("<b>[$chan->{title}] $chan->{description}</b>\n"); + } + + foreach my $video (@{api_videos(@$video_ids)}) { + my $id = $video->{id}; + push @entries, $id; + + my $publishedAt = $video->{snippet}{publishedAt}; + my ($y, $mo, $d, $h, $mi) = $publishedAt =~ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):\d{2}Z$/; + my (undef, $cmi, $ch, $cd, $cmo, $cy) = gmtime(); + $cy += 1900; + $cmo++; + my $published; + if ($y < $cy-1) { + $published = $cy - $y . " years ago"; + } elsif ($mo != $cmo || $y == $cy-1) { + $published = (12 * ($cy-$y) + $cmo) - $mo . " months ago"; + } elsif ($cd != $d) { + $published = $cd - $d . " days ago"; + } elsif ($ch != $h) { + $published = $ch - $h . " hours ago"; + } elsif ($cmi != $mi) { + $published = $cmi - $mi . " minutes ago"; + } + + my $stats = $video->{statistics}; + my $likes = $stats->{likeCount}; + my $dislikes = $stats->{dislikeCount}; + my $rating = ""; + if ($likes && $dislikes) { + my $total = $likes + $dislikes; + $rating = "*" x (6 * $likes / $total); + } + + print $inh sanitize_rofi("($published) [$video->{snippet}{channelTitle}] $video->{snippet}{title} $rating\n"); + } + close $inh; + + my $idx = <$outh> || die "cancelled\n"; + if ($idx < @$channels) { + return ('channel', $entries[$idx]); + } else { + return ('video', $entries[$idx]); + } +} + + +## MAIN + +my $query = join("+", @ARGV) || ask_query(); +die "empty query\n" unless $query; + +if ($query =~ /^http/) { + play($query); + exit 0; +} + +my @channels; +my @video_ids; +foreach my $entry (@{api_search($query)}) { + if ($entry->{id}{kind} eq 'youtube#channel') { + push @channels, $entry->{snippet}; + } elsif ($entry->{id}{kind} eq 'youtube#video') { + push @video_ids, $entry->{id}{videoId}; + } +} + +my ($type, $entry) = select_entry(\@channels, \@video_ids); +if ($type eq 'channel') { + my @videos = api_channel_videos($entry); + my (undef, $entry) = select_entry([], \@videos); + play("https://youtu.be/$entry"); +} elsif ($type eq 'video') { + play("https://youtu.be/$entry"); +} |