diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 2c72ae9..fc54275 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -8,11 +8,10 @@ _(nothing active — pick the next batch from below)_ ## Deferred / future refinements -- [ ] **P13c-2 — opt-in auto-tag-on-download.** The sibling of P13a-2/P13b-3: an opt-in (default-off) setting - to suggest tags for new downloads in the background. Unlike the passive summary/OCR enrichments, tags are - **user-curated** (they drive facets), so this should likely **suggest + post a "review tags" Activity - Inbox nudge** (or write into a marked "ai" namespace) rather than silently auto-apply LLM tags — decide - the apply-vs-review behaviour when planning it. *(From P13c.)* +- [ ] **Library "hide / filter AI tags" facet.** P13c-2 marks AI-applied tags (`media_tags.source = 'ai'`) + and shows a ✦ on their chips, but the library tag facet (`watchDistinctTags`) treats them like any tag. + Add a "hide AI tags" / "AI-tagged only" filter (and maybe a bulk "remove all AI tags on this item") if + auto-tagging proves noisy. Also: promote an AI tag to 'user' when the user re-adds/keeps it. *(From P13c-2.)* - [ ] **AI tag casing/normalization.** `addTagToItem` trims but doesn't lowercase, so P13c's AI suggestions are lowercased while manual/graph tags keep their case (e.g. `Live` vs `live` can coexist). Consider a single normalization policy (case-fold on store, or a display-case + fold-key) if duplicate-case tags diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index a4c777a..fc4f115 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -977,6 +977,14 @@ entries, or verify after P11c lands.)* - [ ] On a **low-end** device the AI row is **absent** (the graph co-occurrence "Suggested" chips still work); with generation **not enabled**, the button routes to **AI settings** (on-ramp). +### P13c-2 — Auto-tag on download *(install `app-arm64-v8a-debug.apk`; needs a capable device)* +- [ ] AI & graph settings → with generation enabled, the **Auto-tag new downloads** toggle is visible; enable + it. Finish a download (item with description/transcript) → sensible **AI tags are applied** + an + Activity Inbox entry ("N tags added"), **fully offline**. The tags show a **✦ marker** in the editor + + on item detail, and appear as **search facets**; any AI tag can be **deleted**. +- [ ] A **manually-added** tag has no marker. **Default off:** downloads aren't auto-tagged; with generation + off there's a one-time "finish setting up auto-tagging" nudge; the queue still drains. + ### P13 (later subphases) - [ ] **Transcription / summarization / translation / OCR** each work (capability-gated) and write results back to the item. diff --git a/docs/design/P13-PLAN.md b/docs/design/P13-PLAN.md index 85c32b2..9206be6 100644 --- a/docs/design/P13-PLAN.md +++ b/docs/design/P13-PLAN.md @@ -194,6 +194,25 @@ LLM-suggested tags feeding the **existing** tag system — builds directly on th (low hides / high shows). **Pending APK spot-check** (generate + apply on a real item offline). The generate→chips flow is APK-verified. Background **auto-tag-on-download is a deliberate follow-up (P13c-2)**. +#### `[~]` P13c-2 — Opt-in auto-tag on download (marked AI) *(generation; APK)* +Follow-up: the LLM **applies** tags to new downloads in the background, opt-in (default off). Because tags are +user-curated (they drive facets), AI tags are **marked** (provenance) rather than silently mixed in. +- **Schema v12→v13:** `MediaTags.source` (`withDefault('user')`); `addTagToItem(…, {source})` (insertOrIgnore + keeps an existing link's source → user tags never demoted); `watchAiTagNamesForItem` + provider. +- **Settings** `autoTagOnDownload` (default off) + setter; `_AutoTagTile` in the generation card. +- **Pure** `autoTagDecision` (skip/needsModel/tag). **Queue** (`_persistCompleted`, after auto-OCR): build + source from title + description/transcript/`ocrText`, `buildTagPrompt` → `generate` → `parseTagSuggestions` + (exclude applied) → `addTagToItem(source: 'ai')`; `tagCount`/`tagNeedsModel` in `_PersistResult`; an `ai` + inbox entry ("N tags added") or a "finish setup" nudge. +- **Marking:** the editor + item-detail tag chips show `auto_awesome_outlined` on AI-sourced tags (via + `aiTagNamesForItemProvider`); AI tags appear as normal search facets. +- **Status:** implemented (CI-green) — schema v13 (+ a defensive table-guard in the migration, mirroring the + v8 guard-add spirit, so partial older test DBs don't break); repo provenance + provider; settings + tile; + `autoTagDecision`; queue block + two inbox posts; chip marking in both surfaces. Tests: `autoTagDecision`, + repo provenance (only-ai set; user not demoted), v12→v13 migration, settings round-trip, queue (applied as + 'ai' + entry; default-off no-op). **No deps.** **Pending APK spot-check** (real download → AI-marked tags + + facets, offline). A library "hide/filter AI tags" facet is deferred (BACKLOG). + ### `[ ]` P13d — Local GraphRAG "Ask your library" *(flagship; split into 3 PRs)* The headline differentiator — natural-language Q&A grounded in the private library, fully on-device (AI-SPEC §6, GRAPH-SPEC §7). Sequenced **mid-phase** so the generation patterns (P13a/c) are proven first. diff --git a/lib/core/db/database.dart b/lib/core/db/database.dart index 983d007..ba95293 100644 --- a/lib/core/db/database.dart +++ b/lib/core/db/database.dart @@ -93,6 +93,9 @@ class MediaTags extends Table { text().references(MediaItems, #id, onDelete: KeyAction.cascade)(); IntColumn get tagId => integer().references(Tags, #id, onDelete: KeyAction.cascade)(); + // P13c-2: provenance of this tag link — 'user' (manual/graph) or 'ai' + // (auto-applied on download). Lets AI tags be marked + managed distinctly. + TextColumn get source => text().withDefault(const Constant('user'))(); @override Set> get primaryKey => {itemId, tagId}; @@ -217,7 +220,7 @@ class AppDatabase extends _$AppDatabase { : super(executor ?? driftDatabase(name: 'grabbit')); @override - int get schemaVersion => 12; + int get schemaVersion => 13; @override MigrationStrategy get migration => MigrationStrategy( @@ -286,6 +289,16 @@ class AppDatabase extends _$AppDatabase { } await customStatement('DROP TABLE IF EXISTS media_fts'); } + if (from < 13) { + // P13c-2: tag provenance ('user' default; 'ai' for auto-applied tags). + // Defensive table guard (mirrors the v8 guard-add spirit): a no-op if + // media_tags somehow isn't present yet. + final hasMediaTags = (await customSelect( + "SELECT 1 FROM sqlite_master WHERE type='table' " + "AND name='media_tags'", + ).get()).isNotEmpty; + if (hasMediaTags) await m.addColumn(mediaTags, mediaTags.source); + } await _createIndices(); await _createFtsObjects(); }, diff --git a/lib/features/library/data/metadata_repository.dart b/lib/features/library/data/metadata_repository.dart index f41ff0e..8cb0d99 100644 --- a/lib/features/library/data/metadata_repository.dart +++ b/lib/features/library/data/metadata_repository.dart @@ -718,7 +718,15 @@ class MetadataRepository { return [for (final r in rows) r.readTable(_db.tags).name]; } - Future addTagToItem(String itemId, String name) async { + /// Applies tag [name] to [itemId]. [source] records provenance — `'user'` + /// (manual/graph) or `'ai'` (auto-applied, P13c-2). `insertOrIgnore` means an + /// existing link keeps its original source, so a user-applied tag is never + /// demoted to `'ai'`. + Future addTagToItem( + String itemId, + String name, { + String source = 'user', + }) async { final clean = name.trim(); if (clean.isEmpty) return; await _db @@ -733,11 +741,31 @@ class MetadataRepository { await _db .into(_db.mediaTags) .insert( - MediaTagsCompanion.insert(itemId: itemId, tagId: tag.id), + MediaTagsCompanion.insert( + itemId: itemId, + tagId: tag.id, + source: Value(source), + ), mode: InsertMode.insertOrIgnore, ); } + /// The names of an item's **AI-applied** tags (P13c-2) — for marking them + /// distinctly in the UI. Reactive. + Stream> watchAiTagNamesForItem(String itemId) { + final query = + _db.select(_db.tags).join([ + innerJoin(_db.mediaTags, _db.mediaTags.tagId.equalsExp(_db.tags.id)), + ])..where( + _db.mediaTags.itemId.equals(itemId) & + _db.mediaTags.source.equals('ai'), + ); + return query + .map((row) => row.readTable(_db.tags).name) + .watch() + .map((names) => names.toSet()); + } + Future removeTagFromItem(String itemId, int tagId) async { await (_db.delete( _db.mediaTags, @@ -822,6 +850,12 @@ final tagsForItemProvider = StreamProvider.family, String>( ref.watch(metadataRepositoryProvider).watchTagsForItem(itemId), ); +/// Names of an item's AI-applied tags (P13c-2), for marking them in the UI. +final aiTagNamesForItemProvider = StreamProvider.family, String>( + (ref, itemId) => + ref.watch(metadataRepositoryProvider).watchAiTagNamesForItem(itemId), +); + final collectionsProvider = StreamProvider>( (ref) => ref.watch(metadataRepositoryProvider).watchCollections(), ); diff --git a/lib/features/library/presentation/item_detail_screen.dart b/lib/features/library/presentation/item_detail_screen.dart index 547563c..2e1aff6 100644 --- a/lib/features/library/presentation/item_detail_screen.dart +++ b/lib/features/library/presentation/item_detail_screen.dart @@ -1646,6 +1646,9 @@ class _TagsRow extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final tokens = GrabBitTokens.of(context); final tags = ref.watch(tagsForItemProvider(itemId)); + final aiTags = + ref.watch(aiTagNamesForItemProvider(itemId)).asData?.value ?? + const {}; return tags.maybeWhen( data: (list) => list.isEmpty ? const SizedBox.shrink() @@ -1657,6 +1660,9 @@ class _TagsRow extends ConsumerWidget { children: [ for (final t in list) ActionChip( + avatar: aiTags.contains(t.name) + ? const Icon(Icons.auto_awesome_outlined, size: 16) + : null, label: Text(t.name), onPressed: () => _openHub(context, 'tag', t.name, t.name), ), diff --git a/lib/features/library/presentation/metadata_edit_screen.dart b/lib/features/library/presentation/metadata_edit_screen.dart index bc594ec..1d784f6 100644 --- a/lib/features/library/presentation/metadata_edit_screen.dart +++ b/lib/features/library/presentation/metadata_edit_screen.dart @@ -125,6 +125,9 @@ class _TagsEditor extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final tokens = GrabBitTokens.of(context); final tags = ref.watch(tagsForItemProvider(itemId)); + final aiTags = + ref.watch(aiTagNamesForItemProvider(itemId)).asData?.value ?? + const {}; final repo = ref.read(metadataRepositoryProvider); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -148,6 +151,12 @@ class _TagsEditor extends ConsumerWidget { children: [ for (final tag in list) Chip( + avatar: aiTags.contains(tag.name) + ? const Icon( + Icons.auto_awesome_outlined, + size: 16, + ) + : null, label: Text(tag.name), onDeleted: () => repo.removeTagFromItem(itemId, tag.id), diff --git a/lib/features/library/presentation/tag_suggestions.dart b/lib/features/library/presentation/tag_suggestions.dart index d6a2ad1..1d59e23 100644 --- a/lib/features/library/presentation/tag_suggestions.dart +++ b/lib/features/library/presentation/tag_suggestions.dart @@ -56,3 +56,28 @@ List parseTagSuggestions( } return out; } + +/// What auto-tag-on-download (P13c-2) should do for a freshly downloaded item, +/// assuming the feature + generation are opted in. A pure decision so the queue +/// path is testable (mirrors `autoSummaryDecision`). +enum AutoTagDecision { + /// Nothing to tag (no source text). + skip, + + /// Would tag, but the generation model isn't downloaded — nudge once. + needsModel, + + /// Generate + apply tags now (model ready). + tag, +} + +/// [hasText] is whether the item has title/description/transcript/OCR to tag; +/// [modelReady] is whether the generation model is downloaded (`ensureReady` — +/// no fetch). +AutoTagDecision autoTagDecision({ + required bool hasText, + required bool modelReady, +}) { + if (!hasText) return AutoTagDecision.skip; + return modelReady ? AutoTagDecision.tag : AutoTagDecision.needsModel; +} diff --git a/lib/features/queue/presentation/queue_controller.dart b/lib/features/queue/presentation/queue_controller.dart index 0e61a3e..f40ac06 100644 --- a/lib/features/queue/presentation/queue_controller.dart +++ b/lib/features/queue/presentation/queue_controller.dart @@ -28,6 +28,7 @@ import 'package:grabbit/features/library/data/metadata_repository.dart'; import 'package:grabbit/features/library/data/transcript_service.dart'; import 'package:grabbit/features/library/presentation/ai_summary.dart'; import 'package:grabbit/features/library/presentation/ocr.dart'; +import 'package:grabbit/features/library/presentation/tag_suggestions.dart'; import 'package:grabbit/features/notifications/data/notification_enums.dart'; import 'package:grabbit/features/notifications/data/notifications_repository.dart'; import 'package:grabbit/features/notifications/data/system_notification_service.dart'; @@ -55,6 +56,10 @@ typedef _PersistResult = ({ bool summaryNeedsModel, // P13b-3: count of image items auto-scanned for text (OCR). int ocrCount, + // P13c-2: count of items auto-tagged, and whether auto-tag is opted in but + // the generation model isn't downloaded → prompt to finish setup. + int tagCount, + bool tagNeedsModel, }); class QueueConfig { @@ -436,6 +441,31 @@ class QueueController extends _$QueueController { dedupeKey: 'ocr_$id', ); } + // P13c-2: auto-tag applied AI tags to the new download. + if (result.tagCount > 0) { + await center.post( + category: NotificationCategory.ai, + severity: NotificationSeverity.success, + title: queued.title, + body: result.tagCount > 1 + ? '${result.tagCount} tags added' + : 'Tag added', + targetRoute: route, + itemId: single ? result.primaryId : null, + dedupeKey: 'tags_$id', + ); + } + // P13c-2: opted into auto-tagging but the generation model isn't downloaded. + if (result.tagNeedsModel) { + await center.post( + category: NotificationCategory.ai, + severity: NotificationSeverity.info, + title: 'Finish setting up auto-tagging', + body: 'Download a text-generation model to auto-tag new downloads.', + targetRoute: '/settings/ai', + dedupeKey: 'tags_needs_model', + ); + } await _maybeNotifyOs( taskId: id, title: queued.title, @@ -597,6 +627,8 @@ class QueueController extends _$QueueController { summaryCount: 0, summaryNeedsModel: false, ocrCount: 0, + tagCount: 0, + tagNeedsModel: false, ); // Files land in a per-task subfolder (see YtDlpHost `-o`): the task id names // the folder, the user's template names the file inside it. @@ -833,6 +865,60 @@ class QueueController extends _$QueueController { } } + // P13c-2: auto-apply LLM tags (marked 'ai') to freshly downloaded items + // when opted in + a generation model is already downloaded (no surprise + // fetch). Mirrors auto-summarize; per-item failures never fail the download. + var tagCount = 0; + var tagNeedsModel = false; + if (settings.autoTagOnDownload && settings.generationEnabled) { + final generation = ref.read(generationEngineProvider); + final genReady = await generation.ensureReady(); + final metadata = ref.read(metadataRepositoryProvider); + for (final (i, _) in outputs.media.indexed) { + final itemId = single ? id : '${id}__$i'; + final item = await metadata.mediaItemById(itemId); + final meta = await metadata.metadataForItem(itemId); + final src = [ + if (item != null) item.title, + ?meta?.description, + ?meta?.transcript, + ?meta?.ocrText, + ].where((s) => s.trim().isNotEmpty).join('\n'); + switch (autoTagDecision( + hasText: src.trim().isNotEmpty, + modelReady: genReady, + )) { + case AutoTagDecision.skip: + continue; + case AutoTagDecision.needsModel: + tagNeedsModel = true; + continue; + case AutoTagDecision.tag: + try { + final existing = await metadata.tagNamesForItem(itemId); + final p = buildTagPrompt(src); + final buffer = StringBuffer(); + await for (final token in generation.generate( + p.prompt, + systemPrompt: p.systemPrompt, + )) { + buffer.write(token); + } + final tags = parseTagSuggestions( + buffer.toString(), + exclude: existing.toSet(), + ); + for (final t in tags) { + await metadata.addTagToItem(itemId, t, source: 'ai'); + } + tagCount += tags.length; + } catch (_) { + // A per-item tagging failure must not fail the download. + } + } + } + } + return ( primaryId: single ? id : '${id}__0', itemCount: outputs.media.length, @@ -841,6 +927,8 @@ class QueueController extends _$QueueController { summaryCount: summaryCount, summaryNeedsModel: summaryNeedsModel, ocrCount: ocrCount, + tagCount: tagCount, + tagNeedsModel: tagNeedsModel, ); } diff --git a/lib/features/settings/data/settings_model.dart b/lib/features/settings/data/settings_model.dart index c4cb842..9f22348 100644 --- a/lib/features/settings/data/settings_model.dart +++ b/lib/features/settings/data/settings_model.dart @@ -102,6 +102,10 @@ abstract class SettingsModel with _$SettingsModel { // images, on-device + offline (bundled ML Kit, no download). The on-demand // "Scan text" on item detail (P13b-1) works regardless. @Default(false) bool autoOcrOnDownload, + // P13c-2: auto-apply LLM-suggested tags to a newly downloaded item in the + // background (marked as 'ai', deletable). Opt-in (defaults off); runs only + // when text generation is enabled and its model is already downloaded. + @Default(false) bool autoTagOnDownload, // On-device speech transcription (P12e). Opt-in (defaults off); the whisper // model is downloaded only when the user enables it + picks a model. // `selectedTranscriptionModelId` empty = the device-tier recommendation; diff --git a/lib/features/settings/presentation/ai_settings_screen.dart b/lib/features/settings/presentation/ai_settings_screen.dart index 48b017f..93bbfa5 100644 --- a/lib/features/settings/presentation/ai_settings_screen.dart +++ b/lib/features/settings/presentation/ai_settings_screen.dart @@ -635,6 +635,7 @@ class _GenerationCard extends ConsumerWidget { children: [ for (final m in eligible) _GenerationModelTile(model: m), const _AutoSummarizeTile(), + const _AutoTagTile(), const _GenerationSelfTestTile(), ], ), @@ -683,6 +684,46 @@ class _AutoSummarizeTile extends ConsumerWidget { } } +/// Opt-in (P13c-2): auto-apply LLM tags to new downloads in the background. +/// Shown only when text generation is enabled; runs only when a model is +/// downloaded. Auto-applied tags are marked as AI and can be deleted; the +/// on-demand "Suggest tags with AI" in the editor works regardless. +class _AutoTagTile extends ConsumerWidget { + const _AutoTagTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final enabled = ref.watch( + settingsControllerProvider.select( + (s) => s.value?.generationEnabled ?? false, + ), + ); + if (!enabled) return const SizedBox.shrink(); + final auto = ref.watch( + settingsControllerProvider.select( + (s) => s.value?.autoTagOnDownload ?? false, + ), + ); + return SwitchListTile( + secondary: const InfoHintButton( + InfoHint( + title: 'Auto-tag new downloads', + body: + 'Add AI-suggested tags to each new download automatically, in the ' + 'background — all on-device. Runs only when a text-generation ' + 'model is downloaded. AI tags are marked with a ✦ and can be ' + 'deleted; you can always tag an item by hand.', + ), + ), + title: const Text('Auto-tag new downloads'), + subtitle: const Text('Tag each download in the background'), + value: auto, + onChanged: (v) => + ref.read(settingsControllerProvider.notifier).setAutoTagOnDownload(v), + ); + } +} + /// One selectable generation model row: badge (Recommended / size band) + size, /// a radio-like check for the active selection. Selecting it opts in + downloads /// (storage-guarded); selecting the active one again turns generation off. diff --git a/lib/features/settings/presentation/settings_controller.dart b/lib/features/settings/presentation/settings_controller.dart index 8e24d20..161d4ce 100644 --- a/lib/features/settings/presentation/settings_controller.dart +++ b/lib/features/settings/presentation/settings_controller.dart @@ -151,6 +151,10 @@ class SettingsController extends _$SettingsController { Future setAutoOcrOnDownload(bool value) async => _update((await future).copyWith(autoOcrOnDownload: value)); + /// Auto-apply LLM tags to newly downloaded items in the background (P13c-2). + Future setAutoTagOnDownload(bool value) async => + _update((await future).copyWith(autoTagOnDownload: value)); + /// On-device transcription opt-in (P12e). Future setTranscriptionEnabled(bool value) async => _update((await future).copyWith(transcriptionEnabled: value)); diff --git a/test/core/db/database_test.dart b/test/core/db/database_test.dart index 15586ca..d28f643 100644 --- a/test/core/db/database_test.dart +++ b/test/core/db/database_test.dart @@ -9,8 +9,8 @@ void main() { setUp(() => db = AppDatabase(NativeDatabase.memory())); tearDown(() => db.close()); - test('opens at schema version 12 with all tables created', () async { - expect(db.schemaVersion, 12); + test('opens at schema version 13 with all tables created', () async { + expect(db.schemaVersion, 13); // Forces onCreate (createAll) + beforeOpen to run. final tableNames = db.allTables.map((t) => t.actualTableName).toSet(); @@ -1035,6 +1035,96 @@ void main() { }, ); + test( + 'upgrades a v12 database to v13, adding media_tags.source (P13c-2)', + () async { + // Seed a minimal v12 DB with a media_tags row (no `source` column), + // user_version=12. Opening at v13 must add `source` defaulting 'user'. + final upgraded = AppDatabase( + NativeDatabase.memory( + setup: (raw) { + raw.execute(''' + CREATE TABLE media_items ( + id TEXT NOT NULL PRIMARY KEY, title TEXT NOT NULL, + source_url TEXT NOT NULL, site TEXT NOT NULL, + file_path TEXT NOT NULL, type TEXT NOT NULL, duration_sec INTEGER, + size_bytes INTEGER, width INTEGER, height INTEGER, thumb_path TEXT, + created_at INTEGER NOT NULL, storage_state TEXT NOT NULL, notes TEXT, + folder_id INTEGER, is_favorite INTEGER NOT NULL DEFAULT 0, + content_hash TEXT, last_accessed_at INTEGER + )'''); + raw.execute(''' + CREATE TABLE media_metadata ( + item_id TEXT NOT NULL PRIMARY KEY REFERENCES media_items (id), + uploader TEXT, upload_date INTEGER, description TEXT, + original_url TEXT, uploader_id TEXT, channel_id TEXT, source_id TEXT, + playlist_id TEXT, playlist_title TEXT, tags TEXT, transcript TEXT, + transcript_cues TEXT, ai_summary TEXT, ai_summary_model_id TEXT, + ocr_text TEXT + )'''); + raw.execute( + 'CREATE TABLE tags (id INTEGER NOT NULL PRIMARY KEY ' + 'AUTOINCREMENT, name TEXT NOT NULL UNIQUE)', + ); + // v12 media_tags — NO `source` column. + raw.execute(''' + CREATE TABLE media_tags ( + item_id TEXT NOT NULL REFERENCES media_items (id), + tag_id INTEGER NOT NULL REFERENCES tags (id), + PRIMARY KEY (item_id, tag_id) + )'''); + // Tables the migration tail (_createIndices) touches. + raw.execute(''' + CREATE TABLE notifications ( + id TEXT NOT NULL PRIMARY KEY, category TEXT NOT NULL, + severity TEXT NOT NULL, title TEXT NOT NULL, body TEXT, + target_route TEXT, item_id TEXT, task_id TEXT, dedupe_key TEXT, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, + read_at INTEGER, expires_at INTEGER, + coalesce_count INTEGER NOT NULL DEFAULT 1 + )'''); + raw.execute( + 'INSERT INTO media_items (id, title, source_url, site, ' + 'file_path, type, created_at, storage_state) VALUES ' + "('old1', 'Clip', 'u', 's', '/m/o', 'video', 0, 'private')", + ); + raw.execute("INSERT INTO tags (id, name) VALUES (1, 'legacy')"); + raw.execute( + "INSERT INTO media_tags (item_id, tag_id) VALUES ('old1', 1)", + ); + raw.execute('PRAGMA user_version = 12'); + }, + ), + ); + addTearDown(upgraded.close); + + // The pre-existing media_tag row defaults to 'user'. + final rows = await upgraded.select(upgraded.mediaTags).get(); + expect(rows.single.source, 'user'); + + // A new AI-sourced link round-trips. + await upgraded + .into(upgraded.tags) + .insert(TagsCompanion.insert(name: 'rock')); + final rock = await (upgraded.select( + upgraded.tags, + )..where((t) => t.name.equals('rock'))).getSingle(); + await upgraded + .into(upgraded.mediaTags) + .insert( + MediaTagsCompanion.insert( + itemId: 'old1', + tagId: rock.id, + source: const Value('ai'), + ), + ); + final ai = await (upgraded.select( + upgraded.mediaTags, + )..where((t) => t.source.equals('ai'))).get(); + expect(ai.single.tagId, rock.id); + }, + ); + test( 'addColumnIfMissing is idempotent and adds only absent columns', () async { diff --git a/test/features/library/item_detail_screen_test.dart b/test/features/library/item_detail_screen_test.dart index 65ce252..9c781a9 100644 --- a/test/features/library/item_detail_screen_test.dart +++ b/test/features/library/item_detail_screen_test.dart @@ -47,6 +47,9 @@ void main() { 'x', ).overrideWith((ref) => Stream.value(metadata)), tagsForItemProvider('x').overrideWith((ref) => Stream.value([])), + aiTagNamesForItemProvider( + 'x', + ).overrideWith((ref) => Stream.value({})), collectionsForItemProvider( 'x', ).overrideWith((ref) => Stream.value([])), @@ -116,6 +119,9 @@ void main() { tagsForItemProvider( 'x', ).overrideWith((ref) => Stream.value([])), + aiTagNamesForItemProvider( + 'x', + ).overrideWith((ref) => Stream.value({})), collectionsForItemProvider( 'x', ).overrideWith((ref) => Stream.value([])), @@ -152,6 +158,9 @@ void main() { tagsForItemProvider('x').overrideWith( (ref) => Stream.value([const Tag(id: 1, name: 'funny')]), ), + aiTagNamesForItemProvider( + 'x', + ).overrideWith((ref) => Stream.value({})), collectionsForItemProvider( 'x', ).overrideWith((ref) => Stream.value([])), @@ -183,6 +192,9 @@ void main() { tagsForItemProvider( 'x', ).overrideWith((ref) => Stream.value([])), + aiTagNamesForItemProvider( + 'x', + ).overrideWith((ref) => Stream.value({})), collectionsForItemProvider('x').overrideWith( (ref) => Stream.value([ Collection(id: 7, name: 'Faves', createdAt: DateTime.utc(2026)), diff --git a/test/features/library/metadata_edit_screen_test.dart b/test/features/library/metadata_edit_screen_test.dart index 25abe78..2908a90 100644 --- a/test/features/library/metadata_edit_screen_test.dart +++ b/test/features/library/metadata_edit_screen_test.dart @@ -44,6 +44,9 @@ void main() { mediaItemByIdProvider('x').overrideWith((ref) => _item()), // Finite stubs so the live Drift watch streams don't stall the test. tagsForItemProvider('x').overrideWith((ref) => Stream.value([])), + aiTagNamesForItemProvider( + 'x', + ).overrideWith((ref) => Stream.value({})), collectionsProvider.overrideWith( (ref) => Stream.value([]), ), @@ -85,6 +88,9 @@ void main() { tagsForItemProvider( 'x', ).overrideWith((ref) => Stream.value([])), + aiTagNamesForItemProvider( + 'x', + ).overrideWith((ref) => Stream.value({})), collectionsProvider.overrideWith( (ref) => Stream.value([]), ), @@ -123,6 +129,9 @@ void main() { appDatabaseProvider.overrideWithValue(db), mediaItemByIdProvider('x').overrideWith((ref) => _item()), tagsForItemProvider('x').overrideWith((ref) => Stream.value([])), + aiTagNamesForItemProvider( + 'x', + ).overrideWith((ref) => Stream.value({})), collectionsProvider.overrideWith( (ref) => Stream.value([]), ), diff --git a/test/features/library/metadata_repository_test.dart b/test/features/library/metadata_repository_test.dart index b9c447b..ebba96d 100644 --- a/test/features/library/metadata_repository_test.dart +++ b/test/features/library/metadata_repository_test.dart @@ -480,6 +480,34 @@ void main() { expect(meta.ocrText, isNull); }); + test( + 'tag provenance: watchAiTagNamesForItem returns only ai tags (P13c-2)', + () async { + await seed('a', 'Clip', 'video'); + await repo.addTagToItem('a', 'rock', source: 'ai'); + await repo.addTagToItem('a', 'jazz'); // default 'user' + + final ai = await repo.watchAiTagNamesForItem('a').first; + expect(ai, {'rock'}); + }, + ); + + test( + 'tag provenance: a user tag is not demoted when AI re-adds it (P13c-2)', + () async { + await seed('a', 'Clip', 'video'); + await repo.addTagToItem('a', 'blues'); // user + await repo.addTagToItem( + 'a', + 'blues', + source: 'ai', + ); // insertOrIgnore → kept user + + final ai = await repo.watchAiTagNamesForItem('a').first; + expect(ai, isEmpty); + }, + ); + test('findItemByUrl matches with tracking params stripped (P9b-4)', () async { await db .into(db.mediaItems) diff --git a/test/features/library/tag_suggestions_test.dart b/test/features/library/tag_suggestions_test.dart index a21b7d1..ea3dfec 100644 --- a/test/features/library/tag_suggestions_test.dart +++ b/test/features/library/tag_suggestions_test.dart @@ -49,4 +49,25 @@ void main() { expect(parseTagSuggestions('"rock", \'live\''), ['rock', 'live']); }); }); + + group('autoTagDecision (P13c-2)', () { + test('no text → skip', () { + expect( + autoTagDecision(hasText: false, modelReady: true), + AutoTagDecision.skip, + ); + }); + test('text + model not ready → needs model', () { + expect( + autoTagDecision(hasText: true, modelReady: false), + AutoTagDecision.needsModel, + ); + }); + test('text + model ready → tag', () { + expect( + autoTagDecision(hasText: true, modelReady: true), + AutoTagDecision.tag, + ); + }); + }); } diff --git a/test/features/queue/queue_controller_test.dart b/test/features/queue/queue_controller_test.dart index 6da2efe..2506439 100644 --- a/test/features/queue/queue_controller_test.dart +++ b/test/features/queue/queue_controller_test.dart @@ -30,6 +30,7 @@ import 'package:grabbit/features/notifications/data/system_notification_service. import 'package:grabbit/features/queue/data/foreground_service.dart'; import 'package:grabbit/features/queue/data/queue_repository.dart'; import 'package:grabbit/features/queue/data/queued_download.dart'; +import 'package:grabbit/features/library/data/metadata_repository.dart'; import 'package:grabbit/features/queue/presentation/queue_controller.dart'; import 'package:grabbit/features/settings/presentation/settings_controller.dart'; @@ -806,6 +807,71 @@ void main() { expect(fakeTranscriber.transcribed, isEmpty); // no whisper on a photo }); + // --- P13c-2: auto-tag on download --- + + test( + 'auto-tag: enabled + model ready → AI tags applied + ai entry (P13c-2)', + () async { + await container + .read(settingsControllerProvider.notifier) + .setGenerationEnabled(true); + await container + .read(settingsControllerProvider.notifier) + .setAutoTagOnDownload(true); + fakeGenerator.ready = true; + fakeGenerator.output = 'rock, live'; + final dir = await describedDownload('vid1'); + + await controller.enqueue( + _qd('vid1', outputDir: dir.path, description: 'A live rock set'), + ); + await waitFor(() async => engine.running.contains('vid1')); + engine.complete('vid1'); + await waitFor( + () async => (await repo.byId('vid1'))?.status == TaskStatus.done, + ); + + // Tags applied and marked 'ai'. + final aiTags = await MetadataRepository( + db, + ).watchAiTagNamesForItem('vid1').first; + expect(aiTags, {'rock', 'live'}); + final ai = await (db.select( + db.notifications, + )..where((n) => n.category.equals(NotificationCategory.ai))).get(); + expect(ai, hasLength(1)); + expect(ai.single.severity, NotificationSeverity.success); + }, + ); + + test('auto-tag: default off → no tags, no entry (P13c-2)', () async { + // generationEnabled on, but autoTagOnDownload stays false. + await container + .read(settingsControllerProvider.notifier) + .setGenerationEnabled(true); + fakeGenerator.ready = true; + fakeGenerator.output = 'rock, live'; + final dir = await describedDownload('vid1'); + + await controller.enqueue( + _qd('vid1', outputDir: dir.path, description: 'A live rock set'), + ); + await waitFor(() async => engine.running.contains('vid1')); + engine.complete('vid1'); + await waitFor( + () async => (await repo.byId('vid1'))?.status == TaskStatus.done, + ); + + final aiTags = await MetadataRepository( + db, + ).watchAiTagNamesForItem('vid1').first; + expect(aiTags, isEmpty); + final ai = await (db.select( + db.notifications, + )..where((n) => n.category.equals(NotificationCategory.ai))).get(); + expect(ai, isEmpty); + }); + test('a completed download posts a success activity entry (P11c)', () async { final dir = await Directory.systemTemp.createTemp('grabbit_ntf_done_'); addTearDown(() => dir.delete(recursive: true)); diff --git a/test/features/settings/settings_test.dart b/test/features/settings/settings_test.dart index 5718639..a3cbcae 100644 --- a/test/features/settings/settings_test.dart +++ b/test/features/settings/settings_test.dart @@ -275,7 +275,7 @@ void main() { }); test( - 'auto-process setters default off and persist (P13a-2/P13b-3)', + 'auto-process setters default off and persist (P13a-2/P13b-3/P13c-2)', () async { final db = AppDatabase(NativeDatabase.memory()); addTearDown(db.close); @@ -287,14 +287,17 @@ void main() { final loaded = await container.read(settingsControllerProvider.future); expect(loaded.autoSummarizeOnDownload, isFalse); expect(loaded.autoOcrOnDownload, isFalse); + expect(loaded.autoTagOnDownload, isFalse); final notifier = container.read(settingsControllerProvider.notifier); await notifier.setAutoSummarizeOnDownload(true); await notifier.setAutoOcrOnDownload(true); + await notifier.setAutoTagOnDownload(true); final saved = await SettingsRepository(db).read(); expect(saved.autoSummarizeOnDownload, isTrue); expect(saved.autoOcrOnDownload, isTrue); + expect(saved.autoTagOnDownload, isTrue); }, );