feat(auto): real metadata, quality sort and name buckets for local music [size:exception]

Local tracks now show embedded title/artist/album art (via native
MediaMetadataRetriever, cached through the existing FileProvider)
instead of the raw filename, falling back gracefully when a file
has no usable tags. Adds two navigable entry points per folder: sort
by audio quality (bitrate, capped at 150 tracks per folder to bound
worst-case latency) and alphabetical name buckets -- the closest
realistic form of "filtering" given Android Auto has no text-search
UI in this integration.

Metadata resolves only for the page actually being browsed (same
slice-cheap-then-map discipline as the paging change), backed by a
flat 256-entry LRU session cache that survives across pages. No new
permission, no new pub dependency, no l10n changes (car-tree labels
stay hardcoded Spanish, matching every existing label in the tree).
This commit is contained in:
2026-07-19 23:52:08 +02:00
parent e030a0975d
commit 352eb9fc37
13 changed files with 2470 additions and 82 deletions
@@ -0,0 +1,91 @@
# Tasks: Android Auto Local Music — Phase 2 (Metadata, Sort, Name Buckets)
## Review Workload Forecast
| Field | Value |
|-------|-------|
| Estimated changed lines | ~950-1250 (native ~180-230, `pista_local.dart` ~60, `musica_local_auto.dart` ~180-220, `navegacion_auto.dart` ~260-320, new/extended tests ~350-450) |
| 400-line budget risk | High |
| Chained PRs recommended | Yes |
| Suggested split | PR 1 -> PR 2 -> PR 3 (see Suggested Work Units) |
| Delivery strategy | single-pr with size:exception (resolved at apply time — user's established preference this session) |
| Chain strategy | N/A — single PR, not chained |
Decision needed before apply: No — resolved as single-pr + size:exception
Chained PRs recommended: Yes (forecast unchanged; overridden by explicit size:exception)
Chain strategy: N/A
400-line budget risk: High (accepted via size:exception)
Rationale: this is the async-conversion blast radius the design explicitly calls out as
"the main implementation hazard" — `itemsLocales` sync-to-async touches every caller
(`hijosMusicaLocal`, both existing callers already `await` it so the call sites are
compatible, but the signature change ripples through `ConstructorArbolAuto` tests), plus
one native batched-extraction method + LRU cache + quality sort + bucket partitioning +
media-id codec extension, each independently testable but collectively larger than
Phase 1's original browse-tree change.
### Suggested Work Units
| Unit | Goal | Likely PR | Notes |
|------|------|-----------|-------|
| 1 | Model + native metadata surface (Tasks 1-3) | PR 1 | `PistaLocal`/`MetadatosPista` DTOs, `readAudioMetadataBatch` + art cache/LRU trim (static-review-only). Independent — no Dart call sites yet. |
| 2 | Metadata cache + async `itemsLocales` conversion (Tasks 4-5, 10-11) | PR 2 | THE load-bearing unit — base = PR 1 branch. `CacheMetadatosSesion`, `metadatosDe`, async `itemsLocales`, regression suite. Ships metadata-backed titles/art with existing name-sort browse tree; no sort/bucket UI yet. |
| 3 | Sort mode + buckets + media-id prefixes + wiring (Tasks 6-9, 12) | PR 3 | base = PR 2 branch. New `_ord`/`_bucket` prefixes, quality comparator, bucket partitioning, page-0 mode entries. |
## Phase 1: Foundation — Model & Native Metadata Surface
- [x] 1.1 RED: `test/modelos/pista_local_test.dart` (new file) — construct `MetadatosPista` with all fields null; assert no throw, all getters return `null`.
- [x] 1.2 GREEN: `lib/modelos/pista_local.dart` — add `MetadatosPista` DTO (`titulo`, `artista`, `artUri` as `String?`; `bitrate`, `sampleRate` as `int?`), all-nullable const constructor.
- [x] 1.3 GREEN: extend `PistaLocal` (`lib/modelos/pista_local.dart:29-46`) with `artista`, `embeddedArtUri`, `bitrate`, `sampleRate` fields (nullable, default `null`), update doc comment (remove stale "Phase 1 minimal shape" note).
- [x] 1.4 Native (static-review-only, thin): `MainActivity.kt` — add `"readAudioMetadataBatch"` case to the `file_actions` handler (after `"hasPersistedPermission"`, `MainActivity.kt:307-313`), extracting `treeUri: String` + `documentIds: List<String>` args, delegating to a new private `readAudioMetadataBatch(treeUri, documentIds): List<Map<String, Any?>>`.
- [x] 1.5 Native (static-review-only): implement `readAudioMetadataBatch` — per-docId `MediaMetadataRetriever` extract (`METADATA_KEY_TITLE`, `_ARTIST`, `_BITRATE`, `getEmbeddedPicture()`; `METADATA_KEY_SAMPLERATE` gated `Build.VERSION.SDK_INT >= 31` per ADR-5), each entry wrapped in its own try/catch -> all-null-but-`documentId` row on failure, `retriever.release()` in `finally`, whole-call try/catch -> `[]`; never throws across the channel boundary (mirrors `listAudioChildren`/`resolvePlayableUri` shape, `MainActivity.kt:373-426`).
- [x] 1.6 Native (static-review-only): embedded-art cache write — inside the same extract loop, when `getEmbeddedPicture()` is non-null, write bytes to `cacheDir/pluriwave_art/<hash(documentId)>` (skip write if file already exists), return `content://${applicationId}.fileprovider/cache/pluriwave_art/<hash>` (reuses `AndroidManifest.xml:97-102` authority + `pluriwave_file_paths.xml:6-8` `cache-path path="."` — confirmed present, zero manifest changes needed).
- [x] 1.7 Native (static-review-only): after each art write, trim `pluriwave_art/` by `lastModified` (oldest first) while `count > 256 OR totalBytes > 32MB`.
- [x] 1.8 Flag clearly in PR description: Tasks 1.4-1.7 are Kotlin, static-review-only per project precedent (no build/DHU here, mirrors `listAudioChildren`/`resolvePlayableUri`/`pickMusicFolder` review treatment).
## Phase 2: Metadata Cache & Async `itemsLocales` Conversion (load-bearing)
- [x] 2.1 RED: `test/servicios/musica_local_auto_test.dart``CacheMetadatosSesion` group: store 256 entries then a 257th, assert the least-recently-*accessed* entry (not just least-recently-inserted) is evicted; assert `obtener()` on a hit refreshes recency order.
- [x] 2.2 GREEN: `lib/servicios/musica_local_auto.dart` — add `CacheMetadatosSesion` (flat `LinkedHashMap<String, MetadatosPista>`, bound 256, LRU-by-access: `obtener` re-inserts on hit, `guardar` evicts `entries.first.key` when `length > 256` after insert).
- [x] 2.3 RED: `musica_local_auto_test.dart``FuenteMusicaLocalAutoImpl.metadatosDe` group: empty `documentIds` -> `{}` without a channel call; channel throws -> `{}` (never propagates); a native null/missing field in a row -> that key's `MetadatosPista` has the corresponding field `null`, not a crash.
- [x] 2.4 GREEN: `lib/servicios/musica_local_auto.dart` — add `metadatosDe(List<String> documentIds)` to `FuenteMusicaLocalAuto` interface (per design contract) and `FuenteMusicaLocalAutoImpl`: try/catch-wrapped `readAudioMetadataBatch` invocation (same pattern as `hijos`, `musica_local_auto.dart:169-189`), map rows to `Map<String, MetadatosPista>` keyed by echoed `documentId`.
- [x] 2.5 RED: `test/servicios/navegacion_auto_test.dart` — extend the `ConstructorArbolAuto.itemsLocales` group with a metadata-resolution spy test mirroring the existing call-count invariant test (`navegacion_auto_test.dart:493-551`): 200 nodes, a fake `metadatosDe` that records the exact `documentIds` list it received; assert on page 0 it receives EXACTLY the 50 page docIds (not all 200), and page 3 receives exactly the trailing 50 — proves the resolve-only-the-page invariant holds through the async conversion.
- [x] 2.6 RED: `navegacion_auto_test.dart` — metadata-present case: a node whose docId resolves to a `MetadatosPista` with `titulo`/`artUri` set -> built `MediaItem.title`/`artUri` reflect the metadata, not the filename/placeholder.
- [x] 2.7 RED: `navegacion_auto_test.dart` — metadata-absent/failed case: docId not present in the resolved map (or `metadatosDe` returns `{}` entirely) -> `MediaItem.title` falls back to `_tituloDesdeNombre`, `artUri` falls back to `artUriLocal` (exactly Phase 1 behavior) — no exception.
- [x] 2.8 GREEN: `lib/servicios/navegacion_auto.dart` — convert `itemsLocales` (`navegacion_auto.dart:363-378`) to `Future<List<MediaItem>>`: after `paginaDe` slices the page (unchanged, still cheap), call `fuente.metadatosDe(paginaActual.where((n) => !n.esDirectorio).map((n) => n.documentId).toList())` for ONLY the sliced page's track docIds, then map via an async-aware `construirItem` (keep `@visibleForTesting` injection point for the spy test) that consults the resolved map before falling back to filename/placeholder. Preserve the exact `sort -> paginaDe -> map(construir)` ordering (slice BEFORE metadata fetch, metadata fetch BEFORE `MediaItem` build).
- [x] 2.9 GREEN: `lib/servicios/navegacion_auto.dart` — update `_itemLocal` (`navegacion_auto.dart:380-391`) to accept the resolved `Map<String, MetadatosPista>` (or become instance-scoped per call), building title/artUri/subtitle from metadata when present, falling back to Phase 1 logic (`_tituloDesdeNombre`, `artUriLocal`) when absent — folders (`esDirectorio`) are unaffected (no metadata lookup for directories).
- [x] 2.10 GREEN: `lib/servicios/navegacion_auto.dart` — update `hijosMusicaLocal` (`navegacion_auto.dart:529-558`) call site: `await constructor.itemsLocales(...)` (already inside an `async` function and already implicitly compatible since the call wasn't previously awaited — now becomes a real `await`).
- [x] 2.11 Regression: run full `test/servicios/navegacion_auto_test.dart` + `test/servicios/musica_local_auto_test.dart` suites; every existing `itemsLocales`/`construirItem` call site in both test files (Tasks reference: 25+ call sites per `navegacion_auto_test.dart:493-851` grep) must be updated to `await` the now-`Future` call — confirm no other production call site exists (only `hijosMusicaLocal` calls `itemsLocales`; verified via `Grep` in this session, single caller).
- [x] 2.12 Regression: confirm Phase 1 scenarios (empty subfolder, folder browse, playback resolution, art fallback) and the existing paging spy test (`navegacion_auto_test.dart:493-551`, adapted for the new async signature) still pass unchanged in behavior.
## Phase 3: Quality Sort, Name Buckets, Media-ID Wiring
- [x] 3.1 RED: `navegacion_auto_test.dart` — quality-sort comparator: tracks with known bitrate sort descending; a track with `bitrate: null` sorts after all known-bitrate tracks, never throws.
- [x] 3.2 GREEN: `lib/servicios/navegacion_auto.dart` — add quality comparator for `PistaLocal`/`MetadatosPista` bitrate desc, reusing `OrdenEmisoras.calidad`'s shape (`lib/estado/orden_emisoras.dart:14`) as the mirrored pattern (no code sharing forced — different types).
- [x] 3.3 RED: `navegacion_auto_test.dart``_maxPistasParaOrdenCalidad` boundary: folder with 149 tracks -> quality entry present; 150 -> present; 151 -> quality entry OMITTED from page-0 mode entries.
- [x] 3.4 GREEN: `lib/servicios/navegacion_auto.dart` — add `static const _maxPistasParaOrdenCalidad = 150`; quality-sort path batch-parses ALL folder tracks via `metadatosDe` (not page-scoped — full-folder, per ADR-3), sorts bitrate desc, caches, then applies existing `paginaDe`.
- [x] 3.5 RED: `test/servicios/navegacion_auto_test.dart` (new group `bucketsDe`) — partitioning: tracks named across multiple letters split into contiguous alphabetical buckets (e.g. A-F/G-M/...); a bucket with zero matches returns `[]` not an error; partitioning uses ONLY `NodoLocal.nombre` (metadata-free) — assert via a spy that `metadatosDe` is never called for bucket partitioning itself.
- [x] 3.6 GREEN: `lib/servicios/navegacion_auto.dart` — implement `bucketsDe(List<NodoLocal>)` pure-Dart, name-only partitioning (no metadata dependency), only offered when folder track count > 50 (design ADR-4).
- [x] 3.7 RED: `navegacion_auto_test.dart` — media-id encode/decode round-trip for `carpeta_local_ord:<modo>:<pagina>:<docId>` and `carpeta_local_bucket:<idxBucket>:<pagina>:<docId>`, including a docId containing `:`/`/` surviving verbatim (split-on-first-colon-after-fixed-fields chain, mirroring `paginaCarpetaLocalDesde`, `navegacion_auto.dart:319-325`).
- [x] 3.8 RED: `navegacion_auto_test.dart` — collision guards: assert `esCarpetaLocalOrdMediaId`/`esCarpetaLocalBucketMediaId` never both match the same id, and neither matches any of the other 4 existing prefixes (`emisora:`, `grupo:`, `eq_preset:`, `carpeta_local:`, `carpeta_local_pag:`, `pista:`) for representative sample ids of each.
- [x] 3.9 GREEN: `lib/servicios/navegacion_auto.dart` — add `_prefijoCarpetaLocalOrd = 'carpeta_local_ord:'`, `_prefijoCarpetaLocalBucket = 'carpeta_local_bucket:'` constants + `esCarpetaLocalOrdMediaId`/`esCarpetaLocalBucketMediaId` + decode helpers (mirroring `paginaCarpetaLocalDesde`'s split-on-first-colon-after-fixed-fields pattern for the extra `modo`/`idxBucket` field).
- [x] 3.10 RED: `navegacion_auto_test.dart` — page-0 mode entries: a folder with <=150 tracks and >50 tracks returns BOTH a quality-sort entry AND bucket entries prepended before the name-sorted list on page 0 only (mirrors `carpetasFavoritos` prepend precedent, `navegacion_auto.dart:416-430`); page >0 never re-prepends them.
- [x] 3.11 GREEN: `lib/servicios/navegacion_auto.dart` — wire mode/bucket entries into `itemsLocales`/`hijosMusicaLocal` on page 0 only, hardcoded Spanish labels ("Ordenar por calidad", bucket range labels e.g. "A-F") — matching the established car-tree precedent (`_tituloMasLocal = 'Más…'`, `_carpeta(idFavoritos, 'Favoritos')`, none of which go through `AppLocalizations`). **Do NOT add new keys to `lib/l10n/*.arb`** — these are car-tree-only labels, not phone UI; the only existing local-music `AppLocalizations` key (`localMusicFolderGenericName`) is phone-settings-only (`pantalla_ajustes.dart:366`), confirming the precedent split. If a genuinely new PHONE-facing string is introduced (none identified in spec/design as of this task pass), scope it into ALL 13 `lib/l10n/*.arb` files, not just en/es.
- [x] 3.12 GREEN: `lib/servicios/navegacion_auto.dart` — route the new `_ord`/`_bucket` media ids through `hijosMusicaLocal`'s dispatch (alongside existing `carpeta_local:`/`carpeta_local_pag:` branches, `navegacion_auto.dart:536-546`).
## Phase 4: Art Fallback & Final Regression
- [x] 4.1 RED: `navegacion_auto_test.dart` — art fallback matrix: cache-miss (native returns `artUri: null`) -> placeholder; parse-failure (metadata entry all-null for that docId) -> placeholder; never an empty/broken tile in any case.
- [x] 4.2 GREEN: confirm `_itemLocal`'s (Task 2.9) fallback branch already covers 4.1 — no new production code expected, this task is verification-only; if a gap is found, fix in `navegacion_auto.dart`.
- [x] 4.3 Full regression: run entire `test/servicios/navegacion_auto_test.dart` + `test/servicios/musica_local_auto_test.dart` + new `test/modelos/pista_local_test.dart` suites; confirm Phase 1 scenarios and paging invariant (Task 2.12) remain green.
- [x] 4.4 Deviated/manual-follow-up (not executable here, same convention as prior changes): `flutter analyze`, `flutter test --coverage`, `flutter gen-l10n` (only if 3.11's l10n gap is ever confirmed) — run manually before merge, not part of this task list's automated scope.
- [x] 4.5 Deviated/manual-follow-up: on-device hardware validation of `_maxPistasParaOrdenCalidad = 150` and the 256-file/32MB art budget (design "Open Questions") — no DHU/emulator coverage in this task list.
## Requirement Traceability
| Spec Requirement | Tasks |
|---|---|
| Local Music Browsable Tree (metadata title/art) | 1.1-1.7, 2.1-2.12, 4.1-4.3 |
| Local Music Sort Mode Navigation | 3.1-3.4, 3.9-3.12 |
| Local Music Alphabetical Name Buckets | 3.5-3.6, 3.9-3.12 |
| Local Track Embedded Album Art Display | 1.6-1.7, 2.6-2.9, 4.1-4.2 |