diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index d3404676e..de1012256 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -80,6 +80,17 @@ contextBridge.exposeInMainWorld('electron', { ipcRenderer.invoke('DB_DELETE_PLAYLIST', playlistId), dbDeleteXtreamContent: (playlistId: string) => ipcRenderer.invoke('DB_DELETE_XTREAM_CONTENT', playlistId), + dbRestoreXtreamUserData: ( + playlistId: string, + favoritedXtreamIds: number[], + recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[] + ) => + ipcRenderer.invoke( + 'DB_RESTORE_XTREAM_USER_DATA', + playlistId, + favoritedXtreamIds, + recentlyViewedXtreamIds + ), dbHasCategories: (playlistId: string, type: string) => ipcRenderer.invoke('DB_HAS_CATEGORIES', playlistId, type), dbGetCategories: (playlistId: string, type: string) => diff --git a/apps/electron-backend/src/app/events/database.events.ts b/apps/electron-backend/src/app/events/database.events.ts index adfe89c01..0e19f1548 100644 --- a/apps/electron-backend/src/app/events/database.events.ts +++ b/apps/electron-backend/src/app/events/database.events.ts @@ -139,39 +139,149 @@ ipcMain.handle( const categoryIds = categories.map((c) => c.id); - console.log( - `Deleting playlist content for ${playlistId}: ${categoryIds.length} categories found` - ); + // Before deleting content, save xtreamIds of favorited and recently viewed items + // so they can be restored after refresh + let favoritedXtreamIds: number[] = []; + let recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[] = []; if (categoryIds.length > 0) { + // Get favorites with their xtreamIds + const favorites = await db + .select({ + xtreamId: schema.content.xtreamId, + }) + .from(schema.favorites) + .innerJoin(schema.content, eq(schema.favorites.contentId, schema.content.id)) + .where( + and( + eq(schema.favorites.playlistId, playlistId), + inArray(schema.content.categoryId, categoryIds) + ) + ); + + favoritedXtreamIds = favorites.map((f) => f.xtreamId); + + // Get recently viewed with their xtreamIds and timestamps + const recentlyViewed = await db + .select({ + xtreamId: schema.content.xtreamId, + viewedAt: schema.recentlyViewed.viewedAt, + }) + .from(schema.recentlyViewed) + .innerJoin(schema.content, eq(schema.recentlyViewed.contentId, schema.content.id)) + .where( + and( + eq(schema.recentlyViewed.playlistId, playlistId), + inArray(schema.content.categoryId, categoryIds) + ) + ); + + recentlyViewedXtreamIds = recentlyViewed.map((rv) => ({ + xtreamId: rv.xtreamId, + viewedAt: rv.viewedAt || new Date().toISOString(), + })); + // Delete all content for these categories + // (This will cascade delete favorites and recently viewed) await db .delete(schema.content) .where(inArray(schema.content.categoryId, categoryIds)); - - console.log( - `Deleted content for ${categoryIds.length} categories` - ); } // Delete all categories for this playlist - // (Could rely on cascade delete, but being explicit is clearer) await db .delete(schema.categories) .where(eq(schema.categories.playlistId, playlistId)); - console.log( - `Deleted ${categoryIds.length} categories for playlist ${playlistId}` - ); + // Return the saved xtreamIds so favorites and recently viewed can be restored + return { + success: true, + favoritedXtreamIds, + recentlyViewedXtreamIds, + }; + } catch (error) { + console.error('Error deleting Xtream content:', error); + throw error; + } + } +); - // NOTE: Do NOT delete user favorites or recently viewed items here. - // When refreshing an Xtream playlist we want to remove only the - // imported categories and content so user-specific data (favorites, - // recently viewed) is preserved. +/** + * Restore favorites and recently viewed items after Xtream refresh + * Matches content by xtreamId and re-creates favorites/recently viewed entries + */ +ipcMain.handle( + 'DB_RESTORE_XTREAM_USER_DATA', + async ( + event, + playlistId: string, + favoritedXtreamIds: number[], + recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[] + ) => { + try { + const db = await getDatabase(); + + // Restore favorites + if (favoritedXtreamIds.length > 0) { + // Find content IDs for the xtreamIds + const content = await db + .select({ id: schema.content.id, xtreamId: schema.content.xtreamId }) + .from(schema.content) + .innerJoin(schema.categories, eq(schema.content.categoryId, schema.categories.id)) + .where( + and( + eq(schema.categories.playlistId, playlistId), + inArray(schema.content.xtreamId, favoritedXtreamIds) + ) + ); + + // Re-create favorite entries + for (const item of content) { + await db.insert(schema.favorites).values({ + contentId: item.id, + playlistId: playlistId, + addedAt: new Date().toISOString(), + }); + } + } + + // Restore recently viewed + if (recentlyViewedXtreamIds.length > 0) { + const xtreamIds = recentlyViewedXtreamIds.map((rv) => rv.xtreamId); + + // Find content IDs for the xtreamIds + const content = await db + .select({ id: schema.content.id, xtreamId: schema.content.xtreamId }) + .from(schema.content) + .innerJoin(schema.categories, eq(schema.content.categoryId, schema.categories.id)) + .where( + and( + eq(schema.categories.playlistId, playlistId), + inArray(schema.content.xtreamId, xtreamIds) + ) + ); + + // Create a map of xtreamId -> contentId + const xtreamIdToContentId = new Map( + content.map((item) => [item.xtreamId, item.id]) + ); + + // Re-create recently viewed entries with original timestamps + for (const item of recentlyViewedXtreamIds) { + const contentId = xtreamIdToContentId.get(item.xtreamId); + if (contentId) { + await db.insert(schema.recentlyViewed).values({ + contentId: contentId, + playlistId: playlistId, + viewedAt: item.viewedAt, + }); + } + } + } return { success: true }; } catch (error) { - console.error('Error deleting Xtream content:', error); + console.error('Error restoring Xtream user data:', error); throw error; } } diff --git a/apps/web/src/app/xtream-tauri/xtream.store.ts b/apps/web/src/app/xtream-tauri/xtream.store.ts index 2415af60c..5d0668a81 100644 --- a/apps/web/src/app/xtream-tauri/xtream.store.ts +++ b/apps/web/src/app/xtream-tauri/xtream.store.ts @@ -620,6 +620,29 @@ export const XtreamStore = signalStore( ), ]); console.log('Content initialization completed'); + + // Check if we need to restore favorites and recently viewed after refresh + const playlistId = route.snapshot.params.id; + if (playlistId) { + const restoreKey = `xtream-restore-${playlistId}`; + const restoreData = localStorage.getItem(restoreKey); + if (restoreData) { + try { + const { + favoritedXtreamIds, + recentlyViewedXtreamIds, + } = JSON.parse(restoreData); + await dbService.restoreXtreamUserData( + playlistId, + favoritedXtreamIds, + recentlyViewedXtreamIds + ); + localStorage.removeItem(restoreKey); + } catch (err) { + console.error('Error restoring user data:', err); + } + } + } } catch (error) { console.error( 'Error during content initialization:', diff --git a/apps/web/src/app/xtream/category-view/category-view.component.ts b/apps/web/src/app/xtream/category-view/category-view.component.ts index 8c308a56e..579e5c993 100644 --- a/apps/web/src/app/xtream/category-view/category-view.component.ts +++ b/apps/web/src/app/xtream/category-view/category-view.component.ts @@ -2,7 +2,7 @@ import { Component, inject, input, output } from '@angular/core'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; import { FilterPipe } from '@iptvnator/pipes'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { XtreamCategory } from 'shared-interfaces'; import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component'; import { PortalStore } from '../portal.store'; @@ -10,36 +10,46 @@ import { PortalStore } from '../portal.store'; @Component({ selector: 'app-category-view', template: ` - @if (items?.length > 0) { @for ( item of items() | filterBy: - searchPhrase() : 'category_name'; track $index ) { - - - {{ item.category_name || item.name || 'No category name' }} - - - } @if ( !(items() | filterBy: searchPhrase() : 'category_name')?.length - ) { - - } } @else { - + @if (items().length > 0) { + @for ( + item of items() | filterBy: searchPhrase() : 'category_name'; + track $index + ) { + + + {{ + item.category_name || + item.name || + 'No category name' + }} + + + } + @if ( + !(items() | filterBy: searchPhrase() : 'category_name')?.length + ) { + + } + } @else { + } `, styleUrl: './category-view.component.scss', @@ -48,7 +58,7 @@ import { PortalStore } from '../portal.store'; MatCardModule, MatIconModule, PlaylistErrorViewComponent, - TranslateModule, + TranslatePipe, ], }) export class CategoryViewComponent { diff --git a/global.d.ts b/global.d.ts index d06fe055a..bacf1bde3 100644 --- a/global.d.ts +++ b/global.d.ts @@ -81,6 +81,15 @@ declare global { ) => Promise<{ success: boolean }>; dbDeleteXtreamContent: ( playlistId: string + ) => Promise<{ + success: boolean; + favoritedXtreamIds: number[]; + recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[]; + }>; + dbRestoreXtreamUserData: ( + playlistId: string, + favoritedXtreamIds: number[], + recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[] ) => Promise<{ success: boolean }>; dbHasCategories: ( playlistId: string, diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index a35591a8d..8e6907bb8 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -67,9 +67,29 @@ export class DatabaseService { /** * Delete all content and categories for an Xtream playlist (for refresh) * Keeps the playlist entry but removes all imported data + * Returns saved favorites and recently viewed xtreamIds for restoration */ - async deleteXtreamPlaylistContent(playlistId: string): Promise { - await window.electron.dbDeleteXtreamContent(playlistId); + async deleteXtreamPlaylistContent(playlistId: string): Promise<{ + success: boolean; + favoritedXtreamIds: number[]; + recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[]; + }> { + return await window.electron.dbDeleteXtreamContent(playlistId); + } + + /** + * Restore favorites and recently viewed items after Xtream refresh + */ + async restoreXtreamUserData( + playlistId: string, + favoritedXtreamIds: number[], + recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[] + ): Promise { + await window.electron.dbRestoreXtreamUserData( + playlistId, + favoritedXtreamIds, + recentlyViewedXtreamIds + ); } /** diff --git a/libs/services/tsconfig.lib.json b/libs/services/tsconfig.lib.json index b11464cac..61d8f1f15 100644 --- a/libs/services/tsconfig.lib.json +++ b/libs/services/tsconfig.lib.json @@ -7,6 +7,7 @@ "inlineSources": true, "types": [] }, + "files": ["../../global.d.ts"], "exclude": [ "src/**/*.spec.ts", "src/test-setup.ts", diff --git a/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts b/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts index 77449dec7..e7493eb6d 100644 --- a/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts @@ -223,9 +223,11 @@ export class RecentPlaylistsComponent { onConfirm: async () => { try { // Delete all content and categories for this playlist - await this.databaseService.deleteXtreamPlaylistContent( - item._id - ); + // This returns the xtreamIds of favorites and recently viewed items + const { favoritedXtreamIds, recentlyViewedXtreamIds } = + await this.databaseService.deleteXtreamPlaylistContent( + item._id + ); const updateDate = Date.now(); @@ -242,6 +244,16 @@ export class RecentPlaylistsComponent { }) ); + // Store the user data in localStorage to restore after re-import + const restoreKey = `xtream-restore-${item._id}`; + localStorage.setItem( + restoreKey, + JSON.stringify({ + favoritedXtreamIds, + recentlyViewedXtreamIds, + }) + ); + this.snackBar.open( this.translate.instant( 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.STARTED'