Skip to content

Commit 960ab59

Browse files
committed
Add Polars scanner mapping and fix rescan artwork
1 parent de1a613 commit 960ab59

5 files changed

Lines changed: 1011 additions & 9 deletions

File tree

Slim/Control/Commands.pm

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ use Slim::Utils::Misc;
3838
use Slim::Utils::Prefs;
3939
use Slim::Utils::OSDetect;
4040
use Slim::Utils::Scanner::Local;
41+
use Slim::Music::Artwork;
4142

4243
my $log = logger('control.command');
4344

@@ -2779,6 +2780,21 @@ sub rescanCommand {
27792780
types => 'audio',
27802781
recursive => 0,
27812782
} ) if scalar @paths;
2783+
2784+
my %albumIds;
2785+
for my $track (@tracks) {
2786+
next unless blessed($track);
2787+
my $album = eval { $track->album };
2788+
next unless $album && blessed($album);
2789+
my $albumId = $album->id;
2790+
next unless defined $albumId;
2791+
$albumIds{$albumId} = 1;
2792+
}
2793+
2794+
my @albumIds = keys %albumIds;
2795+
if ( @albumIds ) {
2796+
Slim::Music::Artwork->updateParentDirectoryArtwork( undef, { albums => \@albumIds } );
2797+
}
27822798
}
27832799
else {
27842800
# In-process scan

Slim/Music/Artwork.pm

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,8 @@ sub findStandaloneArtwork {
216216

217217
sub updateStandaloneArtwork {
218218
my $class = shift;
219-
my $cb = shift; # optional callback when done (main process async mode)
219+
my ($cb, $opts) = _extractArtworkTaskArgs(@_);
220+
$opts ||= {};
220221

221222
my $dbh = Slim::Schema->dbh;
222223

@@ -247,6 +248,13 @@ sub updateStandaloneArtwork {
247248
};
248249
}
249250

251+
my @bind;
252+
if ( my @albumFilter = _sanitizeAlbumIds( $opts->{albums} ) ) {
253+
my $placeholders = join(',', ('?') x @albumFilter);
254+
$where .= " AND tracks.album IN ($placeholders)";
255+
push @bind, @albumFilter;
256+
}
257+
250258
# Find all tracks with un-cached artwork:
251259
# * All distinct cover values where cover isn't 0 and cover_cached is null
252260
# * Tracks share the same cover art when the cover field is the same
@@ -281,7 +289,7 @@ sub updateStandaloneArtwork {
281289

282290
my ($count) = $dbh->selectrow_array( qq{
283291
SELECT COUNT(*) FROM ( $sql ) AS t1
284-
} );
292+
}, undef, @bind );
285293

286294
$log->error("Starting updateStandaloneArtwork for $count albums");
287295

@@ -299,7 +307,7 @@ sub updateStandaloneArtwork {
299307
} );
300308

301309
my $sth = $dbh->prepare($sql);
302-
$sth->execute;
310+
$sth->execute(@bind);
303311

304312
my ($trackid, $url, $cover, $coverid, $albumid, $album_title, $album_artwork, $album_cover);
305313
$sth->bind_columns(\$trackid, \$url, \$cover, \$coverid, \$albumid, \$album_title, \$album_artwork, \$album_cover);
@@ -426,7 +434,8 @@ discs retain their own track-level artwork.
426434

