Merge pull request #649 from 4gray/feat/xtream-preserve-user-data

feat/xtream-preserve-user-data
This commit is contained in:
4gray authored and GitHub committed 2025-11-13 20:38:06 +01:00
commit 43b92edde3
8 files changed
+249 -53

No files matched your search

@@ -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) =>
@@ -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;
}
}
@@ -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:',
@@ -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 ) {
<mat-card
appearance="outlined"
class="category-item"
(click)="categoryClicked.emit(item)"
>
<mat-card-content>
{{ item.category_name || item.name || 'No category name' }}
</mat-card-content>
</mat-card>
} @if ( !(items() | filterBy: searchPhrase() : 'category_name')?.length
) {
<app-playlist-error-view
title="No results"
[description]="
'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate
"
[showActionButtons]="false"
[viewType]="'NO_SEARCH_RESULTS'"
/>
} } @else {
<app-playlist-error-view
[title]="'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE' | translate"
[description]="
'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.DESCRIPTION' | translate
"
[showActionButtons]="false"
[viewType]="'EMPTY_CATEGORY'"
/>
@if (items().length > 0) {
@for (
item of items() | filterBy: searchPhrase() : 'category_name';
track $index
) {
<mat-card
appearance="outlined"
class="category-item"
(click)="categoryClicked.emit(item)"
>
<mat-card-content>
{{
item.category_name ||
item.name ||
'No category name'
}}
</mat-card-content>
</mat-card>
}
@if (
!(items() | filterBy: searchPhrase() : 'category_name')?.length
) {
<app-playlist-error-view
title="No results"
[description]="
'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate
"
[showActionButtons]="false"
[viewType]="'NO_SEARCH_RESULTS'"
/>
}
} @else {
<app-playlist-error-view
[title]="'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE' | translate"
[description]="
'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.DESCRIPTION' | translate
"
[showActionButtons]="false"
[viewType]="'EMPTY_CATEGORY'"
/>
}
`,
styleUrl: './category-view.component.scss',
@@ -48,7 +58,7 @@ import { PortalStore } from '../portal.store';
MatCardModule,
MatIconModule,
PlaylistErrorViewComponent,
TranslateModule,
TranslatePipe,
],
})
export class CategoryViewComponent {
Vendored
+9
View File
@@ -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,
@@ -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<void> {
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<void> {
await window.electron.dbRestoreXtreamUserData(
playlistId,
favoritedXtreamIds,
recentlyViewedXtreamIds
);
}
/**
+1
View File
@@ -7,6 +7,7 @@
"inlineSources": true,
"types": []
},
"files": ["../../global.d.ts"],
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.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'