Add database for library

This commit is contained in:
Myx
2025-03-28 04:46:29 +01:00
committed by Myx
parent c0cfca39a2
commit 35fd50c728
17 changed files with 382 additions and 80 deletions

View File

@@ -22,8 +22,10 @@
"build:windows": "ng build -c production && tsc -p src-electron/tsconfig.electron.json && node src-electron/rename-to-mjs.js && electron-builder build --windows",
"build:mac": "ng build -c production && tsc -p src-electron/tsconfig.electron.json && node src-electron/rename-to-mjs.js && electron-builder build --mac",
"build:linux": "ng build -c production && tsc -p src-electron/tsconfig.electron.json && node src-electron/rename-to-mjs.js && electron-builder build --linux",
"release": "ng build -c production && tsc -p src-electron/tsconfig.electron.json && node src-electron/rename-to-mjs.js && electron-builder build"
},
"release": "ng build -c production && tsc -p src-electron/tsconfig.electron.json && node src-electron/rename-to-mjs.js && electron-builder build",
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"migration:add": "npx typeorm-ts-node-esm migration:generate ./src-electron/database/migrations/%npm_config_name% -d ./src-electron/database/dataSource.ts"
},
"dependencies": {
"@angular/animations": "19.2.1",
"@angular/common": "19.2.1",
@@ -46,11 +48,14 @@
"fs-extra": "11.2.0",
"lodash": "4.17.21",
"parse-sng": "4.0.3",
"reflect-metadata": "^0.2.2",
"rxjs": "7.8.1",
"sanitize-filename": "1.6.3",
"scan-chart": "6.1.0",
"sqlite3": "5.1.6",
"three": "0.166.1",
"tslib": "2.6.3",
"typeorm": "^0.3.21",
"zod": "3.23.8",
"zone.js": "0.15.0"
},

View File

