mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
fix(search): address follow-up review feedback
This commit is contained in:
1 parent
357d1de050
commit
8e7b002914
8 files changed
+267
-24
No files matched your search
@@ -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
|
||||
|
||||
+22
-5
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+59
@@ -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);
|
||||
});
|
||||
});
|
||||
+100
-6
@@ -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')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user