mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
feat(xtream): add category management to hide/show categories
Add the ability to hide irrelevant or empty categories in Xtream playlists. Users can now manage category visibility through a dialog accessible from the sidebar header in both VOD/Series and Live TV views. Features: - New category management dialog with search filter and select/deselect all - Visibility settings persisted to database per playlist - Hidden categories are filtered from sidebar navigation - New categories added during playlist refresh default to visible - Database migration for existing installations Files changed: - Database: schema.ts, connection.ts (hidden column + migration) - Backend: category.events.ts, main.preload.ts (IPC handlers) - Frontend: new dialog component, store method, UI integration - Translations: en.json - Documentation: docs/architecture/category-management.md
This commit is contained in:
1 parent
934c02d9ec
commit
2043b7d8a1
17 files changed
+849
-78
No files matched your search
@@ -131,6 +131,14 @@ contextBridge.exposeInMainWorld('electron', {
|
||||
ipcRenderer.invoke('DB_GET_CATEGORIES', playlistId, type),
|
||||
dbSaveCategories: (playlistId: string, categories: any[], type: string) =>
|
||||
ipcRenderer.invoke('DB_SAVE_CATEGORIES', playlistId, categories, type),
|
||||
dbGetAllCategories: (playlistId: string, type: string) =>
|
||||
ipcRenderer.invoke('DB_GET_ALL_CATEGORIES', playlistId, type),
|
||||
dbUpdateCategoryVisibility: (categoryIds: number[], hidden: boolean) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_UPDATE_CATEGORY_VISIBILITY',
|
||||
categoryIds,
|
||||
hidden
|
||||
),
|
||||
dbHasContent: (playlistId: string, type: string) =>
|
||||
ipcRenderer.invoke('DB_HAS_CONTENT', playlistId, type),
|
||||
dbGetContent: (playlistId: string, type: string) =>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Operations for managing categories within playlists
|
||||
*/
|
||||
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||
import { ipcMain } from 'electron';
|
||||
import { getDatabase } from '../../database/connection';
|
||||
import * as schema from '../../database/schema';
|
||||
@@ -34,7 +34,7 @@ ipcMain.handle(
|
||||
);
|
||||
|
||||
/**
|
||||
* Get categories for a playlist
|
||||
* Get categories for a playlist (excluding hidden)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'DB_GET_CATEGORIES',
|
||||
@@ -47,7 +47,8 @@ ipcMain.handle(
|
||||
.where(
|
||||
and(
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
eq(schema.categories.type, type)
|
||||
eq(schema.categories.type, type),
|
||||
eq(schema.categories.hidden, false)
|
||||
)
|
||||
)
|
||||
.orderBy(sql`name COLLATE NOCASE`);
|
||||
@@ -90,3 +91,49 @@ ipcMain.handle(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Update category visibility (hidden status)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'DB_UPDATE_CATEGORY_VISIBILITY',
|
||||
async (event, categoryIds: number[], hidden: boolean) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
await db
|
||||
.update(schema.categories)
|
||||
.set({ hidden })
|
||||
.where(inArray(schema.categories.id, categoryIds));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error updating category visibility:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get all categories for a playlist (including hidden, for management dialog)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'DB_GET_ALL_CATEGORIES',
|
||||
async (event, playlistId: string, type: 'live' | 'movies' | 'series') => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const result = await db
|
||||
.select()
|
||||
.from(schema.categories)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
eq(schema.categories.type, type)
|
||||
)
|
||||
)
|
||||
.orderBy(sql`name COLLATE NOCASE`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error getting all categories:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
<h2 mat-dialog-title>{{ 'XTREAM.CATEGORY_MANAGEMENT.TITLE' | translate }}</h2>
|
||||
|
||||
<mat-dialog-content>
|
||||
@if (isLoading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="40" />
|
||||
<p>{{ 'XTREAM.CATEGORY_MANAGEMENT.LOADING' | translate }}</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="header-actions">
|
||||
<div class="selection-info">
|
||||
{{ 'XTREAM.CATEGORY_MANAGEMENT.SELECTED' | translate }}:
|
||||
<strong>{{ selectedCount() }}</strong> / {{ totalCount() }}
|
||||
</div>
|
||||
<div class="bulk-actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="selectAll()"
|
||||
[disabled]="allSelected()"
|
||||
>
|
||||
<mat-icon>check_box</mat-icon>
|
||||
{{ 'XTREAM.CATEGORY_MANAGEMENT.SELECT_ALL' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="deselectAll()"
|
||||
[disabled]="selectedCount() === 0"
|
||||
>
|
||||
<mat-icon>check_box_outline_blank</mat-icon>
|
||||
{{ 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_ALL' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="search-field">
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
[ngModel]="searchTerm()"
|
||||
(ngModelChange)="searchTerm.set($event)"
|
||||
placeholder="{{
|
||||
'XTREAM.CATEGORY_MANAGEMENT.SEARCH_PLACEHOLDER' | translate
|
||||
}}"
|
||||
/>
|
||||
@if (searchTerm()) {
|
||||
<button matSuffix mat-icon-button (click)="clearSearch()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="categories-list">
|
||||
@for (category of filteredCategories(); track category.id) {
|
||||
<div class="category-item" (click)="toggleCategory(category)">
|
||||
<mat-checkbox
|
||||
[checked]="category.selected"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="toggleCategory(category)"
|
||||
>
|
||||
</mat-checkbox>
|
||||
<span class="category-name">{{ category.name }}</span>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="empty-message">
|
||||
@if (searchTerm()) {
|
||||
{{
|
||||
'XTREAM.CATEGORY_MANAGEMENT.NO_RESULTS' | translate
|
||||
}}
|
||||
} @else {
|
||||
{{
|
||||
'XTREAM.CATEGORY_MANAGEMENT.NO_CATEGORIES'
|
||||
| translate
|
||||
}}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="cancel()" [disabled]="isSaving()">
|
||||
{{ 'CLOSE' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
(click)="save()"
|
||||
[disabled]="isLoading() || isSaving()"
|
||||
>
|
||||
@if (isSaving()) {
|
||||
<mat-spinner diameter="20" />
|
||||
} @else {
|
||||
{{ 'XTREAM.CATEGORY_MANAGEMENT.SAVE' | translate }}
|
||||
}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.selection-info {
|
||||
font-size: 13px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
strong {
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-field {
|
||||
width: 100%;
|
||||
|
||||
mat-icon[matPrefix] {
|
||||
margin-right: 8px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
.categories-list {
|
||||
height: 450px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
gap: 8px;
|
||||
transition: background-color 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--mat-sys-surface-container);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--mat-sys-surface-container);
|
||||
}
|
||||
|
||||
mat-checkbox {
|
||||
--mdc-checkbox-state-layer-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.category-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:host ::ng-deep {
|
||||
.mat-mdc-dialog-content {
|
||||
min-width: 480px;
|
||||
max-width: 600px;
|
||||
max-height: 75vh !important;
|
||||
}
|
||||
}
|
||||
|
||||
mat-dialog-actions {
|
||||
button {
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import { Component, computed, inject, OnInit, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { DatabaseService, XCategoryFromDb } from 'services';
|
||||
|
||||
export interface CategoryManagementDialogData {
|
||||
playlistId: string;
|
||||
contentType: 'live' | 'vod' | 'series';
|
||||
}
|
||||
|
||||
interface CategoryWithSelection extends XCategoryFromDb {
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-management-dialog',
|
||||
imports: [
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
FormsModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './category-management-dialog.component.html',
|
||||
styleUrl: './category-management-dialog.component.scss',
|
||||
})
|
||||
export class CategoryManagementDialogComponent implements OnInit {
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly dialogRef = inject(
|
||||
MatDialogRef<CategoryManagementDialogComponent>
|
||||
);
|
||||
readonly data = inject<CategoryManagementDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
readonly isLoading = signal(true);
|
||||
readonly isSaving = signal(false);
|
||||
readonly categories = signal<CategoryWithSelection[]>([]);
|
||||
readonly searchTerm = signal('');
|
||||
|
||||
readonly filteredCategories = computed(() => {
|
||||
const term = this.searchTerm().toLowerCase();
|
||||
if (!term) return this.categories();
|
||||
return this.categories().filter((c) =>
|
||||
c.name.toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
|
||||
readonly selectedCount = computed(
|
||||
() => this.categories().filter((c) => c.selected).length
|
||||
);
|
||||
|
||||
readonly totalCount = computed(() => this.categories().length);
|
||||
|
||||
readonly allSelected = computed(
|
||||
() =>
|
||||
this.categories().length > 0 &&
|
||||
this.categories().every((c) => c.selected)
|
||||
);
|
||||
|
||||
readonly someSelected = computed(
|
||||
() => this.categories().some((c) => c.selected) && !this.allSelected()
|
||||
);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.loadCategories();
|
||||
}
|
||||
|
||||
private async loadCategories(): Promise<void> {
|
||||
try {
|
||||
const type = this.getDbType();
|
||||
const allCategories = await this.dbService.getAllXtreamCategories(
|
||||
this.data.playlistId,
|
||||
type
|
||||
);
|
||||
this.categories.set(
|
||||
allCategories.map((c) => ({
|
||||
...c,
|
||||
selected: !c.hidden,
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error loading categories:', error);
|
||||
} finally {
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
clearSearch(): void {
|
||||
this.searchTerm.set('');
|
||||
}
|
||||
|
||||
private getDbType(): 'live' | 'movies' | 'series' {
|
||||
switch (this.data.contentType) {
|
||||
case 'live':
|
||||
return 'live';
|
||||
case 'vod':
|
||||
return 'movies';
|
||||
case 'series':
|
||||
return 'series';
|
||||
}
|
||||
}
|
||||
|
||||
toggleCategory(category: CategoryWithSelection): void {
|
||||
this.categories.update((cats) =>
|
||||
cats.map((c) =>
|
||||
c.id === category.id ? { ...c, selected: !c.selected } : c
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
selectAll(): void {
|
||||
this.categories.update((cats) =>
|
||||
cats.map((c) => ({ ...c, selected: true }))
|
||||
);
|
||||
}
|
||||
|
||||
deselectAll(): void {
|
||||
this.categories.update((cats) =>
|
||||
cats.map((c) => ({ ...c, selected: false }))
|
||||
);
|
||||
}
|
||||
|
||||
toggleAll(): void {
|
||||
if (this.allSelected()) {
|
||||
this.deselectAll();
|
||||
} else {
|
||||
this.selectAll();
|
||||
}
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
this.isSaving.set(true);
|
||||
try {
|
||||
const categories = this.categories();
|
||||
|
||||
// Get IDs of categories to hide (unselected)
|
||||
const toHide = categories
|
||||
.filter((c) => !c.selected)
|
||||
.map((c) => c.id);
|
||||
|
||||
// Get IDs of categories to show (selected)
|
||||
const toShow = categories
|
||||
.filter((c) => c.selected)
|
||||
.map((c) => c.id);
|
||||
|
||||
// Update visibility in database
|
||||
if (toHide.length > 0) {
|
||||
await this.dbService.updateCategoryVisibility(toHide, true);
|
||||
}
|
||||
if (toShow.length > 0) {
|
||||
await this.dbService.updateCategoryVisibility(toShow, false);
|
||||
}
|
||||
|
||||
this.dialogRef.close(true);
|
||||
} catch (error) {
|
||||
console.error('Error saving category visibility:', error);
|
||||
} finally {
|
||||
this.isSaving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,14 @@
|
||||
<mat-icon>list</mat-icon>
|
||||
</button>
|
||||
<h2>{{ 'PORTALS.ALL_CATEGORIES' | translate }}</h2>
|
||||
<button
|
||||
mat-icon-button
|
||||
class="manage-categories-btn"
|
||||
(click)="openCategoryManagement()"
|
||||
[matTooltip]="'XTREAM.CATEGORY_MANAGEMENT.TITLE' | translate"
|
||||
>
|
||||
<mat-icon>tune</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (selectedCategoryId()) {
|
||||
|
||||
@@ -6,15 +6,21 @@ import {
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { XtreamCategory } from 'shared-interfaces';
|
||||
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
|
||||
import { SettingsStore } from '../../services/settings-store.service';
|
||||
import {
|
||||
CategoryManagementDialogComponent,
|
||||
CategoryManagementDialogData,
|
||||
} from '../category-management-dialog/category-management-dialog.component';
|
||||
import { CategoryViewComponent } from '../category-view/category-view.component';
|
||||
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
|
||||
import { FavoritesService } from '../services/favorites.service';
|
||||
@@ -33,6 +39,7 @@ import { XtreamStore } from '../xtream.store';
|
||||
MatIconButton,
|
||||
MatInputModule,
|
||||
MatListModule,
|
||||
MatTooltipModule,
|
||||
/* MpvPlayerBarComponent, */
|
||||
PortalChannelsListComponent,
|
||||
TranslatePipe,
|
||||
@@ -44,11 +51,12 @@ export class LiveStreamLayoutComponent implements OnInit {
|
||||
private readonly favoritesService = inject(FavoritesService);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
readonly categories = this.xtreamStore.getCategoriesBySelectedType;
|
||||
readonly epgItems = this.xtreamStore.epgItems;
|
||||
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
readonly player = this.settingsStore.player;
|
||||
streamUrl: string;
|
||||
@@ -86,4 +94,28 @@ export class LiveStreamLayoutComponent implements OnInit {
|
||||
backToCategories() {
|
||||
this.xtreamStore.setSelectedCategory(null);
|
||||
}
|
||||
|
||||
openCategoryManagement(): void {
|
||||
const playlistId = this.route.parent?.snapshot.params['id'];
|
||||
const contentType = this.xtreamStore.selectedContentType();
|
||||
|
||||
const dialogRef = this.dialog.open<
|
||||
CategoryManagementDialogComponent,
|
||||
CategoryManagementDialogData,
|
||||
boolean
|
||||
>(CategoryManagementDialogComponent, {
|
||||
data: {
|
||||
playlistId,
|
||||
contentType,
|
||||
},
|
||||
width: '500px',
|
||||
maxHeight: '80vh',
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (result) {
|
||||
this.xtreamStore.reloadCategories();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,23 @@
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||
app-region: drag;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.manage-categories-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,14 @@
|
||||
<mat-icon>list</mat-icon>
|
||||
</button>
|
||||
<h2>{{ 'PORTALS.ALL_CATEGORIES' | translate }}</h2>
|
||||
<button
|
||||
mat-icon-button
|
||||
class="manage-categories-btn"
|
||||
(click)="openCategoryManagement()"
|
||||
[matTooltip]="'XTREAM.CATEGORY_MANAGEMENT.TITLE' | translate"
|
||||
>
|
||||
<mat-icon>tune</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<app-category-view
|
||||
[items]="categories()"
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { XtreamCategory } from 'shared-interfaces';
|
||||
import {
|
||||
CategoryManagementDialogComponent,
|
||||
CategoryManagementDialogData,
|
||||
} from './category-management-dialog/category-management-dialog.component';
|
||||
import { CategoryViewComponent } from './category-view/category-view.component';
|
||||
import { XtreamStore } from './xtream.store';
|
||||
|
||||
@@ -18,6 +24,7 @@ import { XtreamStore } from './xtream.store';
|
||||
/* MpvPlayerBarComponent, */
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatTooltipModule,
|
||||
],
|
||||
})
|
||||
export class XtreamMainContainerComponent implements OnInit {
|
||||
@@ -25,6 +32,7 @@ export class XtreamMainContainerComponent implements OnInit {
|
||||
readonly route = inject(ActivatedRoute);
|
||||
readonly translateService = inject(TranslateService);
|
||||
readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
readonly categories = this.xtreamStore.getCategoriesBySelectedType;
|
||||
|
||||
@@ -81,4 +89,30 @@ export class XtreamMainContainerComponent implements OnInit {
|
||||
relativeTo: this.route,
|
||||
});
|
||||
}
|
||||
|
||||
openCategoryManagement(): void {
|
||||
// The playlist id is in the parent route (xtreams/:id)
|
||||
const playlistId = this.route.parent?.snapshot.params['id'];
|
||||
const contentType = this.xtreamStore.selectedContentType();
|
||||
|
||||
const dialogRef = this.dialog.open<
|
||||
CategoryManagementDialogComponent,
|
||||
CategoryManagementDialogData,
|
||||
boolean
|
||||
>(CategoryManagementDialogComponent, {
|
||||
data: {
|
||||
playlistId,
|
||||
contentType,
|
||||
},
|
||||
width: '500px',
|
||||
maxHeight: '80vh',
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (result) {
|
||||
// Reload categories from database to reflect visibility changes
|
||||
this.xtreamStore.reloadCategories();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -639,7 +639,10 @@ export const XtreamStore = signalStore(
|
||||
);
|
||||
localStorage.removeItem(restoreKey);
|
||||
} catch (err) {
|
||||
console.error('Error restoring user data:', err);
|
||||
console.error(
|
||||
'Error restoring user data:',
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -886,6 +889,33 @@ export const XtreamStore = signalStore(
|
||||
removeFromFavorites(favoriteId: string) {
|
||||
console.log('not needed now', favoriteId);
|
||||
},
|
||||
/**
|
||||
* Reload categories from database after visibility changes
|
||||
*/
|
||||
async reloadCategories() {
|
||||
const playlistId = route.snapshot.params.id;
|
||||
if (!playlistId) return;
|
||||
|
||||
try {
|
||||
const [
|
||||
liveCategories,
|
||||
vodCategories,
|
||||
serialCategories,
|
||||
] = await Promise.all([
|
||||
dbService.getXtreamCategories(playlistId, 'live'),
|
||||
dbService.getXtreamCategories(playlistId, 'movies'),
|
||||
dbService.getXtreamCategories(playlistId, 'series'),
|
||||
]);
|
||||
|
||||
patchState(store, {
|
||||
liveCategories: liveCategories as any,
|
||||
vodCategories: vodCategories as any,
|
||||
serialCategories: serialCategories as any,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reloading categories:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
|
||||
@@ -356,6 +356,17 @@
|
||||
"HTTP_PORT": "HTTP",
|
||||
"HTTPS_PORT": "HTTPS",
|
||||
"RTMP_PORT": "RTMP"
|
||||
},
|
||||
"CATEGORY_MANAGEMENT": {
|
||||
"TITLE": "Manage Categories",
|
||||
"LOADING": "Loading categories...",
|
||||
"SELECTED": "Selected",
|
||||
"SELECT_ALL": "Select All",
|
||||
"DESELECT_ALL": "Deselect All",
|
||||
"SEARCH_PLACEHOLDER": "Search categories...",
|
||||
"NO_RESULTS": "No matching categories found",
|
||||
"NO_CATEGORIES": "No categories available",
|
||||
"SAVE": "Save"
|
||||
}
|
||||
},
|
||||
"PORTALS": {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# Category Management Feature
|
||||
|
||||
## Overview
|
||||
|
||||
Xtream API playlists often contain many categories, some of which may be empty, in another language, or simply not relevant to the user. The category management feature allows users to hide unwanted categories from the sidebar while keeping them in the database for potential future use.
|
||||
|
||||
## User Flow
|
||||
|
||||
1. User navigates to an Xtream playlist (Live TV, Movies, or Series section)
|
||||
2. In the sidebar header, next to "All categories", there's a **tune icon button**
|
||||
3. Clicking it opens the **Category Management Dialog**
|
||||
4. User sees all categories with checkboxes (checked = visible, unchecked = hidden)
|
||||
5. User can:
|
||||
- Individually toggle categories
|
||||
- Use "Select All" / "Deselect All" buttons
|
||||
- Search/filter categories by name
|
||||
6. On save, visibility preferences are persisted to the database
|
||||
7. Hidden categories no longer appear in the sidebar
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Database Schema
|
||||
|
||||
Added `hidden` column to the `categories` table:
|
||||
|
||||
```sql
|
||||
ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0
|
||||
```
|
||||
|
||||
- `hidden = 0` (false): Category is visible (default)
|
||||
- `hidden = 1` (true): Category is hidden
|
||||
|
||||
**Migration**: Uses a safe migration pattern in `connection.ts` that catches errors for already-applied migrations, ensuring existing users get the new column automatically.
|
||||
|
||||
### Backend (Electron)
|
||||
|
||||
**File**: `apps/electron-backend/src/app/events/database/category.events.ts`
|
||||
|
||||
| IPC Handler | Purpose |
|
||||
| ------------------------------- | -------------------------------------------------- |
|
||||
| `DB_GET_CATEGORIES` | Returns visible categories only (`hidden = false`) |
|
||||
| `DB_GET_ALL_CATEGORIES` | Returns all categories (for management dialog) |
|
||||
| `DB_UPDATE_CATEGORY_VISIBILITY` | Batch updates `hidden` status for category IDs |
|
||||
|
||||
### Frontend Services
|
||||
|
||||
**File**: `libs/services/src/lib/database-electron.service.ts`
|
||||
|
||||
| Method | Purpose |
|
||||
| ---------------------------- | ------------------------------ |
|
||||
| `getXtreamCategories()` | Sidebar display (filtered) |
|
||||
| `getAllXtreamCategories()` | Management dialog (unfiltered) |
|
||||
| `updateCategoryVisibility()` | Save visibility changes |
|
||||
|
||||
### Components
|
||||
|
||||
**Category Management Dialog**
|
||||
|
||||
- Path: `apps/web/src/app/xtream-tauri/category-management-dialog/`
|
||||
- Features:
|
||||
- Checkbox list of all categories
|
||||
- Select All / Deselect All buttons
|
||||
- Search/filter with clearable input
|
||||
- Shows selected count vs total
|
||||
- Saves changes to database on confirm
|
||||
|
||||
**Integration Points**
|
||||
|
||||
- `xtream-main-container.component.ts` - Movies & Series sections
|
||||
- `live-stream-layout.component.ts` - Live TV section
|
||||
|
||||
Both components:
|
||||
|
||||
1. Add a "tune" icon button in the sidebar header
|
||||
2. Open the dialog with playlist ID and content type
|
||||
3. Call `xtreamStore.reloadCategories()` after dialog closes with changes
|
||||
|
||||
### Store
|
||||
|
||||
**File**: `apps/web/src/app/xtream-tauri/xtream.store.ts`
|
||||
|
||||
Added `reloadCategories()` method to refresh categories from database after visibility changes, ensuring the sidebar updates immediately.
|
||||
|
||||
## Behavior Notes
|
||||
|
||||
- **New categories**: When a playlist is refreshed, new categories from the remote API are added with `hidden = false` (visible by default)
|
||||
- **Persistence**: Visibility settings survive playlist refresh
|
||||
- **Per-playlist, per-type**: Categories are managed per playlist and per content type (live/movies/series)
|
||||
- **No content deletion**: Hiding a category only affects sidebar visibility; the category and its content remain in the database
|
||||
|
||||
## Files Changed
|
||||
|
||||
```
|
||||
libs/shared/database/src/lib/
|
||||
├── schema.ts # Added hidden column to categories table
|
||||
└── connection.ts # Added migration for existing databases
|
||||
|
||||
apps/electron-backend/src/app/
|
||||
├── events/database/category.events.ts # New IPC handlers
|
||||
└── api/main.preload.ts # Exposed new IPC methods
|
||||
|
||||
libs/services/src/lib/
|
||||
└── database-electron.service.ts # New service methods
|
||||
|
||||
apps/web/src/app/xtream-tauri/
|
||||
├── category-management-dialog/ # New dialog component
|
||||
│ ├── category-management-dialog.component.ts
|
||||
│ ├── category-management-dialog.component.html
|
||||
│ └── category-management-dialog.component.scss
|
||||
├── xtream-main-container.component.ts # Added button & dialog
|
||||
├── xtream-main-container.component.html
|
||||
├── live-stream-layout/
|
||||
│ ├── live-stream-layout.component.ts # Added button & dialog
|
||||
│ └── live-stream-layout.component.html
|
||||
├── sidebar.scss # Updated header styles
|
||||
└── xtream.store.ts # Added reloadCategories method
|
||||
|
||||
apps/web/src/assets/i18n/
|
||||
└── en.json # Added translation keys
|
||||
|
||||
global.d.ts # TypeScript types for new IPC methods
|
||||
```
|
||||
|
||||
## Translation Keys
|
||||
|
||||
```json
|
||||
{
|
||||
"XTREAM": {
|
||||
"CATEGORY_MANAGEMENT": {
|
||||
"TITLE": "Manage Categories",
|
||||
"LOADING": "Loading categories...",
|
||||
"SELECTED": "Selected",
|
||||
"SELECT_ALL": "Select All",
|
||||
"DESELECT_ALL": "Deselect All",
|
||||
"SEARCH_PLACEHOLDER": "Search categories...",
|
||||
"NO_RESULTS": "No matching categories found",
|
||||
"NO_CATEGORIES": "No categories available",
|
||||
"SAVE": "Save"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Vendored
+8
@@ -123,6 +123,14 @@ declare global {
|
||||
categories: any[],
|
||||
type: string
|
||||
) => Promise<{ success: boolean }>;
|
||||
dbGetAllCategories: (
|
||||
playlistId: string,
|
||||
type: string
|
||||
) => Promise<any[]>;
|
||||
dbUpdateCategoryVisibility: (
|
||||
categoryIds: number[],
|
||||
hidden: boolean
|
||||
) => Promise<{ success: boolean }>;
|
||||
dbHasContent: (
|
||||
playlistId: string,
|
||||
type: string
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface XCategoryFromDb {
|
||||
playlist_id: string;
|
||||
type: 'movies' | 'live' | 'series';
|
||||
xtream_id: number;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface XtreamContent {
|
||||
@@ -168,6 +169,35 @@ export class DatabaseService {
|
||||
await window.electron.dbSaveCategories(playlistId, categories, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all categories for a playlist (including hidden, for management dialog)
|
||||
*/
|
||||
async getAllXtreamCategories(
|
||||
playlistId: string,
|
||||
type: 'live' | 'movies' | 'series'
|
||||
): Promise<XCategoryFromDb[]> {
|
||||
return await window.electron.dbGetAllCategories(playlistId, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update category visibility (show/hide categories)
|
||||
*/
|
||||
async updateCategoryVisibility(
|
||||
categoryIds: number[],
|
||||
hidden: boolean
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await window.electron.dbUpdateCategoryVisibility(
|
||||
categoryIds,
|
||||
hidden
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error updating category visibility:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content exists
|
||||
*/
|
||||
|
||||
@@ -26,21 +26,21 @@ let initPromise: Promise<DatabaseInstance> | null = null;
|
||||
* Get the database file path
|
||||
*/
|
||||
export function getDatabasePath(): string {
|
||||
const dbDir = join(homedir(), '.iptvnator', 'databases');
|
||||
const dbDir = join(homedir(), '.iptvnator', 'databases');
|
||||
|
||||
// Ensure the directory exists
|
||||
if (!existsSync(dbDir)) {
|
||||
mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
// Ensure the directory exists
|
||||
if (!existsSync(dbDir)) {
|
||||
mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
return join(dbDir, 'iptvnator.db');
|
||||
return join(dbDir, 'iptvnator.db');
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL statements for creating all tables
|
||||
*/
|
||||
const CREATE_TABLE_STATEMENTS = [
|
||||
`CREATE TABLE IF NOT EXISTS playlists (
|
||||
`CREATE TABLE IF NOT EXISTS playlists (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
serverUrl TEXT,
|
||||
@@ -58,15 +58,16 @@ const CREATE_TABLE_STATEMENTS = [
|
||||
url TEXT,
|
||||
last_usage TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS categories (
|
||||
`CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('live', 'movies', 'series')),
|
||||
xtream_id INTEGER NOT NULL,
|
||||
hidden INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS content (
|
||||
`CREATE TABLE IF NOT EXISTS content (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
@@ -77,7 +78,7 @@ const CREATE_TABLE_STATEMENTS = [
|
||||
type TEXT NOT NULL CHECK (type IN ('live', 'movie', 'series')),
|
||||
FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS recently_viewed (
|
||||
`CREATE TABLE IF NOT EXISTS recently_viewed (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content_id INTEGER NOT NULL,
|
||||
playlist_id TEXT NOT NULL,
|
||||
@@ -85,7 +86,7 @@ const CREATE_TABLE_STATEMENTS = [
|
||||
FOREIGN KEY (content_id) REFERENCES content (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS favorites (
|
||||
`CREATE TABLE IF NOT EXISTS favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content_id INTEGER NOT NULL,
|
||||
playlist_id TEXT NOT NULL,
|
||||
@@ -93,20 +94,20 @@ const CREATE_TABLE_STATEMENTS = [
|
||||
FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_content_type ON content(type)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_content_category ON content(category_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_content_title ON content(title)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_content_xtream ON content(xtream_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique ON favorites(content_id, playlist_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS favorites_playlist_idx ON favorites(playlist_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS favorites_content_idx ON favorites(content_id)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS recently_viewed_content_playlist_unique ON recently_viewed(content_id, playlist_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS recently_viewed_playlist_idx ON recently_viewed(playlist_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS recently_viewed_viewed_at_idx ON recently_viewed(viewed_at)`,
|
||||
// EPG tables
|
||||
`CREATE TABLE IF NOT EXISTS epg_channels (
|
||||
`CREATE INDEX IF NOT EXISTS idx_content_type ON content(type)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_content_category ON content(category_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_content_title ON content(title)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_content_xtream ON content(xtream_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique ON favorites(content_id, playlist_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS favorites_playlist_idx ON favorites(playlist_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS favorites_content_idx ON favorites(content_id)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS recently_viewed_content_playlist_unique ON recently_viewed(content_id, playlist_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS recently_viewed_playlist_idx ON recently_viewed(playlist_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS recently_viewed_viewed_at_idx ON recently_viewed(viewed_at)`,
|
||||
// EPG tables
|
||||
`CREATE TABLE IF NOT EXISTS epg_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
icon_url TEXT,
|
||||
@@ -114,7 +115,7 @@ const CREATE_TABLE_STATEMENTS = [
|
||||
source_url TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS epg_programs (
|
||||
`CREATE TABLE IF NOT EXISTS epg_programs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel_id TEXT NOT NULL,
|
||||
start TEXT NOT NULL,
|
||||
@@ -127,30 +128,30 @@ const CREATE_TABLE_STATEMENTS = [
|
||||
episode_num TEXT,
|
||||
FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE
|
||||
)`,
|
||||
// EPG indexes
|
||||
`CREATE INDEX IF NOT EXISTS idx_epg_channels_source ON epg_channels(source_url)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_epg_channels_name ON epg_channels(display_name)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_epg_programs_channel ON epg_programs(channel_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_epg_programs_start ON epg_programs(start)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_epg_programs_time_range ON epg_programs(channel_id, start, stop)`,
|
||||
// FTS5 virtual table for full-text search on EPG programs
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5(
|
||||
// EPG indexes
|
||||
`CREATE INDEX IF NOT EXISTS idx_epg_channels_source ON epg_channels(source_url)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_epg_channels_name ON epg_channels(display_name)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_epg_programs_channel ON epg_programs(channel_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_epg_programs_start ON epg_programs(start)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_epg_programs_time_range ON epg_programs(channel_id, start, stop)`,
|
||||
// FTS5 virtual table for full-text search on EPG programs
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5(
|
||||
title,
|
||||
description,
|
||||
category,
|
||||
content='epg_programs',
|
||||
content_rowid='id'
|
||||
)`,
|
||||
// Triggers to keep FTS index in sync with epg_programs table
|
||||
`CREATE TRIGGER IF NOT EXISTS epg_programs_ai AFTER INSERT ON epg_programs BEGIN
|
||||
// Triggers to keep FTS index in sync with epg_programs table
|
||||
`CREATE TRIGGER IF NOT EXISTS epg_programs_ai AFTER INSERT ON epg_programs BEGIN
|
||||
INSERT INTO epg_programs_fts(rowid, title, description, category)
|
||||
VALUES (new.id, new.title, new.description, new.category);
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS epg_programs_ad AFTER DELETE ON epg_programs BEGIN
|
||||
`CREATE TRIGGER IF NOT EXISTS epg_programs_ad AFTER DELETE ON epg_programs BEGIN
|
||||
INSERT INTO epg_programs_fts(epg_programs_fts, rowid, title, description, category)
|
||||
VALUES ('delete', old.id, old.title, old.description, old.category);
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS epg_programs_au AFTER UPDATE ON epg_programs BEGIN
|
||||
`CREATE TRIGGER IF NOT EXISTS epg_programs_au AFTER UPDATE ON epg_programs BEGIN
|
||||
INSERT INTO epg_programs_fts(epg_programs_fts, rowid, title, description, category)
|
||||
VALUES ('delete', old.id, old.title, old.description, old.category);
|
||||
INSERT INTO epg_programs_fts(rowid, title, description, category)
|
||||
@@ -158,20 +159,43 @@ const CREATE_TABLE_STATEMENTS = [
|
||||
END`,
|
||||
];
|
||||
|
||||
/**
|
||||
* Migration statements that may fail if already applied
|
||||
* These are run with try-catch to handle existing columns
|
||||
*/
|
||||
const MIGRATION_STATEMENTS = [
|
||||
// v1.0.0 -> v1.1.0: Add hidden column to categories for category management
|
||||
`ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0`,
|
||||
];
|
||||
|
||||
/**
|
||||
* Create tables if they don't exist
|
||||
*/
|
||||
function createTables(sqliteDb: Database.Database): void {
|
||||
for (const stmt of CREATE_TABLE_STATEMENTS) {
|
||||
sqliteDb.exec(stmt);
|
||||
}
|
||||
for (const stmt of CREATE_TABLE_STATEMENTS) {
|
||||
sqliteDb.exec(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run migrations that may fail if already applied
|
||||
*/
|
||||
function runMigrations(sqliteDb: Database.Database): void {
|
||||
for (const stmt of MIGRATION_STATEMENTS) {
|
||||
try {
|
||||
sqliteDb.exec(stmt);
|
||||
} catch (error) {
|
||||
// Ignore errors for migrations that may have already been applied
|
||||
// (e.g., "duplicate column name" for ALTER TABLE ADD COLUMN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface DatabaseOptions {
|
||||
/** Open database in read-only mode (for agent-backend) */
|
||||
readonly?: boolean;
|
||||
/** Skip table creation (useful for read-only connections) */
|
||||
skipTableCreation?: boolean;
|
||||
/** Open database in read-only mode (for agent-backend) */
|
||||
readonly?: boolean;
|
||||
/** Skip table creation (useful for read-only connections) */
|
||||
skipTableCreation?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,31 +204,32 @@ export interface DatabaseOptions {
|
||||
* Used by electron-backend
|
||||
*/
|
||||
export async function initDatabase(
|
||||
options: DatabaseOptions = {}
|
||||
options: DatabaseOptions = {}
|
||||
): Promise<DatabaseInstance> {
|
||||
const { readonly = false, skipTableCreation = false } = options;
|
||||
const { readonly = false, skipTableCreation = false } = options;
|
||||
|
||||
if (db) return db;
|
||||
if (initPromise) return initPromise;
|
||||
if (db) return db;
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
initPromise = (async () => {
|
||||
const filePath = getDatabasePath();
|
||||
sqlite = new Database(filePath, { readonly });
|
||||
initPromise = (async () => {
|
||||
const filePath = getDatabasePath();
|
||||
sqlite = new Database(filePath, { readonly });
|
||||
|
||||
// Enable foreign keys
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
// Enable foreign keys
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
// Create tables only for read-write connections
|
||||
if (!readonly && !skipTableCreation) {
|
||||
createTables(sqlite);
|
||||
}
|
||||
// Create tables only for read-write connections
|
||||
if (!readonly && !skipTableCreation) {
|
||||
createTables(sqlite);
|
||||
runMigrations(sqlite);
|
||||
}
|
||||
|
||||
const database = drizzle(sqlite, { schema });
|
||||
db = database;
|
||||
return database;
|
||||
})();
|
||||
const database = drizzle(sqlite, { schema });
|
||||
db = database;
|
||||
return database;
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,10 +238,10 @@ export async function initDatabase(
|
||||
* Used by electron-backend
|
||||
*/
|
||||
export async function getDatabase(
|
||||
options?: DatabaseOptions
|
||||
options?: DatabaseOptions
|
||||
): Promise<DatabaseInstance> {
|
||||
if (db) return db;
|
||||
return initDatabase(options);
|
||||
if (db) return db;
|
||||
return initDatabase(options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,17 +249,17 @@ export async function getDatabase(
|
||||
* Used by agent-backend for safe read-only queries
|
||||
*/
|
||||
export async function getReadOnlyDatabase(): Promise<DatabaseInstance> {
|
||||
return getDatabase({ readonly: true, skipTableCreation: true });
|
||||
return getDatabase({ readonly: true, skipTableCreation: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
export function closeDatabase(): void {
|
||||
if (sqlite) {
|
||||
sqlite.close();
|
||||
sqlite = null;
|
||||
db = null;
|
||||
initPromise = null;
|
||||
}
|
||||
if (sqlite) {
|
||||
sqlite.close();
|
||||
sqlite = null;
|
||||
db = null;
|
||||
initPromise = null;
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export const categories = sqliteTable(
|
||||
name: text('name').notNull(),
|
||||
type: text('type', { enum: ['live', 'movies', 'series'] }).notNull(),
|
||||
xtreamId: integer('xtream_id').notNull(),
|
||||
hidden: integer('hidden', { mode: 'boolean' }).default(false),
|
||||
},
|
||||
(table) => ({
|
||||
playlistIdx: index('idx_categories_playlist').on(table.playlistId),
|
||||
|
||||
Reference in new issue
Block a user