fix(search): address follow-up review feedback

This commit is contained in:
4gray committed 2026-06-20 21:15:45 +02:00
1 parent 357d1de050
commit 8e7b002914
8 files changed
+267 -24

No files matched your search

+3 -2
View File
@@ -213,8 +213,9 @@ Xtream-only row shape:
passed to `MATCH`, so FTS reserved words such as `and` are treated as search
text instead of degrading to the slow fallback path. Existing databases
rebuild this index once through the `migration:content-title-fts-trigram:v1`
app-state marker; fresh inserts, deletes, and title updates stay
synchronized through SQLite triggers.
app-state marker before legacy content cleanup/normalization migrations run;
fresh inserts, deletes, and title updates stay synchronized through SQLite
triggers.
2. `source_type = "m3u"` rows come from M3U playlist payloads stored in
`playlists.payload`. The worker uses SQL `payload LIKE` only as a coarse
candidate prefilter, then parses candidate JSON payloads and matches channel
@@ -3,7 +3,7 @@
<ng-content select="[details]" />
@if (!showDetails()) {
<div class="search-content" (scroll)="onSearchContentScroll($event)">
<div class="search-content">
<div class="header sticky-header">
<div class="header-top">
<div class="header-left">
@@ -47,7 +47,10 @@
<ng-content select="[subheader]" />
</div>
<div class="results-container">
<div
class="results-container"
(scroll)="onSearchContentScroll($event)"
>
@if (isLoading()) {
<div class="loading-state">
<mat-spinner diameter="48" />
@@ -59,8 +62,17 @@
<div class="no-items">
<mat-icon>search_off</mat-icon>
<div class="no-items-content">
<h3>{{ 'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate }}</h3>
<p>{{ 'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate }}</p>
<h3>
{{
'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate
}}
</h3>
<p>
{{
'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS'
| translate
}}
</p>
<p class="search-term-note">
{{
'PORTALS.SEARCH_VIEW.NO_RESULTS_FOR'
@@ -73,7 +85,12 @@
<div class="no-items initial-state">
<mat-icon>search</mat-icon>
<div class="no-items-content">
<h3>{{ 'PORTALS.SEARCH_VIEW.INITIAL_TITLE' | translate }}</h3>
<h3>
{{
'PORTALS.SEARCH_VIEW.INITIAL_TITLE'
| translate
}}
</h3>
<p>{{ initialDescriptionKey() | translate }}</p>
</div>
</div>
@@ -12,6 +12,7 @@
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
width: 100%;
}
@@ -19,7 +20,8 @@
display: flex;
flex-direction: column;
flex: 1;
overflow-y: auto;
min-height: 0;
overflow: hidden;
padding: 0 20px 0 20px;
}
@@ -90,6 +92,7 @@
.results-container {
flex: 1;
min-height: 0;
overflow-y: auto;
}
@@ -0,0 +1,59 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslatePipe } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { SearchLayoutComponent } from './search-layout.component';
describe('SearchLayoutComponent', () => {
let fixture: ComponentFixture<SearchLayoutComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SearchLayoutComponent],
})
.overrideComponent(SearchLayoutComponent, {
remove: { imports: [TranslatePipe] },
add: {
imports: [
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(SearchLayoutComponent);
});
it('emits nearEnd when the results container is scrolled near the bottom', () => {
const nearEndSpy = jest.fn();
fixture.componentRef.setInput('searchTerm', 'matrix');
fixture.componentRef.setInput('resultsCount', 1);
fixture.componentInstance.nearEnd.subscribe(nearEndSpy);
fixture.detectChanges();
const resultsContainer = fixture.debugElement.query(
By.css('.results-container')
).nativeElement as HTMLElement;
Object.defineProperties(resultsContainer, {
scrollHeight: {
configurable: true,
value: 1000,
},
scrollTop: {
configurable: true,
value: 700,
},
clientHeight: {
configurable: true,
value: 120,
},
});
resultsContainer.dispatchEvent(new Event('scroll'));
expect(nearEndSpy).toHaveBeenCalledTimes(1);
});
});
@@ -258,9 +258,12 @@ describe('SearchResultsComponent initialQuery contract', () => {
const component = TestBed.runInInjectionContext(
() =>
new SearchResultsComponent({
isGlobalSearch: true,
}, undefined)
new SearchResultsComponent(
{
isGlobalSearch: true,
},
undefined
)
);
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
@@ -302,9 +305,12 @@ describe('SearchResultsComponent initialQuery contract', () => {
const component = TestBed.runInInjectionContext(
() =>
new SearchResultsComponent({
isGlobalSearch: true,
}, undefined)
new SearchResultsComponent(
{
isGlobalSearch: true,
},
undefined
)
);
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
@@ -339,4 +345,92 @@ describe('SearchResultsComponent initialQuery contract', () => {
expect(store.searchResults()[100].title).toBe('Result 102');
expect(component.hasMoreGlobalResults()).toBe(false);
});
it('clears the load-more indicator when a new global search supersedes pagination', async () => {
const appendSearch = createDeferred<XtreamContentItem[]>();
const freshSearch = createDeferred<XtreamContentItem[]>();
const databaseService = TestBed.inject(DatabaseService) as {
globalSearchContent: jest.Mock;
};
databaseService.globalSearchContent
.mockReturnValueOnce(appendSearch.promise)
.mockReturnValueOnce(freshSearch.promise);
const component = TestBed.runInInjectionContext(
() =>
new SearchResultsComponent(
{
isGlobalSearch: true,
},
undefined
)
);
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
store.searchResults.set([createSearchItem()]);
component.hasMoreGlobalResults.set(true);
const appendPromise = component.searchGlobal(
'matrix',
['movie'],
false,
true
);
expect(component.isLoadingMoreGlobalResults()).toBe(true);
const freshPromise = component.searchGlobal('news', ['live'], false);
expect(component.isLoadingMoreGlobalResults()).toBe(false);
appendSearch.resolve([]);
freshSearch.resolve([]);
await Promise.all([appendPromise, freshPromise]);
});
it('uses the rendered global search results when calculating the next page offset', async () => {
const nextPage = [
createSearchItem({
id: 2,
title: 'Result 2',
xtream_id: 2,
}),
];
const databaseService = TestBed.inject(DatabaseService) as {
globalSearchContent: jest.Mock;
};
databaseService.globalSearchContent.mockResolvedValueOnce(nextPage);
const component = TestBed.runInInjectionContext(
() =>
new SearchResultsComponent(
{
isGlobalSearch: true,
},
undefined
)
);
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
store.searchResults.set([createSearchItem({ id: 1, xtream_id: 1 })]);
(
store as unknown as {
globalSearchResults: ReturnType<
typeof signal<XtreamContentItem[]>
>;
}
).globalSearchResults = signal([]);
component.hasMoreGlobalResults.set(true);
await component.searchGlobal('matrix', ['movie'], false, true);
expect(databaseService.globalSearchContent).toHaveBeenCalledWith(
'matrix',
['movie'],
false,
undefined,
{
limit: 101,
offset: 1,
}
);
expect(store.searchResults()).toHaveLength(2);
});
});
@@ -335,6 +335,7 @@ export class SearchResultsComponent implements AfterViewInit {
this.lastGlobalSearchTypes = [...effectiveTypes];
this.lastGlobalSearchExcludeHidden = effectiveExcludeHidden;
this.hasMoreGlobalResults.set(false);
this.isLoadingMoreGlobalResults.set(false);
this.xtreamStore.setIsSearching(true);
}
@@ -355,7 +356,10 @@ export class SearchResultsComponent implements AfterViewInit {
}
if (results && Array.isArray(results)) {
const visibleResults = results.slice(0, GLOBAL_SEARCH_PAGE_SIZE);
const visibleResults = results.slice(
0,
GLOBAL_SEARCH_PAGE_SIZE
);
this.hasMoreGlobalResults.set(
results.length > GLOBAL_SEARCH_PAGE_SIZE
);
@@ -503,14 +507,7 @@ export class SearchResultsComponent implements AfterViewInit {
}
private getCurrentGlobalSearchResults(): GlobalSearchResult[] {
const store = this.xtreamStore as typeof this.xtreamStore & {
globalSearchResults?: () => GlobalSearchResult[];
};
return (
store.globalSearchResults?.() ??
(this.xtreamStore.searchResults() as GlobalSearchResult[])
);
return this.xtreamStore.searchResults() as GlobalSearchResult[];
}
private selectM3uItem(item: GlobalSearchResult): void {
@@ -236,4 +236,75 @@ describe('database schema statements', () => {
expect(transaction).not.toHaveBeenCalled();
});
it('backfills the content title FTS index before content-mutating migrations', () => {
const callOrder: string[] = [];
const runMigrations = (
__databaseConnectionTestHooks as unknown as {
runMigrations: (sqlite: {
exec: (statement: string) => void;
prepare: (statement: string) => {
all?: () => unknown[];
get?: (...args: unknown[]) => unknown;
run?: (...args: unknown[]) => unknown;
};
transaction: (callback: () => void) => () => void;
}) => void;
}
).runMigrations;
const sqlite = {
exec: jest.fn(),
prepare: jest.fn((statement: string) => {
if (statement.includes('SELECT value FROM app_state')) {
return {
get: (): undefined => {
return undefined;
},
};
}
if (
statement.includes(
'INSERT INTO content_title_fts(content_title_fts)'
)
) {
return {
run: () => {
callOrder.push('fts-rebuild');
},
};
}
if (statement.includes('INSERT INTO app_state')) {
return { run: jest.fn() };
}
if (statement.includes('UPDATE content')) {
return {
run: () => {
callOrder.push('content-update');
},
};
}
if (statement.includes('DELETE FROM content')) {
return {
run: () => {
callOrder.push('content-delete');
},
};
}
return {
all: () => [],
get: (): undefined => undefined,
run: jest.fn(),
};
}),
transaction: jest.fn((callback: () => void) => callback),
};
runMigrations(sqlite);
expect(callOrder).toEqual(['fts-rebuild', 'content-update']);
expect(sqlite.prepare).toHaveBeenCalledWith(
expect.stringContaining('UPDATE content')
);
});
});
+2 -1
View File
@@ -329,6 +329,7 @@ export const __databaseConnectionTestHooks = {
indexMigrationStatements: INDEX_MIGRATION_STATEMENTS,
normalizeXtreamContentAddedEpochs,
ensureContentTitleFts,
runMigrations,
} as const;
/**
@@ -626,10 +627,10 @@ function runMigrationStatements(
*/
function runMigrations(sqliteDb: Database.Database): void {
runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS);
ensureContentTitleFts(sqliteDb);
deduplicateXtreamCache(sqliteDb);
normalizeXtreamContentAddedEpochs(sqliteDb);
runMigrationStatements(sqliteDb, INDEX_MIGRATION_STATEMENTS);
ensureContentTitleFts(sqliteDb);
}
export interface DatabaseOptions {