From 6577174a225cdac3fef54908be1d78d21e730fa1 Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Thu, 22 Jan 2026 11:37:05 +1030 Subject: [PATCH] feat: implement basic cooldown, remove old time based one --- docs/todo/scratchpad.md | 13 - src/lib/server/db/migrations.ts | 12 +- .../migrations/031_remove_search_cooldown.ts | 76 +++ .../032_add_filter_id_to_upgrade_runs.ts | 26 + src/lib/server/db/queries/upgradeRuns.ts | 11 +- src/lib/server/upgrades/cooldown.ts | 188 ++++-- src/lib/server/upgrades/logger.ts | 2 +- src/lib/server/upgrades/processor.ts | 39 +- src/lib/server/upgrades/types.ts | 2 +- src/lib/shared/filters.ts | 6 +- src/routes/arr/[id]/upgrades/+page.server.ts | 3 +- .../upgrades/components/FilterSettings.svelte | 57 +- src/tests/upgrades/cooldown.test.ts | 566 ++++++++++++++++++ 13 files changed, 875 insertions(+), 126 deletions(-) create mode 100644 src/lib/server/db/migrations/031_remove_search_cooldown.ts create mode 100644 src/lib/server/db/migrations/032_add_filter_id_to_upgrade_runs.ts create mode 100644 src/tests/upgrades/cooldown.test.ts diff --git a/docs/todo/scratchpad.md b/docs/todo/scratchpad.md index e966ca4..827bef2 100644 --- a/docs/todo/scratchpad.md +++ b/docs/todo/scratchpad.md @@ -2,12 +2,6 @@ # Feedback from Serpahys -- add tags to filters for upgrades -- upradinatorr filter should be default - both when adding new config and when - an arr is added (run a transformation on the database when an arr is added to - add the default config, but keep upgrades disabled) -- cutoff % should be 100 or removed entirely -- cooldown maybe too complicated - cache fetches from github on things that dont change often (images, stats for example) - rethink job polling architecture @@ -165,10 +159,3 @@ Run 50: matched=50, afterCooldown=0 Run N: sleeping, dry run → matched=70 (was 50) WAKE UP! ``` - -# Upgrades Info - -Move info to upgrades/info to use full page. Modal is too small - -- using a table maybe? -- examples? diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 5675ec1..153a59a 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -32,12 +32,15 @@ import { migration as migration027 } from './migrations/027_create_rename_runs.t import { migration as migration028 } from './migrations/028_simplify_delay_profile_sync.ts'; import { migration as migration029 } from './migrations/029_add_database_id_foreign_keys.ts'; import { migration as migration030 } from './migrations/030_create_general_settings.ts'; +import { migration as migration031 } from './migrations/031_remove_search_cooldown.ts'; +import { migration as migration032 } from './migrations/032_add_filter_id_to_upgrade_runs.ts'; export interface Migration { version: number; name: string; up: string; down?: string; + afterUp?: () => void; // Optional callback for data migrations } /** @@ -97,6 +100,11 @@ class MigrationRunner { migration.name ); }); + + // Run data migration callback if present (outside transaction) + if (migration.afterUp) { + migration.afterUp(); + } } catch (error) { await logger.error(`Failed to apply migration ${migration.version}: ${migration.name}`, { source: 'DatabaseMigrations', @@ -276,7 +284,9 @@ export function loadMigrations(): Migration[] { migration027, migration028, migration029, - migration030 + migration030, + migration031, + migration032 ]; // Sort by version number diff --git a/src/lib/server/db/migrations/031_remove_search_cooldown.ts b/src/lib/server/db/migrations/031_remove_search_cooldown.ts new file mode 100644 index 0000000..2f189b5 --- /dev/null +++ b/src/lib/server/db/migrations/031_remove_search_cooldown.ts @@ -0,0 +1,76 @@ +import type { Migration } from '../migrations.ts'; +import { db } from '../db.ts'; + +/** + * Migration 031: Remove searchCooldown from filter configs + * + * The cooldown system is being simplified to use filter-level tags + * instead of time-based cooldowns. This migration removes the + * searchCooldown field from all existing filter configurations. + */ + +interface LegacyFilterConfig { + id: string; + name: string; + enabled: boolean; + group: unknown; + selector: string; + count: number; + cutoff: number; + searchCooldown?: number; // Field being removed +} + +interface UpgradeConfigRow { + id: number; + filters: string; +} + +/** + * Remove searchCooldown from filter configs + */ +function migrateFilterConfigs(): void { + const rows = db.query('SELECT id, filters FROM upgrade_configs'); + + for (const row of rows) { + try { + const filters = JSON.parse(row.filters) as LegacyFilterConfig[]; + let modified = false; + + const updatedFilters = filters.map((filter) => { + if ('searchCooldown' in filter) { + modified = true; + const { searchCooldown: _, ...rest } = filter; + return rest; + } + return filter; + }); + + if (modified) { + db.execute( + 'UPDATE upgrade_configs SET filters = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + JSON.stringify(updatedFilters), + row.id + ); + } + } catch { + // Skip rows with invalid JSON - shouldn't happen but be safe + } + } +} + +export const migration: Migration = { + version: 31, + name: 'Remove searchCooldown from filter configs', + + up: ` + -- Data migration handled by afterUp callback + SELECT 1; + `, + + down: ` + -- Cannot restore removed searchCooldown values + SELECT 1; + `, + + afterUp: migrateFilterConfigs +}; diff --git a/src/lib/server/db/migrations/032_add_filter_id_to_upgrade_runs.ts b/src/lib/server/db/migrations/032_add_filter_id_to_upgrade_runs.ts new file mode 100644 index 0000000..27de5ca --- /dev/null +++ b/src/lib/server/db/migrations/032_add_filter_id_to_upgrade_runs.ts @@ -0,0 +1,26 @@ +import type { Migration } from '../migrations.ts'; + +/** + * Migration 032: Add filter_id to upgrade_runs table + * + * Replaces the cooldown_hours column with filter_id. + * The cooldown system now uses filter-level tags instead of time-based cooldown. + * + * - Adds filter_id TEXT column to track which filter was used + * - cooldown_hours is deprecated but kept for backwards compatibility + */ + +export const migration: Migration = { + version: 32, + name: 'Add filter_id to upgrade_runs', + + up: ` + ALTER TABLE upgrade_runs ADD COLUMN filter_id TEXT NOT NULL DEFAULT ''; + `, + + down: ` + -- SQLite doesn't support dropping columns easily + -- The column will remain but won't be used + SELECT 1; + ` +}; diff --git a/src/lib/server/db/queries/upgradeRuns.ts b/src/lib/server/db/queries/upgradeRuns.ts index 2abb1de..0cb1d67 100644 --- a/src/lib/server/db/queries/upgradeRuns.ts +++ b/src/lib/server/db/queries/upgradeRuns.ts @@ -14,12 +14,12 @@ interface UpgradeRunRow { schedule: number; filter_mode: string; filter_name: string; + filter_id: string; library_total: number; library_cached: number; library_fetch_ms: number; matched_count: number; after_cooldown: number; - cooldown_hours: number; dry_run_excluded: number; selection_method: string; selection_requested: number; @@ -60,11 +60,11 @@ function rowToLog(row: UpgradeRunRow): UpgradeJobLog { }, filter: { + id: row.filter_id, name: row.filter_name, rules: { type: 'group', match: 'all', children: [] }, // Not stored, too complex matchedCount: row.matched_count, afterCooldown: row.after_cooldown, - cooldownHours: row.cooldown_hours, dryRunExcluded: row.dry_run_excluded }, @@ -95,13 +95,13 @@ export const upgradeRunsQueries = { db.execute( `INSERT INTO upgrade_runs ( id, instance_id, started_at, completed_at, status, dry_run, - schedule, filter_mode, filter_name, + schedule, filter_mode, filter_name, filter_id, library_total, library_cached, library_fetch_ms, matched_count, after_cooldown, cooldown_hours, dry_run_excluded, selection_method, selection_requested, selected_count, searches_triggered, successful, failed, items, errors - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, log.id, log.instanceId, log.startedAt, @@ -111,12 +111,13 @@ export const upgradeRunsQueries = { log.config.schedule, log.config.filterMode, log.config.selectedFilter, + log.filter.id, log.library.totalItems, log.library.fetchedFromCache ? 1 : 0, log.library.fetchDurationMs, log.filter.matchedCount, log.filter.afterCooldown, - log.filter.cooldownHours, + 0, // cooldown_hours deprecated, kept for backwards compatibility log.filter.dryRunExcluded, log.selection.method, log.selection.requestedCount, diff --git a/src/lib/server/upgrades/cooldown.ts b/src/lib/server/upgrades/cooldown.ts index 44ebd7a..e4dce6b 100644 --- a/src/lib/server/upgrades/cooldown.ts +++ b/src/lib/server/upgrades/cooldown.ts @@ -1,74 +1,61 @@ /** * Tag-based cooldown tracking for upgrade searches * - * Uses tags in the format: profilarr-searched-YYYY-MM-DD - * This allows checking if an item was searched within the cooldown window + * Basic mode (current implementation): + * - Uses filter-level tags: profilarr-filter-{filterId} + * - Items are tagged when searched + * - Tagged items are skipped on subsequent runs + * - When filter is exhausted (no untagged items), all tags are cleared to reset + * + * Future: Advanced mode with adaptive backoff per scratchpad.md */ import type { RadarrTag, RadarrMovie } from '$lib/server/utils/arr/types.ts'; import type { RadarrClient } from '$lib/server/utils/arr/clients/radarr.ts'; -const TAG_PREFIX = 'profilarr-searched-'; +const FILTER_TAG_PREFIX = 'profilarr-'; /** - * Get today's cooldown tag label + * Slugify a filter name for use in tags + * Converts "Things I Don't Want to Upgrade" -> "things-i-dont-want-to-upgrade" */ -export function getTodayTagLabel(): string { - const today = new Date(); - const year = today.getFullYear(); - const month = String(today.getMonth() + 1).padStart(2, '0'); - const day = String(today.getDate()).padStart(2, '0'); - return `${TAG_PREFIX}${year}-${month}-${day}`; +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/['']/g, '') // Remove apostrophes + .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with dashes + .replace(/^-+|-+$/g, '') // Trim leading/trailing dashes + .slice(0, 50); // Limit length } /** - * Parse a date from a profilarr search tag label - * Returns null if not a valid profilarr tag + * Get the tag label for a specific filter */ -function parseDateFromTagLabel(label: string): Date | null { - if (!label.startsWith(TAG_PREFIX)) { - return null; - } - - const dateStr = label.slice(TAG_PREFIX.length); - const parsed = new Date(dateStr); - - // Check if valid date - if (isNaN(parsed.getTime())) { - return null; - } - - return parsed; +export function getFilterTagLabel(filterName: string): string { + return `${FILTER_TAG_PREFIX}${slugify(filterName)}`; } /** - * Check if an item is on cooldown based on its tags - * - * @param itemTagIds - The tag IDs on the item - * @param allTags - All tags from the arr instance - * @param cooldownHours - The cooldown period in hours + * Check if a tag label is a profilarr filter tag */ -export function isOnCooldown( +export function isFilterTag(label: string): boolean { + return label.startsWith(FILTER_TAG_PREFIX); +} + +/** + * Check if an item has a specific filter's tag + */ +export function hasFilterTag( itemTagIds: number[], allTags: RadarrTag[], - cooldownHours: number + filterName: string ): boolean { - const now = new Date(); - const cooldownMs = cooldownHours * 60 * 60 * 1000; - - // Create a lookup for tag IDs to labels + const targetLabel = getFilterTagLabel(filterName); const tagMap = new Map(allTags.map((t) => [t.id, t.label])); for (const tagId of itemTagIds) { const label = tagMap.get(tagId); - if (!label) continue; - - const tagDate = parseDateFromTagLabel(label); - if (!tagDate) continue; - - // Check if the tag date is within the cooldown window - const diffMs = now.getTime() - tagDate.getTime(); - if (diffMs <= cooldownMs) { + if (label === targetLabel) { return true; } } @@ -77,35 +64,26 @@ export function isOnCooldown( } /** - * Filter items that are NOT on cooldown - * - * @param items - Items with _tags property - * @param allTags - All tags from the arr instance - * @param cooldownHours - The cooldown period in hours + * Filter items that do NOT have the filter's tag + * Returns only items eligible for searching (not yet searched by this filter) */ -export function filterByCooldown( +export function filterByFilterTag( items: T[], allTags: RadarrTag[], - cooldownHours: number + filterName: string ): T[] { - if (cooldownHours <= 0) { - // No cooldown, return all items - return items; - } - - return items.filter((item) => !isOnCooldown(item._tags, allTags, cooldownHours)); + return items.filter((item) => !hasFilterTag(item._tags, allTags, filterName)); } /** - * Apply today's search tag to a movie + * Apply a filter's tag to a movie * Adds the tag to the movie's existing tags and updates via API */ -export async function applySearchTag( +export async function applyFilterTag( client: RadarrClient, movie: RadarrMovie, tagId: number ): Promise { - // Get current tags, add new one if not present const currentTags = movie.tags ?? []; if (currentTags.includes(tagId)) { return movie; // Already has the tag @@ -120,9 +98,9 @@ export async function applySearchTag( } /** - * Apply search tag to multiple movies + * Apply filter tag to multiple movies */ -export async function applySearchTagToMovies( +export async function applyFilterTagToMovies( client: RadarrClient, movies: RadarrMovie[], tagId: number @@ -133,7 +111,7 @@ export async function applySearchTagToMovies( for (const movie of movies) { try { - await applySearchTag(client, movie, tagId); + await applyFilterTag(client, movie, tagId); success++; } catch (error) { failed++; @@ -145,3 +123,85 @@ export async function applySearchTagToMovies( return { success, failed, errors }; } + +/** + * Remove a filter's tag from a single movie + */ +export async function removeFilterTag( + client: RadarrClient, + movie: RadarrMovie, + tagId: number +): Promise { + const currentTags = movie.tags ?? []; + if (!currentTags.includes(tagId)) { + return movie; // Doesn't have the tag + } + + const updatedMovie = { + ...movie, + tags: currentTags.filter((id) => id !== tagId) + }; + + return await client.updateMovie(updatedMovie); +} + +/** + * Reset filter cooldown by removing the filter's tag from all movies that have it + * Called when a filter is "exhausted" (no more untagged items to search) + * + * @returns Count of movies that had their tags removed + */ +export async function resetFilterCooldown( + client: RadarrClient, + filterName: string +): Promise<{ reset: number; failed: number; errors: string[] }> { + const tagLabel = getFilterTagLabel(filterName); + + // Find the tag ID + const tags = await client.getTags(); + const filterTag = tags.find((t) => t.label === tagLabel); + + if (!filterTag) { + // No tag exists, nothing to reset + return { reset: 0, failed: 0, errors: [] }; + } + + // Get all movies that have this tag + const movies = await client.getMovies(); + const taggedMovies = movies.filter((m) => m.tags?.includes(filterTag.id)); + + if (taggedMovies.length === 0) { + return { reset: 0, failed: 0, errors: [] }; + } + + let reset = 0; + let failed = 0; + const errors: string[] = []; + + for (const movie of taggedMovies) { + try { + await removeFilterTag(client, movie, filterTag.id); + reset++; + } catch (error) { + failed++; + errors.push( + `Failed to untag "${movie.title}": ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + return { reset, failed, errors }; +} + +/** + * Check if a filter is exhausted (all matched items are tagged) + * When true, the filter should be reset before the next run + */ +export function isFilterExhausted( + matchedItems: T[], + allTags: RadarrTag[], + filterName: string +): boolean { + const untaggedItems = filterByFilterTag(matchedItems, allTags, filterName); + return untaggedItems.length === 0 && matchedItems.length > 0; +} diff --git a/src/lib/server/upgrades/logger.ts b/src/lib/server/upgrades/logger.ts index 5b0c95b..03da0e1 100644 --- a/src/lib/server/upgrades/logger.ts +++ b/src/lib/server/upgrades/logger.ts @@ -69,8 +69,8 @@ export async function logUpgradeRun(log: UpgradeJobLog): Promise { const meta = { dryRun: log.config.dryRun, filter: log.filter.name, + filterId: log.filter.id, selector: log.selection.method, - cooldownHours: log.filter.cooldownHours, funnel, items: formattedItems }; diff --git a/src/lib/server/upgrades/processor.ts b/src/lib/server/upgrades/processor.ts index 1bf359c..d8b21e2 100644 --- a/src/lib/server/upgrades/processor.ts +++ b/src/lib/server/upgrades/processor.ts @@ -10,7 +10,13 @@ import { evaluateGroup } from '$lib/shared/filters.ts'; import { getSelector } from '$lib/shared/selectors.ts'; import type { UpgradeItem, UpgradeJobLog, UpgradeSelectionItem } from './types.ts'; import { normalizeRadarrItems } from './normalize.ts'; -import { filterByCooldown, getTodayTagLabel, applySearchTagToMovies } from './cooldown.ts'; +import { + filterByFilterTag, + getFilterTagLabel, + applyFilterTagToMovies, + isFilterExhausted, + resetFilterCooldown +} from './cooldown.ts'; import { logUpgradeRun, logUpgradeError, logUpgradeSkipped } from './logger.ts'; import { notifications } from '$lib/server/notifications/definitions/index.ts'; import { notificationServicesQueries } from '$lib/server/db/queries/notificationServices.ts'; @@ -145,11 +151,11 @@ function createSkippedLog( fetchDurationMs: 0 }, filter: { + id: '', name: '', rules: { type: 'group', match: 'all', children: [] }, matchedCount: 0, afterCooldown: 0, - cooldownHours: 0, dryRunExcluded: 0 }, selection: { @@ -222,8 +228,19 @@ export async function processUpgradeConfig( evaluateGroup(item as unknown as Record, filter.group) ); - // Step 4: Filter by cooldown (tags already fetched above) - const afterCooldownItems = filterByCooldown(matchedItems, tags, filter.searchCooldown); + // Step 4: Filter by filter-level tag (items already searched by this filter) + // First check if filter is exhausted - if so, reset the cooldown + if (isFilterExhausted(matchedItems, tags, filter.name)) { + const resetResult = await resetFilterCooldown(client, filter.name); + if (resetResult.reset > 0) { + // Re-fetch tags after reset + const updatedTags = await client.getTags(); + tags.length = 0; + tags.push(...updatedTags); + } + } + + const afterCooldownItems = filterByFilterTag(matchedItems, tags, filter.name); const afterCooldownCount = afterCooldownItems.length; // Step 4b: If dry run, also exclude items from previous dry runs @@ -363,13 +380,13 @@ export async function processUpgradeConfig( } } - // Apply search tag - const tagLabel = getTodayTagLabel(); - const searchTag = await client.getOrCreateTag(tagLabel); - const tagResult = await applySearchTagToMovies( + // Apply filter tag to mark items as searched by this filter + const tagLabel = getFilterTagLabel(filter.name); + const filterTag = await client.getOrCreateTag(tagLabel); + const tagResult = await applyFilterTagToMovies( client, selectedItems.map((item) => item._raw), - searchTag.id + filterTag.id ); failed = tagResult.failed; @@ -414,11 +431,11 @@ export async function processUpgradeConfig( fetchDurationMs }, filter: { + id: filter.id, name: filter.name, rules: filter.group, matchedCount: matchedItems.length, afterCooldown: afterCooldownCount, - cooldownHours: filter.searchCooldown, dryRunExcluded: dryRunExcludedCount }, selection: { @@ -451,6 +468,8 @@ export async function processUpgradeConfig( log.completedAt = new Date().toISOString(); log.status = 'failed'; log.config.selectedFilter = filter?.name ?? ''; + log.filter.id = filter?.id ?? ''; + log.filter.name = filter?.name ?? ''; return log; } finally { diff --git a/src/lib/server/upgrades/types.ts b/src/lib/server/upgrades/types.ts index a787b53..3701e8b 100644 --- a/src/lib/server/upgrades/types.ts +++ b/src/lib/server/upgrades/types.ts @@ -101,11 +101,11 @@ export interface UpgradeJobLog { }; filter: { + id: string; name: string; rules: FilterGroup; matchedCount: number; afterCooldown: number; - cooldownHours: number; dryRunExcluded: number; }; diff --git a/src/lib/shared/filters.ts b/src/lib/shared/filters.ts index 08d11e0..8771557 100644 --- a/src/lib/shared/filters.ts +++ b/src/lib/shared/filters.ts @@ -48,7 +48,8 @@ export interface FilterConfig { selector: string; count: number; cutoff: number; - searchCooldown: number; // hours - skip items searched within this time + // Cooldown is handled via filter-level tags (profilarr-{filterId}) + // Future: cooldownMode?: 'basic' | 'advanced' for adaptive backoff } export type FilterMode = 'round_robin' | 'random'; @@ -392,8 +393,7 @@ export function createEmptyFilterConfig(name: string = 'New Filter'): FilterConf group: createDefaultGroup(), selector: 'random', count: 2, - cutoff: 100, - searchCooldown: 672 // 4 weeks in hours + cutoff: 100 }; } diff --git a/src/routes/arr/[id]/upgrades/+page.server.ts b/src/routes/arr/[id]/upgrades/+page.server.ts index 9d1e5a6..ccc516b 100644 --- a/src/routes/arr/[id]/upgrades/+page.server.ts +++ b/src/routes/arr/[id]/upgrades/+page.server.ts @@ -86,8 +86,7 @@ export const actions: Actions = { enabled: f.enabled, selector: f.selector, count: f.count, - cutoff: f.cutoff, - searchCooldown: f.searchCooldown + cutoff: f.cutoff })) } }); diff --git a/src/routes/arr/[id]/upgrades/components/FilterSettings.svelte b/src/routes/arr/[id]/upgrades/components/FilterSettings.svelte index bd118ee..4808baa 100644 --- a/src/routes/arr/[id]/upgrades/components/FilterSettings.svelte +++ b/src/routes/arr/[id]/upgrades/components/FilterSettings.svelte @@ -77,7 +77,16 @@ } function addFilter() { - const newFilter = createEmptyFilterConfig(`Filter ${filters.length + 1}`); + // Generate unique filter name + let baseName = 'Filter'; + let counter = filters.length + 1; + let name = `${baseName} ${counter}`; + while (filters.some((f) => f.name.toLowerCase() === name.toLowerCase())) { + counter++; + name = `${baseName} ${counter}`; + } + + const newFilter = createEmptyFilterConfig(name); filters = [...filters, newFilter]; expandedIds.add(newFilter.id); expandedIds = expandedIds; @@ -93,7 +102,16 @@ if (editingId) { const filter = filters.find((f) => f.id === editingId); if (filter) { - filter.name = editingName; + // Check for duplicate names + const trimmedName = editingName.trim(); + const isDuplicate = filters.some( + (f) => f.id !== editingId && f.name.toLowerCase() === trimmedName.toLowerCase() + ); + if (isDuplicate) { + alertStore.add('error', 'A filter with this name already exists'); + return; + } + filter.name = trimmedName || filter.name; filters = filters; notifyChange(); } @@ -119,10 +137,19 @@ function duplicateFilter(id: string) { const filter = filters.find((f) => f.id === id); if (filter) { + // Generate unique name for duplicate + let baseName = `${filter.name} (Copy)`; + let name = baseName; + let counter = 1; + while (filters.some((f) => f.name.toLowerCase() === name.toLowerCase())) { + counter++; + name = `${baseName} ${counter}`; + } + const duplicate: FilterConfig = { ...structuredClone(filter), id: uuid(), - name: `${filter.name} (Copy)` + name }; filters = [...filters, duplicate]; expandedIds.add(duplicate.id); @@ -166,7 +193,6 @@ filter.selector = imported.selector ?? filter.selector; filter.count = imported.count ?? filter.count; filter.cutoff = imported.cutoff ?? filter.cutoff; - filter.searchCooldown = imported.searchCooldown ?? filter.searchCooldown; filter.enabled = imported.enabled ?? filter.enabled; filters = filters; @@ -288,7 +314,7 @@ class="rounded-lg border border-neutral-200 bg-neutral-50 p-4 dark:border-neutral-800 dark:bg-neutral-800/50" >

Settings

-
+
-
- -
- -
-

- Skip if searched recently -

-