App-DrivePlayer

 view release on metacpan or  search on metacpan

lib/App/DrivePlayer/DB.pm  view on Meta::CPAN

# Trigger schema build (and thus table creation) at construction time.
sub BUILD { $_[0]->schema }

# ---- Helpers ----

# Maximum character lengths for text fields written to the database.
# Long values are silently truncated; integers are never truncated.
my %MAX_LEN = (
    title         => 500,
    artist        => 255,
    album         => 255,
    genre         => 100,
    comment       => 500,
    mime_type     => 100,
    modified_time =>  50,
    name          => 500,
    path          => 500,
    folder_path   => 500,
);

sub _trunc {
    my ($val, $field) = @_;
    return $val unless defined $val && exists $MAX_LEN{$field};
    my $max = $MAX_LEN{$field};
    return length($val) > $max ? substr($val, 0, $max) : $val;
}

# Convert a DBIC Row object to a plain hashref.
sub _row_to_hash { my $row = shift; return $row ? { $row->get_columns() } : undef }

# Return a resultset shorthand.
sub _rs { $_[0]->schema->resultset($_[1]) }

# ---------- scan_folders ----------

sub upsert_scan_folder {
    my ($self, $drive_id, $name) = @_;
    my $row = $self->_rs('ScanFolder')->update_or_create(
        { drive_id => $drive_id,
          name     => _trunc($name, 'name') },
        { key => 'unique_drive_id' },
    );
    return _row_to_hash($row);
}

sub get_scan_folder_by_drive_id {
    my ($self, $drive_id) = @_;
    return _row_to_hash(
        $self->_rs('ScanFolder')->find({ drive_id => $drive_id })
    );
}

sub all_scan_folders {
    my ($self) = @_;
    return map { _row_to_hash($_) }
        $self->_rs('ScanFolder')->search({}, { order_by => 'name' })->all;
}

sub delete_scan_folder {
    my ($self, $drive_id) = @_;
    # cascade_delete on the has_many relationship removes child folders+tracks
    my $row = $self->_rs('ScanFolder')->find({ drive_id => $drive_id });
    $row->delete if $row;
}

# ---------- folders ----------

sub upsert_folder {
    my ($self, %f) = @_;
    my $row = $self->_rs('Folder')->update_or_create(
        {
            drive_id        => $f{drive_id},
            name            => _trunc($f{name},            'name'),
            parent_drive_id => $f{parent_drive_id},
            path            => _trunc($f{path},            'path'),
            scan_folder_id  => $f{scan_folder_id},
        },
        { key => 'unique_drive_id' },
    );
    return _row_to_hash($row);
}

sub get_folder_by_drive_id {
    my ($self, $drive_id) = @_;
    return _row_to_hash(
        $self->_rs('Folder')->find({ drive_id => $drive_id })
    );
}

sub folders_for_scan_folder {
    my ($self, $scan_folder_id) = @_;
    return map { _row_to_hash($_) }
        $self->_rs('Folder')->search(
            { scan_folder_id => $scan_folder_id },
            { order_by       => 'path' },
        )->all;
}

# ---------- tracks ----------

my @STRUCTURAL_FIELDS = qw( folder_id folder_path mime_type size modified_time );
my @METADATA_FIELDS   = qw( title artist album track_number year duration_ms genre comment );

