view release on metacpan or search on metacpan
lib/App/DrivePlayer/Config.pm view on Meta::CPAN
my $data = $self->_defaults();
if (-f $self->config_file) {
my $file = LoadFile($self->config_file);
# Migrate legacy root-level auth key into google_restapi.auth
if ($file->{auth} && !($file->{google_restapi} && $file->{google_restapi}{auth})) {
$file->{google_restapi} //= {};
$file->{google_restapi}{auth} = delete $file->{auth};
}
_merge($data, $file);
}
_expand_paths($data, dirname($self->config_file));
return $data;
}
# Recursively merge $src over $dst (scalar/array values in $src win).
sub _merge {
my ($dst, $src) = @_;
for my $key (keys %{ $src }) {
if (ref $src->{$key} eq 'HASH' && ref $dst->{$key} eq 'HASH') {
_merge($dst->{$key}, $src->{$key});
} else {
lib/App/DrivePlayer/Config.pm view on Meta::CPAN
},
music_folders => [],
database => { path => $DEFAULT_DB_PATH },
log_level => 'WARN',
log_file => $DEFAULT_LOG_FILE,
acoustid_key => '',
sheet_id => '',
};
}
sub _expand_paths {
my ($data, $config_dir) = @_;
for my $key (qw( log_file )) {
$data->{$key} = _abs_path($data->{$key}, $config_dir) if defined $data->{$key};
}
$data->{database}{path} = _abs_path($data->{database}{path}, $config_dir)
if defined $data->{database}{path};
# Support auth under google_restapi.auth (preferred) or legacy root auth key
my $auth = $data->{google_restapi}{auth} // $data->{auth};
if ($auth && defined $auth->{token_file}) {
lib/App/DrivePlayer/Config.pm view on Meta::CPAN
$cfg->save; # write changes back to disk
$cfg->ensure_dirs; # create parent directories for db, log, token
=head1 DESCRIPTION
Reads a YAML configuration file and provides typed accessors for every
setting. Missing files are silently replaced by built-in defaults so the
application works out of the box before the user runs the setup wizard.
Tilde (C<~>) at the start of any path value is expanded to C<$HOME>.
=head1 ATTRIBUTES
=head2 config_file
is: ro, isa: Str
Path to the YAML configuration file. Defaults to
F<~/.config/drive_player/config.yaml>.
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
$view->signal_connect('cursor-changed' => sub { $self->_sidebar_activated($view) });
$view->signal_connect('button-press-event' => sub { $self->_sidebar_button_press($view, $_[1]) });
$self->sidebar_view($view);
$view->set_size_request(100, 1);
my $renderer = Gtk3::CellRendererText->new();
$renderer->set(ellipsize => 'end');
my $col = Gtk3::TreeViewColumn->new_with_attributes('', $renderer, text => 0);
$col->set_sizing('fixed');
$col->set_expand(TRUE);
$view->append_column($col);
$view->set_fixed_height_mode(TRUE);
$sw->add($view);
my $hbox = Gtk3::Box->new('horizontal', 0);
$hbox->pack_start($self->_build_alpha_strip(), FALSE, FALSE, 0);
$hbox->pack_start($sw, TRUE, TRUE, 0);
return $hbox;
}
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
my $target_iter = $store->get_iter($target) or return;
my $n = $store->iter_n_children($target_iter);
for my $i (0 .. $n - 1) {
my $child = $store->iter_nth_child($target_iter, $i) or next;
my $label = $store->get($child, 0) // '';
my $first = uc(substr($label, 0, 1));
my $matches = $letter eq '#' ? ($first lt 'A' || $first gt 'Z')
: $first eq $letter;
next unless $matches;
my $path = $store->get_path($child);
$view->expand_to_path($path);
$view->set_cursor($path, undef, FALSE);
$view->scroll_to_cell($path, undef, TRUE, 0.0, 0.0);
return;
}
}
sub _navigate_sidebar_to {
my ($self, $kind, $value) = @_;
my $cat_label = $kind eq 'artist' ? 'Artists'
: $kind eq 'album' ? 'Albums'
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
# Match against the raw value stored in column 2, not the display label
# in column 0 (which may carry LRM markers from _display_text/_album).
my $cat_iter2 = $store->get_iter($cat_path) or return;
my $n = $store->iter_n_children($cat_iter2);
for my $i (0 .. $n - 1) {
my $child = $store->iter_nth_child($cat_iter2, $i) or next;
my $stored = $store->get($child, 2);
next unless defined $stored && $stored eq $value;
my $path = $store->get_path($child);
$view->expand_to_path($path);
$view->set_cursor($path, undef, FALSE);
$view->scroll_to_cell($path, undef, TRUE, 0.5, 0.0);
return;
}
}
sub _update_alpha_category {
my ($self, $label) = @_;
my $enabled = defined $label
&& $label =~ / \A (?: Artists | Albums | Genres ) \z /x;
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
};
if ($check->($cat)) {
$view->set_cursor($store->get_path($cat), undef, FALSE);
last TOP;
}
my $n = $store->iter_n_children($cat);
for my $i (0 .. $n - 1) {
my $child = $store->iter_nth_child($cat, $i) or next;
next unless $check->($child);
my $cp = $store->get_path($child);
$view->expand_to_path($cp);
$view->set_cursor($cp, undef, FALSE);
last TOP;
}
last unless $store->iter_next($cat);
}
}
$self->_suppress_sidebar_activated(0);
}
sub _populate_sidebar {
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
}
# Folders
my $fld_iter = $store->append(undef);
$store->set($fld_iter, 0, 'Folders', 1, 'category', 2, '');
for my $sf ($self->db->all_scan_folders()) {
my $iter = $store->append($fld_iter);
$store->set($iter, 0, $sf->{name}, 1, 'folder', 2, $sf->{drive_id});
}
$self->sidebar_view->expand_all();
}
sub _populate_tracklist {
my ($self, @tracks) = @_;
my $store = $self->track_store;
$store->clear();
$self->_track_iter_map({});
$self->_track_by_id({});
$self->_playlist(\@tracks);
$self->_playing_row_ref(undef);
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
['token_file', 'Token File:',
'Path to the OAuth2 token created by running '
. 'google_restapi_oauth_token_creator.'],
) {
my ($key, $lbl, $tip) = @$field;
my $lbl_w = Gtk3::Label->new($lbl);
$lbl_w->set_xalign(1.0);
$lbl_w->set_tooltip_text($tip);
$grid->attach($lbl_w, 0, $row, 1, 1);
my $e = Gtk3::Entry->new();
$e->set_hexpand(TRUE);
$e->set_text($self->config->auth_config()->{$key} // '');
$e->set_visibility(FALSE) if $key eq 'client_secret';
$e->set_tooltip_text($tip);
$grid->attach($e, 1, $row, 1, 1);
$entries{$key} = $e;
$row++;
}
my $auth_note = Gtk3::Label->new();
$auth_note->set_markup(
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
$sheet_grid->set_border_width(8);
$sheet_frame->add($sheet_grid);
$vbox->pack_start($sheet_frame, FALSE, FALSE, 0);
my $sid_lbl = Gtk3::Label->new('Spreadsheet ID:');
$sid_lbl->set_xalign(1.0);
$sheet_grid->attach($sid_lbl, 0, 0, 1, 1);
my $sid_box = Gtk3::Box->new('horizontal', 6);
my $sid_entry = Gtk3::Entry->new();
$sid_entry->set_hexpand(TRUE);
$sid_entry->set_text($self->config->sheet_id());
$sid_entry->set_placeholder_text('Paste spreadsheet ID, or click Find or Create');
$sid_entry->set_tooltip_text(
'The spreadsheet ID is the long string between "/d/" and "/edit" '
. 'in a Google Sheets URL. Leave blank and click "Find or Create" '
. 'to have DrivePlayer find or create a sheet for you.'
);
$sid_lbl->set_tooltip_text($sid_entry->get_tooltip_text());
$sid_box->pack_start($sid_entry, TRUE, TRUE, 0);
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
return TRUE; # keep polling
});
});
# AcoustID API key
my $aid_lbl = Gtk3::Label->new('AcoustID API Key:');
$aid_lbl->set_xalign(1.0);
$fp_grid->attach($aid_lbl, 0, 1, 1, 1);
my $aid_entry = Gtk3::Entry->new();
$aid_entry->set_hexpand(TRUE);
$aid_entry->set_text($self->config->acoustid_key());
$aid_entry->set_placeholder_text('Get a free key at acoustid.org');
$aid_entry->set_tooltip_text(
'Free AcoustID API key â used with fpcalc to look up missing tags '
. 'by acoustic fingerprint.'
);
$aid_lbl->set_tooltip_text($aid_entry->get_tooltip_text());
$fp_grid->attach($aid_entry, 1, 1, 1, 1);
$fp_lbl->set_tooltip_text(
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
[ genre => 'Genre:' ],
[ track_number => 'Track Number:' ],
[ year => 'Year:' ],
[ comment => 'Comment:' ],
) {
my ($key, $lbl) = @$field;
my $label = Gtk3::Label->new($lbl);
$label->set_xalign(1.0);
$grid->attach($label, 0, $row, 1, 1);
my $entry = Gtk3::Entry->new();
$entry->set_hexpand(TRUE);
$entry->set_direction('ltr');
$entry->set_alignment(0.0);
$put->($entry, $track->{$key});
$grid->attach($entry, 1, $row, 1, 1);
$entries{$key} = $entry;
$row++;
}
my $fetch_btn = Gtk3::Button->new_with_label('Fetch');
$fetch_btn->set_halign('end');
lib/App/DrivePlayer/GUI/FolderBrowse.pm view on Meta::CPAN
my $grid = Gtk3::Grid->new();
$grid->set_row_spacing(6);
$grid->set_column_spacing(8);
$grid->set_border_width(12);
$dlg->get_content_area()->add($grid);
$grid->attach(Gtk3::Label->new('Drive Folder:'), 0, 0, 1, 1);
my $id_box = Gtk3::Box->new('horizontal', 4);
my $id_entry = Gtk3::Entry->new();
$id_entry->set_placeholder_text('Folder ID or paste from Drive URL');
$id_entry->set_hexpand(TRUE);
my $browse_btn = Gtk3::Button->new_with_label('Browseâ¦');
$id_box->pack_start($id_entry, TRUE, TRUE, 0);
$id_box->pack_start($browse_btn, FALSE, FALSE, 0);
$grid->attach($id_box, 1, 0, 1, 1);
$grid->attach(Gtk3::Label->new('Display Name:'), 0, 1, 1, 1);
my $name_entry = Gtk3::Entry->new();
$name_entry->set_placeholder_text('e.g. My Music');
$name_entry->set_hexpand(TRUE);
$grid->attach($name_entry, 1, 1, 1, 1);
# When a Drive URL is pasted, extract the folder ID and look up its name.
$id_entry->signal_connect(changed => sub {
my $text = $id_entry->get_text();
if (my ($extracted) = $text =~ m{drive\.google\.com/\S*?/([a-zA-Z0-9_-]{25,})}x) {
$id_entry->set_text($extracted); # triggers changed again with bare ID â no-op second time
if ($name_entry->get_text() eq '') {
my $name = $self->_fetch_drive_name($extracted);
$name_entry->set_text($name) if $name;
lib/App/DrivePlayer/GUI/FolderBrowse.pm view on Meta::CPAN
my $dlg = Gtk3::Dialog->new_with_buttons(
'Browse Drive Folders', $parent,
[qw/ modal destroy-with-parent /],
'Select', 'ok',
'Cancel', 'cancel',
);
$dlg->set_default_size(460, 420);
$dlg->set_response_sensitive('ok', FALSE);
# TreeStore columns: 0=name 1=id 2=children_loaded 3=expand_filter
my $store = Gtk3::TreeStore->new(
'Glib::String', 'Glib::String', 'Glib::Boolean', 'Glib::String',
);
my $tree = Gtk3::TreeView->new($store);
$tree->set_headers_visible(FALSE);
$tree->append_column(
Gtk3::TreeViewColumn->new_with_attributes(
'Name', Gtk3::CellRendererText->new(), text => 0,
)
);
my $sw = Gtk3::ScrolledWindow->new();
$sw->set_policy('automatic', 'automatic');
$sw->set_vexpand(TRUE);
$sw->add($tree);
my $status = Gtk3::Label->new(q{});
$status->set_xalign(0.0);
my $vbox = Gtk3::Box->new('vertical', 6);
$vbox->set_border_width(12);
$vbox->pack_start($sw, TRUE, TRUE, 0);
$vbox->pack_start($status, FALSE, FALSE, 0);
$dlg->get_content_area()->add($vbox);
lib/App/DrivePlayer/GUI/FolderBrowse.pm view on Meta::CPAN
}
# Enable Select only when a real folder (non-empty id) is highlighted
$tree->get_selection()->signal_connect(changed => sub {
my ($sel) = @_;
my (undef, $iter) = $sel->get_selected();
my $id = $iter ? $store->get($iter, 1) : q{};
$dlg->set_response_sensitive('ok', ($id ne q{}) ? TRUE : FALSE);
});
# Lazy-load subfolders when a row is expanded.
# Do NOT remove the placeholder before the API call â removing the only
# child while the row is expanded causes GTK to auto-collapse it.
# The placeholder is removed inside _load_drive_children after real
# children have been appended.
$tree->signal_connect('row-expanded' => sub {
my (undef, $iter, undef) = @_;
return if $store->get($iter, 2);
$store->set($iter, 2, TRUE);
my $filter = $store->get($iter, 3);
$self->_load_drive_children($store, $iter, $filter, $status);
});
my $response = $dlg->run();
my $result;
if ($response eq 'ok') {
lib/App/DrivePlayer/GUI/FolderBrowse.pm view on Meta::CPAN
for my $f (sort { lc($a->{name}) cmp lc($b->{name}) } @folders) {
my $iter = $store->append($parent_iter);
$store->set($iter, 0, $f->{name}, 1, $f->{id}, 2, FALSE,
3, "'$f->{id}' in parents and $mf");
my $ph = $store->append($iter);
$store->set($ph, 0, "Loading\x{2026}", 1, q{}, 2, FALSE, 3, q{});
}
# Remove the placeholder now that real children (or an empty notice) are
# in place. Do this after appending so the row is never childless while
# expanded (which would cause GTK to auto-collapse it).
my $ph = $store->iter_children($parent_iter);
if ($ph && $store->get($ph, 1) eq q{}) {
if (@folders) {
$store->remove($ph);
} else {
# Can't remove the last child; relabel it instead.
$store->set($ph, 0, '(no subfolders)');
}
}
return;
lib/App/DrivePlayer/SheetDB.pm view on Meta::CPAN
my $i = tied(%$cols)->iterator(from => 0);
while (my $row = $i->iterate()) {
tied(%$row)->values();
last unless $row->{drive_id};
push(@result, $row);
}
return \@result;
}
# Open a worksheet by name, creating it with enough rows if absent, or
# expand it if it already exists but the grid is too small for the data.
sub _ensure_worksheet {
my ($self, $ss, $name, $needed_rows) = @_;
# Default Google Sheets grid is 1000 rows; honour that for small data.
$needed_rows = 1000 if !$needed_rows || $needed_rows < 1000;
# Try to create; silently ignore the error if it already exists.
# If the API call fails (e.g. sheet already exists), the failed addSheet
# request is left in $ss's internal batch queue. Clear it so that
# subsequent submit_requests() calls don't re-send the stale request.
eval { $ss->add_worksheet(
t/unit/Test/DrivePlayer/Config.pm view on Meta::CPAN
my $path = $self->_write_yaml('config.yaml', {
auth => { class => 'OAuth2Client', client_id => 'x', client_secret => 'x',
token_file => '~/token.dat', scope => [] },
database => { path => '~/music.db' },
log_file => '~/drive_player.log',
music_folders => [],
});
my $cfg = fake_config(config_file => $path);
like $cfg->db_path, qr{^\Q$ENV{HOME}\E}, 'db_path ~ expanded';
like $cfg->log_file, qr{^\Q$ENV{HOME}\E}, 'log_file ~ expanded';
like $cfg->token_file, qr{^\Q$ENV{HOME}\E}, 'token_file ~ expanded';
}
# ---- auth_config ----
sub auth_config : Tests(3) {
my ($self) = @_;
my $cfg = fake_config(config_file => '/nonexistent/drive_player/config.yaml');
my $auth = $cfg->auth_config();
is ref($auth), 'HASH', 'auth_config returns hashref';