@@ -1,22 +1,22 @@
<div class="border-t border-t-neutral p-2 flex gap-2 items-center max-w-full justify-between">
<div class="flex items-center gap-[8px]">
{{ (this.libraryService.tracks$ | async)?.length ?? 0 }} Songs
<button class="btn btn-sm btn-primary" (click)="fileInput.click()">Import Setlist</button>
@if ((this.libraryService.selectedSongs$ | async)!.length > 0) {
<button class="btn btn-sm btn-primary" (click)="exportSelected()">Export Selected</button>
<button class="btn btn-sm btn-primary" (click)="exportSelected()">Export Selected Setlist</button>
} @else {
<button class="btn btn-sm btn-primary" (click)="exportPlaylist()">Export</button>
<button class="btn btn-sm btn-primary" (click)="exportLibrary()">Export Setlist</button>
}
<button class="btn btn-sm btn-primary" (click)="importPlaylist()">Import</button>
<input type="file" #fileInput accept=".library" class="hidden" (change)="onFileSelected($event)" />
@if ((this.libraryService.selectedSongs$ | async)!.length === 0) {
<button type="button" class="btn btn-sm min-w-[108px] hover:btn-error" (click)="this.libraryService.clearPlaylist()">
<button type="button" class="btn btn-sm min-w-[108px] hover:btn-error" (click)="this.libraryService.clearLibrary()">
<i class="bi bi-trash"></i>
Delete all
</button>
} @else {
<button type="button" class="btn btn-sm min-w-[108px] hover:btn-error" (click)="this.libraryService.removeFromPlaylist()">
<button type="button" class="btn btn-sm min-w-[108px] hover:btn-error" (click)="this.libraryService.removeFromLibrary()">
<i class="bi bi-trash"></i>
Delete selected
</button>

View File

@@ -1,6 +1,7 @@
import { Component, ElementRef, ViewChild } from '@angular/core'
import { DownloadService } from '../../../core/services/download.service'
import { LibraryService } from 'src-angular/app/core/services/library.service'
import { ChartData } from 'src-shared/interfaces/search.interface'
@Component({
selector: 'app-library-bar',
@@ -12,7 +13,7 @@ export class LibraryBarComponent {
constructor(public libraryService: LibraryService, public downloadService: DownloadService) { }
exportPlaylist() {
exportLibrary() {
this.libraryService.storeLibrary()
}
@@ -20,29 +21,24 @@ export class LibraryBarComponent {
this.libraryService.storeSelectedSongs()
}
importPlaylist() {
this.libraryfileInput.nativeElement.click()
}
onFileSelected(event: Event) {
async onFileSelected(event: Event) {
const input = event.target as HTMLInputElement
if (input.files && input.files.length > 0) {
const file = input.files[0]
const reader = new FileReader()
reader.onload = () => {
try {
const importedTracks = JSON.parse(reader.result as string)
if (Array.isArray(importedTracks)) {
this.libraryService.downloadLibrary(importedTracks)
} else {
console.error('Invalid file format')
}
} catch (error) {
console.error('Error parsing file:', error)
}
}
reader.readAsText(file)
if (!input.files?.length)
return
const file = input.files[0]
try {
const fileContent = await file.text()
const json = JSON.parse(fileContent)
const chartData = json as ChartData[]
this.libraryService.downloadLibrary(chartData)
} catch (error) {
console.error('Error reading or parsing the file:', error)
}
this.libraryfileInput.nativeElement.value = ''
}
}

View File

@@ -7,7 +7,7 @@
(keyup)="filterSongs()" />
</div>
<div
*ngIf="filteredSongs.length > 0"
*ngIf="songs.length > 0"
class="basis-2/3 flex-1 overflow-y-auto scrollbar scrollbar-w-2 scrollbar-h-2 scrollbar-track-base-300 scrollbar-thumb-neutral scrollbar-thumb-rounded-full h-[calc(100vh-164px)]">
<table id="resultTable" class="table table-zebra table-pin-rows" [class.table-xs]="settingsService.isCompactTable">
<thead>
@@ -36,7 +36,7 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let song of filteredSongs; trackBy: trackByFn">
<tr *ngFor="let song of songs; trackBy: trackByFn">
<td>
<input
type="checkbox"
@@ -55,13 +55,6 @@
</table>
</div>
<div
class="align-center h-[calc(100vh-169.5px)]"
*ngIf="songs.length > 1 && filteredSongs.length < 1"
style="display: flex; justify-content: center; align-items: center">
<p class="text-center" style="font-size: 1.5rem">No songs found!</p>
</div>
<div class="flex align-center justify-center items-center h-[calc(100vh-169.5px)]" *ngIf="songs.length < 1 && filteredSongs.length === 0">
<div class="flex align-center justify-center items-center h-[calc(100vh-169.5px)]" *ngIf="songs.length < 1">
<p class="text-center" style="font-size: 1.5rem">No songs added!</p>
</div>

View File

@@ -16,7 +16,6 @@ export class LibraryTableComponent implements OnInit, OnDestroy {
songs: ChartData[] = []
sortDirection: 'asc' | 'desc' = 'asc'
sortColumn: SortColumn = null
filteredSongs: ChartData[] = []
searchTerm: string = ''
allRowsSelected: boolean = false
subscriptions: Subscription[] = []
@@ -45,10 +44,9 @@ export class LibraryTableComponent implements OnInit, OnDestroy {
this.libraryService.tracks$
.subscribe(tracks => {
this.songs = tracks
this.filterSongs()
})
)
this.filteredSongs = [...this.songs]
this.subscriptions.push(
this.libraryService.selectedSongs$
.subscribe(songs =>
@@ -58,20 +56,11 @@ export class LibraryTableComponent implements OnInit, OnDestroy {
}
filterSongs(): void {
const term = this.searchTerm.toLowerCase()
this.filteredSongs = this.songs.filter(
song =>
song.name?.toLowerCase().includes(term) ||
song.artist?.toLowerCase().includes(term) ||
song.album?.toLowerCase().includes(term) ||
song.genre?.toLowerCase().includes(term) ||
song.year?.toLowerCase().includes(term) ||
song.charter?.toLowerCase().includes(term)
)
this.libraryService.getChartsBySearchTerm(this.searchTerm)
}
onColClicked(column: SortColumn) {
if (this.filteredSongs.length === 0) { return }
if (this.songs.length === 0) { return }
if (this.sortColumn !== column) {
this.sortColumn = column
@@ -84,7 +73,7 @@ export class LibraryTableComponent implements OnInit, OnDestroy {
}
if (this.sortColumn) {
this.filteredSongs.sort((a, b) => {
this.songs.sort((a, b) => {
const valueA = a[this.sortColumn! as keyof ChartData]
const valueB = b[this.sortColumn! as keyof ChartData]
@@ -116,7 +105,7 @@ export class LibraryTableComponent implements OnInit, OnDestroy {
this.allRowsSelected = !this.allRowsSelected
if (this.allRowsSelected) {
this.filteredSongs.forEach(song => this.libraryService.addToSelectedSongs(song))
this.songs.forEach(song => this.libraryService.addToSelectedSongs(song))
} else {
this.libraryService.clearSelectedSongs()
}

View File

@@ -2,8 +2,7 @@ import { Injectable, Injector } from '@angular/core'
import { BehaviorSubject } from 'rxjs'
import { ChartData } from 'src-shared/interfaces/search.interface'
import { DownloadService } from './download.service'
const LibraryStorageIdentifyer: string = "library"
import { StorageService } from './storage.service'
@Injectable({
providedIn: 'root',
@@ -17,42 +16,39 @@ export class LibraryService {
private _downloadService: DownloadService | null = null
constructor(private injector: Injector) {
const library = localStorage.getItem(LibraryStorageIdentifyer)
if (library) {
this._tracks.next(JSON.parse(library))
}
constructor(private injector: Injector, private storageService: StorageService) {
this.storageService.getChartsBySearchTerm().then(library => {
if (library) {
this._tracks.next(library)
}
})
}
private get downloadService(): DownloadService {
if (!this._downloadService) {
this._downloadService = this.injector.get(DownloadService)
}
return this._downloadService
}
getPlaylist() {
return this._tracks.value
return this._downloadService
}
libraryAdd(chart: ChartData) {
const updatedTracks = [...this._tracks.value, chart]
this._tracks.next(updatedTracks)
localStorage.setItem(LibraryStorageIdentifyer, JSON.stringify(updatedTracks))
this.storageService.addChart(chart)
}
downloadLibrary(songs: ChartData[]) {
songs.forEach(track => {
if (!this._tracks.value.includes(track)) {
this.downloadService.addDownload(track)
}
this.downloadService.addDownload(track)
})
}
storeLibrary() {
const fakeLink = document.createElement('a')
const file = new Blob([JSON.stringify(this._tracks.value)], { type: 'application/json' })
fakeLink.href = URL.createObjectURL(file)
fakeLink.download = 'songs.library'
fakeLink.click()
@@ -61,6 +57,7 @@ export class LibraryService {
storeSelectedSongs() {
const fakeLink = document.createElement('a')
const file = new Blob([JSON.stringify(this._selectedSongs.value)], { type: 'application/json' })
fakeLink.href = URL.createObjectURL(file)
fakeLink.download = 'selected.library'
fakeLink.click()
@@ -80,16 +77,28 @@ export class LibraryService {
this._selectedSongs.next([])
}
removeFromPlaylist() {
this._selectedSongs.value.forEach(selectedSong => {
const updatedTracks = this._tracks.value.filter(track => track !== selectedSong)
removeFromLibrary() {
this._selectedSongs.value.forEach((selectedSong: ChartData) => {
const updatedTracks = this._tracks.value.filter(track => track !== selectedSong) as ChartData[]
this._tracks.next(updatedTracks)
localStorage.setItem(LibraryStorageIdentifyer, JSON.stringify(updatedTracks))
this.storageService.removeChart(selectedSong.md5)
})
this.clearSelectedSongs()
}
clearPlaylist() {
this._tracks.next([])
localStorage.removeItem(LibraryStorageIdentifyer)
async clearLibrary() {
this.storageService.removeAllCharts()
this.clearSelectedSongs()
const library = await this.storageService.getChartsBySearchTerm()
this._tracks.next(library)
}
async getChartsBySearchTerm(searchTerm?: string): Promise<ChartData[]> {
const library = await this.storageService.getChartsBySearchTerm(searchTerm)
this._tracks.next(library)
return library
}
}

View File

@@ -0,0 +1,27 @@
import { Injectable } from '@angular/core'
import { ChartData } from 'src-shared/interfaces/search.interface'
@Injectable({
providedIn: 'root',
})
export class StorageService {
async addChart(chartData: ChartData): Promise<ChartData> {
return window.electron.invoke.addChart(chartData)
}
async removeChart(md5: string): Promise<void> {
return window.electron.invoke.removeChart(md5)
}
async removeCharts(charts: ChartData[]): Promise<void> {
return window.electron.invoke.removeCharts(charts)
}
async getChartsBySearchTerm(searchTerm?: string): Promise<ChartData[]> {
return window.electron.invoke.getChartsBySearchTerm(searchTerm)
}
async removeAllCharts(): Promise<void> {
return window.electron.emit.removeAllCharts()
}
}

View File

@@ -1,6 +1,7 @@
import { IpcInvokeHandlers, IpcToMainEmitHandlers } from '../src-shared/interfaces/ipc.interface.js'
import { download } from './ipc/DownloadHandler.ipc.js'
import { scanIssues } from './ipc/issue-scan/IssueScanHandler.ipc.js'
import { addChart, getChartsBySearchTerm, removeAllCharts, removeChart, removeCharts } from './ipc/LibraryHandler.ipc.js'
import { getSettings, setSettings } from './ipc/SettingsHandler.ipc.js'
import { downloadUpdate, getCurrentVersion, getUpdateAvailable, quitAndInstall, retryUpdate } from './ipc/UpdateHandler.ipc.js'
import { getPlatform, getThemeColors, isMaximized, maximize, minimize, openUrl, quit, restore, showFile, showFolder, showOpenDialog, toggleDevTools } from './ipc/UtilHandlers.ipc.js'
@@ -14,6 +15,10 @@ export function getIpcInvokeHandlers(): IpcInvokeHandlers {
isMaximized,
showOpenDialog,
getThemeColors,
addChart,
removeChart,
removeCharts,
getChartsBySearchTerm,
}
}
@@ -33,5 +38,6 @@ export function getIpcToMainEmitHandlers(): IpcToMainEmitHandlers {
showFile,
showFolder,
scanIssues,
removeAllCharts,
}
}

View File

@@ -0,0 +1,18 @@
import { DataSource } from 'typeorm'
import { Chart } from './entities/Chart.js'
import { Init1743124434920 } from './migrations/1743124434920-init.js'
const migrations = [Init1743124434920]
const entities = [Chart]
export const dataSource = new DataSource({
type: "sqlite",
database: "library.sqlite",
entities: entities,
// Configure migrations to use a folder that contains your migration files:
migrations: migrations,
// Keep synchronize off when using migrations in production
synchronize: false,
logging: true,
migrationsRun: true,
})

View File

@@ -0,0 +1,125 @@
import { ChartData } from 'src-shared/interfaces/search.interface.js'
import { dataSource } from './dataSource.js'
import { Chart } from './entities/Chart.js'
import { Like } from 'typeorm'
export class DatabaseService {
async insertChart(chartData: ChartData): Promise<ChartData> {
try {
if (!dataSource.isInitialized) {
await dataSource.initialize()
}
const chartRepository = dataSource.getRepository(Chart)
// if one already exist dont create
const existingChart = await chartRepository.findOneBy({ md5: chartData.md5 })
if (existingChart) {
return existingChart as unknown as ChartData
}
const newChart = chartRepository.create({
name: chartData.name!,
album: chartData.album!,
artist: chartData.artist!,
genre: chartData.genre!,
year: chartData.year!,
charter: chartData.charter!,
md5: chartData.md5,
hasVideoBackground: chartData.hasVideoBackground,
})
return await chartRepository.save(newChart) as unknown as ChartData
} catch (error) {
console.error('Error inserting chart:', error)
throw error
}
}
async removeChart(md5: string): Promise<void> {
try {
if (!dataSource.isInitialized) {
await dataSource.initialize()
}
const chartRepository = dataSource.getRepository(Chart)
await chartRepository.delete({ md5 })
} catch (error) {
console.error('Error removing chart:', error)
throw error
}
}
async removeCharts(charts: ChartData[]): Promise<void> {
try {
if (!dataSource.isInitialized) {
await dataSource.initialize()
}
const chartRepository = dataSource.getRepository(Chart)
// delete the array of charts provided using querybulilder
charts.forEach(async chart => {
console.log('removing chart:', chart.name)
await chartRepository.delete({ md5: chart.md5 })
})
} catch (error) {
console.error('Error removing charts:', error)
throw error
}
}
async getChartsBySearchTerm(searchTerm?: string): Promise<ChartData[]> {
try {
if (!dataSource.isInitialized) {
await dataSource.initialize()
}
const chartRepository = dataSource.getRepository(Chart)
let charts: Chart[]
if (searchTerm) {
const likeSearchTerm = `%${searchTerm}%`
charts = await chartRepository.find({
where: [
{ name: Like(likeSearchTerm) },
{ album: Like(likeSearchTerm) },
{ artist: Like(likeSearchTerm) },
{ genre: Like(likeSearchTerm) },
{ year: Like(likeSearchTerm) },
{ charter: Like(likeSearchTerm) },
],
})
} else {
charts = await chartRepository.find()
}
return charts as unknown as ChartData[]
} catch (error) {
console.error('Error fetching charts by search term:', error)
throw error
}
}
async removeAllCharts(): Promise<void> {
try {
if (!dataSource.isInitialized) {
await dataSource.initialize()
}
const chartRepository = dataSource.getRepository(Chart)
await chartRepository.clear()
} catch (error) {
console.error('Error removing all charts:', error)
throw error
}
}
}
export const databaseService = new DatabaseService()

View File

@@ -0,0 +1,31 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity()
export class Chart {
@PrimaryGeneratedColumn('uuid')
id: string
@Column()
md5: string
@Column()
hasVideoBackground: boolean
@Column()
charter: string
@Column()
name: string
@Column()
artist: string
@Column()
album: string
@Column()
genre: string
@Column()
year: string
}

View File

@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Init1743124434920 implements MigrationInterface {
name = 'Init1743124434920'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "chart" ("id" varchar PRIMARY KEY NOT NULL, "md5" varchar NOT NULL, "hasVideoBackground" boolean NOT NULL, "charter" varchar NOT NULL, "name" varchar NOT NULL, "artist" varchar NOT NULL, "album" varchar NOT NULL, "genre" varchar NOT NULL, "year" varchar NOT NULL)`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "chart"`);
}
}

View File

@@ -0,0 +1,10 @@
## Migrations
In order to create a new migration, there is a some steps to go through.
1. Run ``npm run migration:add --name <migration name>`` This currently work for Windows machines. If using Linux or Mac run this instead ``npx typeorm-ts-node-esm migration:generate ./src-electron/database/migrations/<migration name> -d ./src-electron/database/dataSource.ts``
2. Go to ``./src-electron/database/dataSource.ts`` and add the newly
generated migration to the migrations array and entity variables. In that way it will automatically apply the latest changes to the database on startup.
## The database
A Sqlite database file is automatically created on startup named Library, it will be placed in the same directory as the executable.

View File

@@ -0,0 +1,47 @@
import { databaseService } from '../database/databaseService.js'
import { ChartData } from 'src-shared/interfaces/search.interface.js'
export async function addChart(chartData: ChartData): Promise<ChartData> {
try {
return await databaseService.insertChart(chartData)
} catch (error) {
console.error('Error in addChartHandler:', error)
throw error
}
}
export async function removeChart(md5: string): Promise<void> {
try {
await databaseService.removeChart(md5)
} catch (error) {
console.error('Error in removeChartHandler:', error)
throw error
}
}
export async function removeCharts(charts: ChartData[]): Promise<void> {
try {
await databaseService.removeCharts(charts)
} catch (error) {
console.error('Error in removeChartsHandler:', error)
throw error
}
}
export async function removeAllCharts(): Promise<void> {
try {
await databaseService.removeAllCharts()
} catch (error) {
console.error('Error in removeAllChartsHandler:', error)
throw error
}
}
export async function getChartsBySearchTerm(searchTerm?: string): Promise<ChartData[]> {
try {
return await databaseService.getChartsBySearchTerm(searchTerm)
} catch (error) {
console.error('Error in getChartsHandler:', error)
throw error
}
}

View File

@@ -3,12 +3,13 @@ import electronUnhandled from 'electron-unhandled'
import windowStateKeeper from 'electron-window-state'
import * as path from 'path'
import * as url from 'url'
import "reflect-metadata"
import { IpcFromMainEmitEvents } from '../src-shared/interfaces/ipc.interface.js'
import { dataPath } from '../src-shared/Paths.js'
import { settings } from './ipc/SettingsHandler.ipc.js'
import { retryUpdate } from './ipc/UpdateHandler.ipc.js'
import { getIpcInvokeHandlers, getIpcToMainEmitHandlers } from './IpcHandler.js'
import { dataSource } from './database/dataSource.js'
electronUnhandled({ showDialog: true, logger: err => console.log('Error: Unhandled Rejection:', err) })
@@ -26,6 +27,14 @@ app.on('ready', async () => {
if (!isDevBuild) {
retryUpdate()
}
// Initialize the database
dataSource.initialize().then(() => {
console.log('Database initialized')
}
).catch(error => {
console.error('Error initializing database:', error)
})
})
/**

View File

@@ -25,6 +25,10 @@ const electronApi: ContextBridgeApi = {
isMaximized: getInvoker('isMaximized'),
showOpenDialog: getInvoker('showOpenDialog'),
getThemeColors: getInvoker('getThemeColors'),
addChart: getInvoker('addChart'),
removeChart: getInvoker('removeChart'),
removeCharts: getInvoker('removeCharts'),
getChartsBySearchTerm: getInvoker('getChartsBySearchTerm'),
},
emit: {
download: getEmitter('download'),
@@ -41,6 +45,7 @@ const electronApi: ContextBridgeApi = {
showFolder: getEmitter('showFolder'),
showFile: getEmitter('showFile'),
scanIssues: getEmitter('scanIssues'),
removeAllCharts: getEmitter('removeAllCharts'),
},
on: {
errorLog: getListenerAdder('errorLog'),

View File

@@ -5,6 +5,7 @@ import { Settings } from '../Settings.js'
import { Download, DownloadProgress } from './download.interface.js'
import { ThemeColors } from './theme.interface.js'
import { UpdateProgress } from './update.interface.js'
import { ChartData } from './search.interface.js'
export interface ContextBridgeApi {
invoke: IpcInvokeHandlers
@@ -50,6 +51,22 @@ export interface IpcInvokeEvents {
input: string
output: ThemeColors | null
}
addChart: {
input: ChartData
output: ChartData
}
removeChart: {
input: string
output: void
}
removeCharts: {
input: ChartData[]
output: void
}
getChartsBySearchTerm: {
input?: string
output: ChartData[]
}
}
export type IpcInvokeHandlers = {
@@ -75,6 +92,7 @@ export interface IpcToMainEmitEvents {
showFolder: string
showFile: string
scanIssues: void
removeAllCharts: void
}
export type IpcToMainEmitHandlers = {