427435
sub updateParentDirectoryArtwork {
428436
my $class = shift;
429-
my $cb = shift; # optional callback when done
437+
my ($cb, $opts) = _extractArtworkTaskArgs(@_);
438+
$opts ||= {};
430439

431440
my $dbh = Slim::Schema->dbh;
432441
my $log = logger('scan.artwork');
@@ -435,6 +444,14 @@ sub updateParentDirectoryArtwork {
435444

436445
# Find multi-disc albums (disc count > 1). They might span multiple directories
437446
# or keep everything in a single folder with disc-specific artwork.
447+
my @bind;
448+
my $albumFilterSql = '';
449+
if ( my @albumFilter = _sanitizeAlbumIds( $opts->{albums} ) ) {
450+
my $placeholders = join(',', ('?') x @albumFilter);
451+
$albumFilterSql = "AND albums.id IN ($placeholders)";
452+
push @bind, @albumFilter;
453+
}
454+
438455
my $sql = qq{
439456
SELECT
440457
albums.id AS album_id,
@@ -450,6 +467,7 @@ sub updateParentDirectoryArtwork {
450467
FROM albums
451468
JOIN tracks ON tracks.album = albums.id
452469
WHERE tracks.url LIKE 'file://%'
470+
$albumFilterSql
453471
GROUP BY albums.id
454472
HAVING COUNT(DISTINCT tracks.disc) > 1
455473
};
@@ -460,7 +478,7 @@ sub updateParentDirectoryArtwork {
460478

461479
my @albums_to_check;
462480
my $sth = $dbh->prepare($sql);
463-
$sth->execute;
481+
$sth->execute(@bind);
464482

465483
while (my $row = $sth->fetchrow_hashref) {
466484
push @albums_to_check, $row;
@@ -1248,6 +1266,39 @@ sub precacheAllArtwork {
12481266
}
12491267
}
12501268

1269+
sub _extractArtworkTaskArgs {
1270+
my @args = @_;
1271+
my $cb;
1272+
my $opts = {};
1273+
for my $arg (@args) {
1274+
next unless ref $arg;
1275+
if ( ref $arg eq 'CODE' && !$cb ) {
1276+
$cb = $arg;
1277+
next;
1278+
}
1279+
if ( ref $arg eq 'HASH' ) {
1280+
$opts = $arg;
1281+
}
1282+
}
1283+
return ( $cb, $opts );
1284+
}
1285+
1286+
sub _sanitizeAlbumIds {
1287+
my $candidates = shift;
1288+
return () unless $candidates && ref $candidates eq 'ARRAY';
1289+
1290+
my %seen;
1291+
my @valid;
1292+
for my $id (@$candidates) {
1293+
next unless defined $id;
1294+
next unless $id =~ /^\d+$/;
1295+
next if $seen{$id}++;
1296+
push @valid, $id;
1297+
}
1298+
1299+
return @valid;
1300+
}
1301+
12511302
sub getResizeSpecs {
12521303
my @specs = (
12531304
'64x64_m', # Fab4 10'-UI Album list

Slim/Utils/Scanner/Local.pm

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,15 +1087,17 @@ sub changed {
10871087

10881088
$track->update;
10891089

1090+
# Regenerate coverid now that cover/url/timestamp are final.
1091+
# The lazy accessor persists the new value to the DB.
1092+
my $coverid = $track->coverid;
1093+
10901094
# Make sure album.artwork points to this track, so the album
10911095
# uses the newest available artwork. Skip this when albums.cover
10921096
# is set, because box-set parent artwork should remain authoritative.
10931097
my $albumHasParentCover = $album && defined $album->cover && length $album->cover;
10941098
if ( !$albumHasParentCover ) {
1095-
if ( my $coverid = $track->coverid ) {
1096-
if ( $album->artwork ne $coverid ) {
1097-
$album->artwork($coverid);
1098-
}
1099+
if ( $coverid && $album->artwork ne $coverid ) {
1100+
$album->artwork($coverid);
10991101
}
11001102
}
11011103

docs/polars_scanner_mapping.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Polars Scanner – `alib``library.db` Mapping
2+
3+
The Python-based scanner consumes the staging table `alib` (produced by `tags2db-polars-multidrive-optimised.py`) and reproduces the database layout that the legacy Perl scanner leaves in `library.db`. The mapping below documents how every field is derived. Column names prefixed with `__` come directly from the staging table, while others are tag fields extracted by `audioinfo`.
4+
5+
## Tracks (`tracks`)
6+
7+
| `tracks` column | Source in `alib` | Notes |
8+
| --- | --- | --- |
9+
| `url` | `fileURL(__path)` | Stored as `file://` URL (same encoding as `Slim::Utils::Misc::fileURLFromPath`). |
10+
| `title` | `title` | Raw tag value.
11+
| `titlesort` | `title` | `Slim::Utils::Text::ignoreCaseArticles()` equivalent (upper-case, articles stripped, punctuation removed).
12+
| `titlesearch` | `title` | `ignoreCase()` equivalent (case-insensitive, no article stripping).
13+
| `customsearch` | `title` + `subtitle` | Concatenated and normalized identical to Perl scanner.
14+
| `album` | FK produced from `album` table mapping | Deterministic key: (`album`, `albumartist`, `musicbrainz_albumid`, `discnumber`, `compilation`).
15+
| `tracknum` | `track` | Parsed to integer; zero if missing.
16+
| `content_type` | `__filetype` | Lower-case; same values server uses (e.g., `flc`, `mp3`).
17+
| `timestamp` | `__file_mod_datetime_raw` | Seconds since epoch; float accepted.
18+
| `filesize` | `__file_size_bytes` | Integer bytes.
19+
| `audio_size` | `__file_size_bytes` | Same as `filesize` when full file scanned.
20+
| `audio_offset` | `0` | Not tracked in staging data.
21+
| `year` | `year` or fallback `originalyear` | Prefer release year; integer.
22+
| `secs` | `__length_seconds` | Float seconds with millisecond precision.
23+
| `cover` | `NULL` | Artwork from file is handled later by LMS.
24+
| `vbr_scale` | `__bitrate` | Retained as text description.
25+
| `bitrate` | `__bitrate_num` | Numeric kbps; converted from string if needed.
26+
| `samplerate` | `__frequency_num` | Integer Hz.
27+
| `samplesize` | `__bitspersample` | Integer.
28+
| `channels` | `__channels` | Integer.
29+
| `block_alignment` | `NULL` | Not exposed by `alib`.
30+
| `endian` | `NULL` | Not exposed.
31+
| `bpm` | `__bpm` if present | New tag fallback.
32+
| `tagversion` | `__version` |
33+
| `drm` | `explicit` flag | 1 for DRM/explicit when flagged.
34+
| `disc` | `disc` or `discnumber` | Integer disc index.
35+
| `audio` | `1` | All staged files are audio.
36+
| `remote` | `0` | Staging only contains local files.
37+
| `lossless` | Derived from extension (`flac`,`alac`,`wv`,`aiff`,`ape`) | Boolean flag.
38+
| `lyrics` | `lyrics` or `unsyncedlyrics` | UTF-8 text.
39+
| `musicbrainz_id` | `musicbrainz_trackid` | 36-char UUID.
40+
| `musicmagic_mixable` | `analysis` | Set to 1 when the `analysis` tag is non-null, otherwise 0.
41+
| `replay_gain` | `replaygain_track_gain` | Normalized float.
42+
| `replay_peak` | `replaygain_track_peak` | Float.
43+
| `extid` | `tagminder_uuid` | Custom stable GUID for track.
44+
| `urlmd5` | `md5(url)` | 32-char hex, same as Perl scanner.
45+
| `coverid` | `NULL` | Populated later by LMS artwork pipeline.
46+
| `cover_cached` | `NULL` |
47+
| `virtual` | `0` | Only physical files handled.
48+
| `added_time` | `__file_mod_datetime_raw` | Mirrors `updated_time` like the Perl scanner.
49+
| `updated_time` | `__file_mod_datetime_raw` | Same as `timestamp`.
50+
51+
Any column not backed by staging data is set to `NULL` to allow LMS to fill it later.
52+
53+
## Albums (`albums`)
54+
55+
| Column | Source | Notes |
56+
| --- | --- | --- |
57+
| `title` | `album` |
58+
| `titlesort` | `album` | `ignoreCaseArticles()`.
59+
| `titlesearch` | `album` | `ignoreCase()`.
60+
| `customsearch` | `album` + `albumartist` | Same logic as Perl scanner.
61+
| `compilation` | `compilation` tag or heuristics | 1 if tagged or if there are multiple track artists and no albumartist tag.
62+
| `year` | `year` or `originalyear` |
63+
| `artwork` | `NULL` | Linked once tracks inserted.
64+
| `disc` | minimum disc number |
65+
| `discc` | maximum disc count across album |
66+
| `replay_gain` / `replay_peak` | album-level replaygain tags |
67+
| `musicbrainz_id` | `musicbrainz_albumid` |
68+
| `musicmagic_mixable` | `amgtagged` |
69+
| `contributor` | FK to album-artist `contributors.id` | Derived from albumartist tag or fallback to track artist.
70+
71+
Albums are keyed by (`title`,`albumartist`,`musicbrainz_albumid`,`discc`,`compilation`).
72+
73+
## Contributors (`contributors`)
74+
75+
Names are taken from the respective tag fields (artist, albumartist, composer, conductor, etc.). For each role we:
76+
77+
1. Split multi-value tags using the `\\` literal delimiter (same as staging file).
78+
2. Normalize `namesort` (`ignoreCaseArticles`), `namesearch` (`ignoreCase`), and transliterate to ASCII for indexes.
79+
3. Store MusicBrainz IDs where available (e.g., `musicbrainz_artistid`, `musicbrainz_albumartistid`, etc.).
80+
81+
Roles follow the LMS internal IDs:
82+
83+
| Role | ID | `alib` column(s) |
84+
| --- | --- | --- |
85+
| ARTIST | 1 | `artist` |
86+
| COMPOSER | 2 | `composer` |
87+
| CONDUCTOR | 3 | `conductor` |
88+
| BAND | 4 | `ensemble`, `band`, `orchestra` |
89+
| ALBUMARTIST | 5 | `albumartist` |
90+
| TRACKARTIST | 6 | `artist` (when distinct per track) |
91+
92+
Custom roles defined in `userDefinedRoles` (from `server.prefs`) are appended, preserving their IDs (≥ 21) so plugins continue to work.
93+
94+
`contributor_track` receives one row per `(track_id, role_id, contributor_id)` triple, and `contributor_album` aggregates `(album_id, role_id, contributor_id)` to mirror the Perl scanner’s behavior.
95+
96+
## Genres (`genres`, `genre_track`)
97+
98+
Tags `genre` and `style` are combined. Each multi-value tag is split on the `\\` delimiter, normalized using the same case-folding as contributors, inserted (deduped) into `genres`, and related to tracks through `genre_track`. `mood` and `theme` stay as plain tags and are not mapped to `genres`.
99+
100+
## Playlist & Comments
101+
102+
When `alib` exposes playlist or comment metadata:
103+
104+
- `playlist_track` gets rebuilt from staged playlist rows (path stored under `__path` with `__tag == 'playlist'`). For now the scanner preserves existing playlist rows because staging focuses on audio files; LMS will rebuild playlists on demand.
105+
- `comments` table receives `review`/`lyrics` style annotations keyed by track ID.
106+
107+
## Virtual Libraries & Release Types
108+
109+
After the core tables are populated:
110+
111+
1. `library_track`, `library_album`, `library_contributor`, and `library_genre` are regenerated when virtual libraries are enabled (matching the SQL found in `Slim/Music/VirtualLibraries.pm`).
112+
2. Release type hints run the same queries as `Slim::Music::ReleaseTypes` to update `albums.release_type` for Singles/EPs.
113+
114+
## Full Text Search (optional)
115+
116+
If the Full Text Search plugin is enabled (`plugin.fulltext` pref), the scanner rebuilds `fulltext` and `fulltext_terms` with the SQL templates embedded in `Slim::Plugin::FullTextSearch::Plugin`. The resulting tables are byte-for-byte compatible with the plugin’s importer, meaning LMS will not trigger another FTS rebuild when it starts.
117+
118+
---
119+
120+
This mapping guarantees that every table `scanner.pl` touches is populated with equivalent data, enabling the new Polars-based scanner to be a drop-in replacement.

0 commit comments

Comments
 (0)