App-DrivePlayer

 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';



( run in 0.905 second using v1.01-cache-2.11-cpan-5623c5533a1 )