mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
feat(global-search): implement global search functionality and manage recently viewed items
This commit is contained in:
1 parent
a56e3594b4
commit
50df27f5ee
4 files changed
+237
-95
No files matched your search
@@ -30,6 +30,17 @@ export interface XtreamPlaylist {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface GlobalSearchResult extends XtreamContent {
|
||||
playlist_id: string;
|
||||
playlist_name: string;
|
||||
}
|
||||
|
||||
export interface GlobalRecentItem extends XtreamContent {
|
||||
playlist_id: string;
|
||||
playlist_name: string;
|
||||
viewed_at: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@@ -272,6 +283,96 @@ export class DatabaseService {
|
||||
);
|
||||
}
|
||||
|
||||
async globalSearchContent(
|
||||
searchTerm: string,
|
||||
types: string[]
|
||||
): Promise<GlobalSearchResult[]> {
|
||||
const db = await this.getConnection();
|
||||
const placeholders = types.map(() => '?').join(',');
|
||||
|
||||
// Use a materialized subquery for better performance
|
||||
return await db.select(
|
||||
`
|
||||
WITH filtered_content AS (
|
||||
SELECT
|
||||
c.id,
|
||||
c.category_id,
|
||||
c.title,
|
||||
c.rating,
|
||||
c.added,
|
||||
c.poster_url,
|
||||
c.xtream_id,
|
||||
c.type,
|
||||
cat.playlist_id,
|
||||
p.name as playlist_name
|
||||
FROM content c
|
||||
INNER JOIN categories cat ON c.category_id = cat.id
|
||||
INNER JOIN playlists p ON cat.playlist_id = p.id
|
||||
WHERE c.type IN (${placeholders})
|
||||
)
|
||||
SELECT * FROM filtered_content
|
||||
WHERE LOWER(title) LIKE LOWER(?)
|
||||
ORDER BY title
|
||||
LIMIT 50
|
||||
`,
|
||||
[...types, `%${searchTerm}%`]
|
||||
);
|
||||
}
|
||||
|
||||
async getGlobalRecentlyViewed(): Promise<GlobalRecentItem[]> {
|
||||
try {
|
||||
console.log('Starting getGlobalRecentlyViewed query...');
|
||||
const db = await this.getConnection();
|
||||
console.log('Got database connection');
|
||||
|
||||
// Check if table exists
|
||||
const tableCheck = await db.select(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='recently_viewed'
|
||||
`);
|
||||
console.log('Tables check:', tableCheck);
|
||||
|
||||
const items = await db.select<GlobalRecentItem[]>(`
|
||||
SELECT
|
||||
c.id,
|
||||
c.category_id,
|
||||
c.title,
|
||||
c.rating,
|
||||
c.added,
|
||||
c.poster_url,
|
||||
c.xtream_id,
|
||||
c.type,
|
||||
cat.playlist_id,
|
||||
p.name as playlist_name,
|
||||
rv.viewed_at
|
||||
FROM recently_viewed rv
|
||||
INNER JOIN content c ON rv.content_id = c.id
|
||||
INNER JOIN categories cat ON c.category_id = cat.id
|
||||
INNER JOIN playlists p ON cat.playlist_id = p.id
|
||||
ORDER BY rv.viewed_at DESC
|
||||
LIMIT 100
|
||||
`);
|
||||
|
||||
console.log('Query executed, items found:', items?.length);
|
||||
console.log('First few items:', items?.slice(0, 3));
|
||||
|
||||
return items || [];
|
||||
} catch (error) {
|
||||
console.error('Detailed error in getGlobalRecentlyViewed:', error);
|
||||
throw error; // Let's throw the error to see it in the component
|
||||
}
|
||||
}
|
||||
|
||||
async clearGlobalRecentlyViewed(): Promise<void> {
|
||||
try {
|
||||
const db = await this.getConnection();
|
||||
await db.execute('DELETE FROM recently_viewed');
|
||||
} catch (error) {
|
||||
console.error('Error clearing global recently viewed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getPlaylistById(playlistId: string): Promise<XtreamPlaylist | null> {
|
||||
const db = await this.getConnection();
|
||||
const results = await db.select<XtreamPlaylist[]>(
|
||||
|
||||
@@ -143,6 +143,28 @@ export const withRecentItems = function () {
|
||||
})
|
||||
)
|
||||
),
|
||||
async loadGlobalRecentItems() {
|
||||
try {
|
||||
const items = await dbService.getGlobalRecentlyViewed();
|
||||
patchState(store, {
|
||||
recentItems: items || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading global recent items:', error);
|
||||
patchState(store, { recentItems: [] });
|
||||
}
|
||||
},
|
||||
async clearGlobalRecentlyViewed() {
|
||||
try {
|
||||
await dbService.clearGlobalRecentlyViewed();
|
||||
patchState(store, { recentItems: [] });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error clearing global recently viewed:',
|
||||
error
|
||||
);
|
||||
}
|
||||
},
|
||||
}))
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { XtreamCategory } from '../../../shared/xtream-category.interface';
|
||||
import { XtreamLiveStream } from '../../../shared/xtream-live-stream.interface';
|
||||
import { XtreamSerieItem } from '../../../shared/xtream-serie-item.interface';
|
||||
import { XtreamVodStream } from '../../../shared/xtream-vod-stream.interface';
|
||||
import { GlobalSearchResult } from '../services/database.service';
|
||||
import { EpgItem } from '../xtream/epg-item.interface';
|
||||
|
||||
export type ContentType = 'live' | 'vod' | 'series';
|
||||
@@ -39,4 +40,5 @@ export interface XtreamState {
|
||||
epgItems: EpgItem[];
|
||||
hideExternalInfoDialog: boolean;
|
||||
portalStatus: PortalStatusType;
|
||||
globalSearchResults: GlobalSearchResult[];
|
||||
}
|
||||
@@ -53,6 +53,7 @@ const initialState: XtreamState = {
|
||||
hideExternalInfoDialog:
|
||||
localStorage.getItem('hideExternalInfoDialog') === 'true',
|
||||
portalStatus: 'unavailable',
|
||||
globalSearchResults: [],
|
||||
};
|
||||
|
||||
/** to decode epg */
|
||||
@@ -67,108 +68,105 @@ function b64DecodeUnicode(str: string) {
|
||||
}
|
||||
|
||||
export const XtreamStore = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withState(initialState),
|
||||
withRecentItems(),
|
||||
withFavorites(),
|
||||
withComputed((store) => {
|
||||
return {
|
||||
getCategoriesBySelectedType: computed(() => {
|
||||
const type = store.selectedContentType();
|
||||
return type === 'live'
|
||||
? store.liveCategories()
|
||||
: type === 'vod'
|
||||
? store.vodCategories()
|
||||
: store.serialCategories();
|
||||
}),
|
||||
getSelectedCategory: computed(() => {
|
||||
const categoryId = store.selectedCategoryId();
|
||||
return [
|
||||
...store.vodCategories(),
|
||||
...store.liveCategories(),
|
||||
...store.serialCategories(),
|
||||
].find((c) => c.id === categoryId);
|
||||
}),
|
||||
getImportCount: computed(() => store.importCount()),
|
||||
getSelectedItemById: computed(() => {
|
||||
const categoryType = store.selectedContentType();
|
||||
const content =
|
||||
categoryType === 'live'
|
||||
? store.liveStreams()
|
||||
: categoryType === 'vod'
|
||||
? store.vodStreams()
|
||||
: store.serialStreams();
|
||||
withComputed((store) => ({
|
||||
getCategoriesBySelectedType: computed(() => {
|
||||
const type = store.selectedContentType();
|
||||
return type === 'live'
|
||||
? store.liveCategories()
|
||||
: type === 'vod'
|
||||
? store.vodCategories()
|
||||
: store.serialCategories();
|
||||
}),
|
||||
getSelectedCategory: computed(() => {
|
||||
const categoryId = store.selectedCategoryId();
|
||||
return [
|
||||
...store.vodCategories(),
|
||||
...store.liveCategories(),
|
||||
...store.serialCategories(),
|
||||
].find((c) => c.id === categoryId);
|
||||
}),
|
||||
getImportCount: computed(() => store.importCount()),
|
||||
getSelectedItemById: computed(() => {
|
||||
const categoryType = store.selectedContentType();
|
||||
const content =
|
||||
categoryType === 'live'
|
||||
? store.liveStreams()
|
||||
: categoryType === 'vod'
|
||||
? store.vodStreams()
|
||||
: store.serialStreams();
|
||||
|
||||
if (!store.selectedItem()) return null;
|
||||
if (!store.selectedItem()) return null;
|
||||
|
||||
return content.find(
|
||||
(item) =>
|
||||
(item as any).stream_id ===
|
||||
store.selectedItem().stream_id ||
|
||||
(item as any).id === store.selectedItem().id
|
||||
);
|
||||
}),
|
||||
getPaginatedContent: computed(() => {
|
||||
const startIndex = (store.page() - 1) * store.limit();
|
||||
const endIndex = startIndex + store.limit();
|
||||
const categoryId = store.selectedCategoryId();
|
||||
return content.find(
|
||||
(item) =>
|
||||
(item as any).stream_id ===
|
||||
store.selectedItem().stream_id ||
|
||||
(item as any).id === store.selectedItem().id
|
||||
);
|
||||
}),
|
||||
getPaginatedContent: computed(() => {
|
||||
const startIndex = (store.page() - 1) * store.limit();
|
||||
const endIndex = startIndex + store.limit();
|
||||
const categoryId = store.selectedCategoryId();
|
||||
|
||||
if (!categoryId) {
|
||||
return [];
|
||||
}
|
||||
if (!categoryId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const categoryType = store.selectedContentType();
|
||||
const content =
|
||||
categoryType === 'live'
|
||||
? store.liveStreams()
|
||||
: categoryType === 'vod'
|
||||
? store.vodStreams()
|
||||
: store.serialStreams();
|
||||
const categoryType = store.selectedContentType();
|
||||
const content =
|
||||
categoryType === 'live'
|
||||
? store.liveStreams()
|
||||
: categoryType === 'vod'
|
||||
? store.vodStreams()
|
||||
: store.serialStreams();
|
||||
|
||||
return content
|
||||
.filter((item) => item.category_id === categoryId)
|
||||
.slice(startIndex, endIndex);
|
||||
}),
|
||||
getTotalPages: computed(() => {
|
||||
const categoryId = store.selectedCategoryId();
|
||||
if (!categoryId) return 0;
|
||||
return content
|
||||
.filter((item) => item.category_id === categoryId)
|
||||
.slice(startIndex, endIndex);
|
||||
}),
|
||||
getTotalPages: computed(() => {
|
||||
const categoryId = store.selectedCategoryId();
|
||||
if (!categoryId) return 0;
|
||||
|
||||
const categoryType = store.selectedContentType();
|
||||
const totalItems =
|
||||
categoryType === 'live'
|
||||
? store
|
||||
.liveStreams()
|
||||
.filter(
|
||||
(i) => Number((i as any).id) === categoryId
|
||||
).length
|
||||
: categoryType === 'vod'
|
||||
? store
|
||||
.vodStreams()
|
||||
.filter(
|
||||
(i) => Number(i.category_id) === categoryId
|
||||
).length
|
||||
: store
|
||||
.serialStreams()
|
||||
.filter((i) => i.category_id === categoryId)
|
||||
.length;
|
||||
const categoryType = store.selectedContentType();
|
||||
const totalItems =
|
||||
categoryType === 'live'
|
||||
? store
|
||||
.liveStreams()
|
||||
.filter((i) => Number((i as any).id) === categoryId)
|
||||
.length
|
||||
: categoryType === 'vod'
|
||||
? store
|
||||
.vodStreams()
|
||||
.filter((i) => Number(i.category_id) === categoryId)
|
||||
.length
|
||||
: store
|
||||
.serialStreams()
|
||||
.filter((i) => i.category_id === categoryId).length;
|
||||
|
||||
return Math.ceil(totalItems / store.limit());
|
||||
}),
|
||||
selectItemsFromSelectedCategory: computed(() => {
|
||||
const categoryId = store.selectedCategoryId();
|
||||
const categoryType = store.selectedContentType();
|
||||
const content =
|
||||
categoryType === 'live'
|
||||
? store.liveStreams()
|
||||
: categoryType === 'vod'
|
||||
? store.vodStreams()
|
||||
: store.serialStreams();
|
||||
return Math.ceil(totalItems / store.limit());
|
||||
}),
|
||||
selectItemsFromSelectedCategory: computed(() => {
|
||||
const categoryId = store.selectedCategoryId();
|
||||
const categoryType = store.selectedContentType();
|
||||
const content =
|
||||
categoryType === 'live'
|
||||
? store.liveStreams()
|
||||
: categoryType === 'vod'
|
||||
? store.vodStreams()
|
||||
: store.serialStreams();
|
||||
|
||||
return content.filter(
|
||||
(item) => item.category_id === categoryId
|
||||
);
|
||||
}),
|
||||
};
|
||||
}),
|
||||
return content.filter((item) => item.category_id === categoryId);
|
||||
}),
|
||||
globalRecentItems: computed(() => {
|
||||
return store.recentItems(); // Remove the filter since we're getting global items directly from DB
|
||||
}),
|
||||
})),
|
||||
withMethods(
|
||||
(
|
||||
store,
|
||||
@@ -329,14 +327,18 @@ export const XtreamStore = signalStore(
|
||||
|
||||
const searchContent = async (
|
||||
searchTerm: string,
|
||||
types: string[]
|
||||
types: string[],
|
||||
route = inject(ActivatedRoute),
|
||||
dbService = inject(DatabaseService)
|
||||
) => {
|
||||
if (!route.snapshot.params.id) return;
|
||||
|
||||
const results = await dbService.searchXtreamContent(
|
||||
route.snapshot.params.id,
|
||||
searchTerm,
|
||||
types
|
||||
);
|
||||
patchState(store, { searchResults: results });
|
||||
return results;
|
||||
};
|
||||
|
||||
const fetchXtreamPlaylist = rxMethod<void>(
|
||||
@@ -467,7 +469,13 @@ export const XtreamStore = signalStore(
|
||||
}>(
|
||||
pipe(
|
||||
switchMap(({ term, types }) =>
|
||||
from(searchContent(term, types))
|
||||
from(searchContent(term, types, route, dbService)).pipe(
|
||||
tap((results) => {
|
||||
patchState(store, {
|
||||
searchResults: results || [],
|
||||
});
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -694,7 +702,10 @@ export const XtreamStore = signalStore(
|
||||
});
|
||||
},
|
||||
resetSearchResults() {
|
||||
patchState(store, { searchResults: [] });
|
||||
patchState(store, {
|
||||
searchResults: [],
|
||||
globalSearchResults: [],
|
||||
});
|
||||
},
|
||||
async loadEpg() {
|
||||
console.log('Loading EPG...');
|
||||
@@ -773,6 +784,12 @@ export const XtreamStore = signalStore(
|
||||
patchState(store, { hideExternalInfoDialog });
|
||||
},
|
||||
checkPortalStatus,
|
||||
setGlobalSearchResults(results: any[]) {
|
||||
patchState(store, {
|
||||
searchResults: results,
|
||||
globalSearchResults: results,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user