sub upsert_track {
    my ($self, %t) = @_;
    my $existing = $self->_rs('Track')->find({ drive_id => $t{drive_id} });

    if ($existing) {
        # Always refresh structural fields from the Drive scan.
        # Preserve metadata fields that are already populated; only fill in
        # nulls from the scan's filename-derived values.  This ensures that
        # metadata loaded from the sheet (or set via AcoustID) is never
        # silently overwritten by a re-scan.
        my %update = map { $_ => _trunc($t{$_}, $_) } @STRUCTURAL_FIELDS;
        for my $f (@METADATA_FIELDS) {
            my $cur = $existing->get_column($f);
            # Treat the drive_id placeholder as absent for the title field.
            my $absent = !defined $cur || $cur eq ''
                      || ($f eq 'title' && $cur eq $existing->drive_id);
            $update{$f} = _trunc($t{$f}, $f) if $absent;
        }

lib/App/DrivePlayer/DB.pm  view on Meta::CPAN

    return map { _row_to_hash($_) }
        $self->_rs('Folder')->search(
            \[
                'folder.parent_drive_id = scan_folder.drive_id
                 OR folder.drive_id = scan_folder.drive_id'
            ],
            {
                join     => 'scan_folder',
                order_by => 'me.path',
            },
        )->all;
}

sub track_count {
    my ($self) = @_;
    return $self->_rs('Track')->count;
}

sub tracks_needing_metadata {
    my ($self, $scan_folder_id) = @_;
    my %cond = ( metadata_fetched => 0 );
    my %attr = ( order_by => \@TRACK_ORDER );
    if (defined $scan_folder_id) {
        $cond{'folder.scan_folder_id'} = $scan_folder_id;
        $attr{join} = 'folder';
    }
    return map { _row_to_hash($_) }
        $self->_rs('Track')->search(\%cond, \%attr)->all;
}

sub mark_metadata_fetched {
    my ($self, $id) = @_;
    my $row = $self->_rs('Track')->find($id) or return;
    $row->update({ metadata_fetched => 1 });
}

sub reset_metadata_fetched {
    my ($self) = @_;
    $self->_rs('Track')->update_all({ metadata_fetched => 0 });
}

sub reset_metadata_fetched_incomplete {
    my ($self) = @_;
    $self->_rs('Track')->search({
        metadata_fetched => 1,
        -or => [
            genre  => undef,
            genre  => '',
            artist => undef,
            artist => '',
            album  => undef,
            album  => '',
            year   => undef,
        ],
    })->update_all({ metadata_fetched => 0 });
}

sub clear_scan_folder_tracks {
    my ($self, $scan_folder_id) = @_;
    # Collect folder IDs, then bulk-delete tracks and folders.
    # (Relies on foreign_keys=ON cascade for correctness; explicit here for speed.)
    my @folder_ids = $self->_rs('Folder')
        ->search({ scan_folder_id => $scan_folder_id })
        ->get_column('id')->all;

    if (@folder_ids) {
        $self->_rs('Track')->search({ folder_id => \@folder_ids })->delete;
    }
    $self->_rs('Folder')->search({ scan_folder_id => $scan_folder_id })->delete;
}

sub count_unseen_tracks {
    my ($self, $scan_folder_id, $seen) = @_;
    my @keep = keys %$seen;
    my @folder_ids = $self->_rs('Folder')
        ->search({ scan_folder_id => $scan_folder_id })
        ->get_column('id')->all;
    return 0 unless @folder_ids;
    return $self->_rs('Track')->search({
        folder_id => { -in  => \@folder_ids },
        (@keep ? (drive_id => { -not_in => \@keep }) : ()),
    })->count;
}

sub remove_unseen_tracks {
    my ($self, $scan_folder_id, $seen) = @_;
    my @keep = keys %$seen;
    my @folder_ids = $self->_rs('Folder')
        ->search({ scan_folder_id => $scan_folder_id })
        ->get_column('id')->all;
    return 0 unless @folder_ids;
    return $self->_rs('Track')->search({
        folder_id => { -in  => \@folder_ids },
        (@keep ? (drive_id => { -not_in => \@keep }) : ()),
    })->delete;
}

sub remove_unseen_folders {
    my ($self, $scan_folder_id, $seen) = @_;
    my @keep = keys %$seen;
    return $self->_rs('Folder')->search({
        scan_folder_id => $scan_folder_id,
        (@keep ? (drive_id => { -not_in => \@keep }) : ()),
    })->delete;
}

1;

__END__

=head1 NAME

App::DrivePlayer::DB - SQLite database facade for the DrivePlayer library

=head1 SYNOPSIS

  use App::DrivePlayer::DB;

  my $db = App::DrivePlayer::DB->new(path => '/path/to/music.db');

  # Scan-folder management
  my $sf = $db->upsert_scan_folder($drive_id, 'My Music');
  my @sfs = $db->all_scan_folders;
  $db->delete_scan_folder($drive_id);   # cascades to folders + tracks

  # Track queries
  my @tracks = $db->all_tracks;
  my @tracks = $db->search_tracks('zeppelin');
  my @tracks = $db->tracks_by_artist('Queen');
  my @tracks = $db->tracks_by_album('Led Zeppelin IV');
  my $track  = $db->get_track_by_drive_id($drive_id);
  my $track  = $db->get_track($id);

  my @artists = $db->all_artists;
  my @albums  = $db->all_albums;
  my $count   = $db->track_count;

=head1 DESCRIPTION

A thin L<Moo> facade over a L<App::DrivePlayer::Schema> (L<DBIx::Class>) schema.
Handles database creation on first use and exposes a simple hashref-based
API so the rest of the application never touches DBIx::Class directly.

All query methods return plain hashrefs (or lists of hashrefs), never
DBIx::Class row objects.

=head1 ATTRIBUTES

=head2 path

  is: ro, isa: Str, required: 1

Filesystem path to the SQLite database file.  The parent directory is
created automatically if it does not exist.

=head2 schema

  is: lazy, isa: App::DrivePlayer::Schema

The underlying DBIx::Class schema object.  Built automatically on first
access; the database file and tables are created at that point if needed.

=head1 METHODS

=head2 new

  my $db = App::DrivePlayer::DB->new(path => $path);

Constructor.  The schema (and the SQLite file) is initialised immediately.

=head2 upsert_scan_folder

  my $hashref = $db->upsert_scan_folder($drive_id, $name);

Insert or update a top-level scan folder record.  Returns the row as a
hashref with at least C<id>, C<drive_id>, and C<name>.

=head2 get_scan_folder_by_drive_id

  my $hashref = $db->get_scan_folder_by_drive_id($drive_id);

Returns the scan folder hashref, or C<undef> if not found.

=head2 all_scan_folders

  my @hashrefs = $db->all_scan_folders;

Returns all scan folders ordered alphabetically by name.

=head2 delete_scan_folder

  $db->delete_scan_folder($drive_id);

Deletes the scan folder and, via cascaded foreign-key constraints, all of
its child folders and tracks.

=head2 upsert_folder

  my $hashref = $db->upsert_folder(%fields);

Insert or update a subfolder record.  Required keys: C<drive_id>, C<name>,
C<path>, C<scan_folder_id>.  Optional: C<parent_drive_id>.

=head2 get_folder_by_drive_id

  my $hashref = $db->get_folder_by_drive_id($drive_id);

Returns the folder hashref, or C<undef> if not found.

=head2 folders_for_scan_folder

  my @hashrefs = $db->folders_for_scan_folder($scan_folder_id);

Returns all folders belonging to a scan folder, ordered by path.

=head2 upsert_track

  $db->upsert_track(%fields);

Insert or update a track record keyed on C<drive_id>.  Common fields:
C<drive_id>, C<title>, C<artist>, C<album>, C<track_number>, C<year>,
C<duration_ms>, C<size>, C<mime_type>, C<modified_time>, C<folder_id>,
C<folder_path>.

=head2 get_track

  my $hashref = $db->get_track($id);

Look up a track by its integer primary key.  Returns C<undef> if not found.

=head2 get_track_by_drive_id

  my $hashref = $db->get_track_by_drive_id($drive_id);

Look up a track by its Google Drive file ID.  Returns C<undef> if not found.

=head2 all_tracks

  my @hashrefs = $db->all_tracks;

Returns every track, ordered by artist, album, track_number, title
(all comparisons case-insensitive, NULLs last).

=head2 tracks_by_artist

  my @hashrefs = $db->tracks_by_artist($artist);

Case-insensitive artist match, ordered by album -> track_number -> title.

=head2 tracks_by_album

  my @hashrefs = $db->tracks_by_album($album);

Case-insensitive album match, ordered by track_number -> title.



( run in 0.404 second using v1.01-cache-2.11-cpan-e93a5daba3e )