yeah
This commit is contained in:
@@ -1,168 +0,0 @@
|
||||
# Migration vers SQLite - Guide d'utilisation
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
```bash
|
||||
pnpm add better-sqlite3
|
||||
pnpm add -D @types/better-sqlite3
|
||||
```
|
||||
|
||||
## 🚀 Migration des données
|
||||
|
||||
### 1. Exécuter la migration
|
||||
|
||||
```bash
|
||||
pnpm tsx server/database/migrate.ts
|
||||
```
|
||||
|
||||
Cette commande va :
|
||||
|
||||
- Créer la base de données SQLite dans `server/database/evilspins.db`
|
||||
- Créer les tables (boxes, sides, artists, tracks)
|
||||
- Importer toutes vos données existantes
|
||||
|
||||
### 2. Vérifier la migration
|
||||
|
||||
Lancez votre serveur Nuxt :
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Testez les nouveaux endpoints :
|
||||
|
||||
```bash
|
||||
# Récupérer toutes les boxes
|
||||
curl http://localhost:3000/api/boxes
|
||||
|
||||
# Récupérer tous les artistes
|
||||
curl http://localhost:3000/api/artists
|
||||
|
||||
# Récupérer les tracks de compilation (première page)
|
||||
curl http://localhost:3000/api/tracks/compilation
|
||||
|
||||
# Filtrer par boxId
|
||||
curl http://localhost:3000/api/tracks/compilation?boxId=ES00
|
||||
|
||||
# Pagination
|
||||
curl http://localhost:3000/api/tracks/compilation?page=2&limit=10
|
||||
```
|
||||
|
||||
## 📁 Structure créée
|
||||
|
||||
```
|
||||
server/
|
||||
├── database/
|
||||
│ ├── evilspins.db # Base SQLite (créée automatiquement)
|
||||
│ ├── schema.sql # Schéma de la base
|
||||
│ └── migrate.ts # Script de migration
|
||||
├── utils/
|
||||
│ └── database.ts # Utilitaire de connexion
|
||||
└── api/
|
||||
├── boxes.ts # ✅ Nouveau (SQLite)
|
||||
├── artists.ts # ✅ Nouveau (SQLite)
|
||||
└── tracks/
|
||||
├── compilation.ts # ✅ Nouveau (SQLite avec pagination)
|
||||
└── playlist.ts # ⚠️ À adapter
|
||||
```
|
||||
|
||||
## 🔄 Côté client : utiliser la pagination
|
||||
|
||||
Exemple pour charger les tracks progressivement :
|
||||
|
||||
```typescript
|
||||
// Au lieu de charger tout d'un coup
|
||||
const { data } = await useFetch('/api/tracks/compilation')
|
||||
|
||||
// Maintenant avec pagination
|
||||
const { data } = await useFetch('/api/tracks/compilation', {
|
||||
query: {
|
||||
page: 1,
|
||||
limit: 50,
|
||||
boxId: 'ES00',
|
||||
side: 'A'
|
||||
}
|
||||
})
|
||||
|
||||
// data.tracks -> tableau de tracks
|
||||
// data.pagination -> { page, limit, total, totalPages }
|
||||
```
|
||||
|
||||
## 📊 Avantages obtenus
|
||||
|
||||
✅ **Performances** : Plus de chargement massif, pagination efficace
|
||||
✅ **Scalabilité** : Peut gérer des milliers de tracks sans ralentir
|
||||
✅ **Filtrage** : Recherche et filtres côté serveur (ultra rapide)
|
||||
✅ **Déploiement** : Un seul fichier `.db` à déployer
|
||||
|
||||
## 🔧 À faire ensuite
|
||||
|
||||
### 1. Adapter l'endpoint playlist
|
||||
|
||||
L'endpoint `tracks/playlist.ts` lit des fichiers sur disque. Options :
|
||||
|
||||
**Option A** : Scanner le dossier au démarrage et insérer dans SQLite
|
||||
**Option B** : Garder la lecture filesystem mais optimiser avec un cache
|
||||
|
||||
### 2. Modifier le frontend
|
||||
|
||||
Mettre à jour vos composants Vue pour utiliser la pagination :
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const page = ref(1)
|
||||
const { data, refresh } = await useFetch('/api/tracks/compilation', {
|
||||
query: { page, limit: 20 }
|
||||
})
|
||||
|
||||
function loadMore() {
|
||||
page.value++
|
||||
refresh()
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 3. Ajouter des fonctionnalités
|
||||
|
||||
Exemples de requêtes possibles maintenant :
|
||||
|
||||
```typescript
|
||||
// Recherche par titre
|
||||
GET /api/tracks/compilation?search=love
|
||||
|
||||
// Tracks d'un artiste
|
||||
GET /api/tracks/compilation?artistId=5
|
||||
|
||||
// Tri personnalisé
|
||||
GET /api/tracks/compilation?sortBy=title&order=asc
|
||||
```
|
||||
|
||||
## 🐛 Dépannage
|
||||
|
||||
### Erreur "Cannot find module 'better-sqlite3'"
|
||||
|
||||
```bash
|
||||
pnpm add better-sqlite3
|
||||
```
|
||||
|
||||
### La base ne se crée pas
|
||||
|
||||
Vérifiez les permissions :
|
||||
|
||||
```bash
|
||||
chmod -R 755 server/database
|
||||
```
|
||||
|
||||
### Données manquantes après migration
|
||||
|
||||
Re-exécutez la migration :
|
||||
|
||||
```bash
|
||||
pnpm tsx scripts/migrate.ts
|
||||
```
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- La base SQLite est créée automatiquement au premier lancement
|
||||
- Elle est incluse dans `.gitignore` par défaut (à ajuster selon vos besoins)
|
||||
- Pour un déploiement, commitez le fichier `.db` OU re-exécutez la migration en production
|
||||
@@ -1,23 +0,0 @@
|
||||
import { eventHandler } from 'h3'
|
||||
import { getDatabase } from '../utils/database'
|
||||
|
||||
export default eventHandler(() => {
|
||||
const db = getDatabase()
|
||||
|
||||
const artists = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, name, url, cover_id
|
||||
FROM artists
|
||||
ORDER BY id
|
||||
`
|
||||
)
|
||||
.all()
|
||||
|
||||
return artists.map((artist: any) => ({
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
url: artist.url,
|
||||
coverId: `https://f4.bcbits.com/img/${artist.cover_id}_4.jpg`
|
||||
}))
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
import { eventHandler } from 'h3'
|
||||
import { getDatabase } from '../utils/database'
|
||||
|
||||
export default eventHandler(() => {
|
||||
const db = getDatabase()
|
||||
|
||||
// Récupérer les boxes
|
||||
const boxes = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, type, name, description, state, duration, active_side, color1, color2
|
||||
FROM boxes
|
||||
ORDER BY id
|
||||
`
|
||||
)
|
||||
.all()
|
||||
|
||||
// Récupérer les sides pour chaque box
|
||||
const sides = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT box_id, side, name, description, duration, color1, color2
|
||||
FROM sides
|
||||
`
|
||||
)
|
||||
.all()
|
||||
|
||||
// Grouper les sides par box_id
|
||||
const sidesByBoxId: Record<string, any> = {}
|
||||
for (const side of sides) {
|
||||
if (!sidesByBoxId[side.box_id]) {
|
||||
sidesByBoxId[side.box_id] = {}
|
||||
}
|
||||
sidesByBoxId[side.box_id][side.side] = {
|
||||
name: side.name,
|
||||
description: side.description,
|
||||
duration: side.duration,
|
||||
color1: side.color1,
|
||||
color2: side.color2
|
||||
}
|
||||
}
|
||||
|
||||
// Formater les résultats
|
||||
return boxes.map((box: any) => ({
|
||||
id: box.id,
|
||||
type: box.type,
|
||||
name: box.name,
|
||||
description: box.description,
|
||||
state: box.state,
|
||||
duration: box.duration,
|
||||
activeSide: box.active_side,
|
||||
...(box.type === 'compilation'
|
||||
? {
|
||||
sides: sidesByBoxId[box.id] || {}
|
||||
}
|
||||
: {
|
||||
color1: box.color1,
|
||||
color2: box.color2
|
||||
})
|
||||
}))
|
||||
})
|
||||
41
server/api/card/[slug].ts
Normal file
41
server/api/card/[slug].ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../db'
|
||||
|
||||
export default eventHandler(async (event) => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
|
||||
if (!slug) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'ESID manquant dans la requête'
|
||||
})
|
||||
}
|
||||
|
||||
const db = useDB()
|
||||
const card = await db.select().from(schema.cards).where(eq(schema.cards.slug, slug)).get()
|
||||
|
||||
if (!card) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Morceau non trouvé'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
id: card.id,
|
||||
esid: card.esid,
|
||||
title: card.title,
|
||||
artist: card.artist,
|
||||
url_audio: card.url_audio,
|
||||
url_image: card.url_image,
|
||||
year: card.year,
|
||||
month: card.month,
|
||||
day: card.day,
|
||||
hour: card.hour,
|
||||
slug: card.slug,
|
||||
suit: card.suit,
|
||||
rank: card.rank,
|
||||
createdAt: card.createdAt,
|
||||
updatedAt: card.updatedAt
|
||||
}
|
||||
})
|
||||
41
server/api/cards/[esid].ts
Normal file
41
server/api/cards/[esid].ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../db'
|
||||
|
||||
export default eventHandler(async (event) => {
|
||||
const esid = getRouterParam(event, 'esid')
|
||||
|
||||
if (!esid) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'ESID manquant dans la requête'
|
||||
})
|
||||
}
|
||||
|
||||
const db = useDB()
|
||||
const card = await db.select().from(schema.cards).where(eq(schema.cards.esid, esid)).get()
|
||||
|
||||
if (!card) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Morceau non trouvé'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
id: card.id,
|
||||
esid: card.esid,
|
||||
title: card.title,
|
||||
artist: card.artist,
|
||||
url_audio: card.url_audio,
|
||||
url_image: card.url_image,
|
||||
year: card.year,
|
||||
month: card.month,
|
||||
day: card.day,
|
||||
hour: card.hour,
|
||||
slug: card.slug,
|
||||
suit: card.suit,
|
||||
rank: card.rank,
|
||||
createdAt: card.createdAt,
|
||||
updatedAt: card.updatedAt
|
||||
}
|
||||
})
|
||||
84
server/api/cards/index.ts
Normal file
84
server/api/cards/index.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { and, eq, ilike, or, sql } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../../db'
|
||||
|
||||
const PAGE_SIZE = 20 // Nombre d'éléments par page
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const page = Number(query.page) || 1
|
||||
const search = query.search?.toString()
|
||||
const cardRank = query.rank?.toString()
|
||||
const cardSuit = query.suit?.toString()
|
||||
const year = query.year?.toString()
|
||||
|
||||
const db = useDB()
|
||||
const offset = (page - 1) * PAGE_SIZE
|
||||
|
||||
// Log pour débogage
|
||||
console.log('Requête avec paramètres:', { search, cardRank, cardSuit, year })
|
||||
console.log('Schéma des cards:', Object.keys(schema.cards))
|
||||
|
||||
// Construction des conditions de filtrage
|
||||
const conditions = []
|
||||
|
||||
if (search) {
|
||||
const searchTerm = `%${search}%`
|
||||
conditions.push(
|
||||
or(ilike(schema.cards.title, searchTerm), ilike(schema.cards.artist, searchTerm))
|
||||
)
|
||||
}
|
||||
|
||||
if (cardRank) {
|
||||
conditions.push(eq(schema.cards.rank, cardRank))
|
||||
}
|
||||
|
||||
if (cardSuit) {
|
||||
conditions.push(eq(schema.cards.suit, cardSuit))
|
||||
}
|
||||
|
||||
if (year) {
|
||||
conditions.push(eq(schema.cards.year, year))
|
||||
}
|
||||
|
||||
// Requête pour le comptage total
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.cards)
|
||||
.$dynamic()
|
||||
|
||||
// Log pour débogage SQL
|
||||
console.log('Requête de comptage SQL:', countQuery.toSQL())
|
||||
|
||||
// Requête pour les données paginées
|
||||
const cardsQuery = db
|
||||
.select()
|
||||
.from(schema.cards)
|
||||
.$dynamic()
|
||||
.limit(PAGE_SIZE)
|
||||
.offset(offset)
|
||||
.orderBy(schema.cards.title)
|
||||
|
||||
// Application des conditions si elles existent
|
||||
if (conditions.length > 0) {
|
||||
const where = and(...conditions)
|
||||
countQuery.where(where)
|
||||
cardsQuery.where(where)
|
||||
}
|
||||
|
||||
const [countResult, cards] = await Promise.all([countQuery, cardsQuery])
|
||||
|
||||
const totalItems = countResult[0]?.count || 0
|
||||
const totalPages = Math.ceil(totalItems / PAGE_SIZE)
|
||||
|
||||
return {
|
||||
data: cards,
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
pageSize: PAGE_SIZE,
|
||||
totalItems,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1
|
||||
}
|
||||
}
|
||||
})
|
||||
27
server/api/sync-cards.post.ts
Normal file
27
server/api/sync-cards.post.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { syncCardsWithDatabase } from '../services/cardSync.service'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const folderPath = config.pathFiles || process.env.PATH_FILES
|
||||
|
||||
if (!folderPath) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: 'PATH_FILES not configured'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await syncCardsWithDatabase(folderPath)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...result
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
21
server/api/test/test-db-sync.post.ts
Normal file
21
server/api/test/test-db-sync.post.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { syncCardsWithDatabase } from '../../services/cardSync.service'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const folderPath = config.pathFiles || process.env.PATH_FILES || 'mnt/media/files/music'
|
||||
|
||||
try {
|
||||
const result = await syncCardsWithDatabase(folderPath)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...result
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
}
|
||||
}
|
||||
})
|
||||
29
server/api/test/test-scanner.get.ts
Normal file
29
server/api/test/test-scanner.get.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { scanMusicFolder } from '../../utils/fileScanner'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const folderPath = config.pathFiles || process.env.PATH_FILES || 'mnt/media/files/music'
|
||||
|
||||
try {
|
||||
// Test 1: Vérifier que le dossier existe
|
||||
const { access } = await import('node:fs/promises')
|
||||
await access(folderPath)
|
||||
|
||||
// Test 2: Scanner le dossier
|
||||
const cards = await scanMusicFolder(folderPath)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
folderPath,
|
||||
cardsFound: cards.length,
|
||||
cards: cards.slice(0, 5), // Afficher seulement les 5 premiers
|
||||
sample: cards[0] // Un exemple complet
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
folderPath
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,93 +0,0 @@
|
||||
import { eventHandler, getQuery } from 'h3'
|
||||
import { getDatabase } from '../../utils/database'
|
||||
|
||||
export default eventHandler((event) => {
|
||||
const db = getDatabase()
|
||||
const query = getQuery(event)
|
||||
|
||||
// Paramètres de pagination
|
||||
const page = parseInt(query.page as string) || 1
|
||||
const limit = parseInt(query.limit as string) || 50
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
// Filtres optionnels
|
||||
const boxId = query.boxId as string | undefined
|
||||
const side = query.side as string | undefined
|
||||
|
||||
// Construction de la requête
|
||||
let sql = `
|
||||
SELECT
|
||||
t.id,
|
||||
t.box_id,
|
||||
t.side,
|
||||
t.track_order,
|
||||
t.title,
|
||||
t.artist_id,
|
||||
t.start,
|
||||
t.link,
|
||||
t.cover_id,
|
||||
t.url,
|
||||
t.type,
|
||||
a.name as artist_name
|
||||
FROM tracks t
|
||||
LEFT JOIN artists a ON t.artist_id = a.id
|
||||
WHERE t.type = 'compilation'
|
||||
`
|
||||
|
||||
const params: any[] = []
|
||||
|
||||
if (boxId) {
|
||||
sql += ' AND t.box_id = ?'
|
||||
params.push(boxId)
|
||||
}
|
||||
|
||||
if (side) {
|
||||
sql += ' AND t.side = ?'
|
||||
params.push(side)
|
||||
}
|
||||
|
||||
sql += ' ORDER BY t.box_id, t.side, t.track_order'
|
||||
sql += ' LIMIT ? OFFSET ?'
|
||||
params.push(limit, offset)
|
||||
|
||||
const tracks = db.prepare(sql).all(...params)
|
||||
|
||||
// Compter le total pour la pagination
|
||||
let countSql = "SELECT COUNT(*) as total FROM tracks WHERE type = 'compilation'"
|
||||
const countParams: any[] = []
|
||||
|
||||
if (boxId) {
|
||||
countSql += ' AND box_id = ?'
|
||||
countParams.push(boxId)
|
||||
}
|
||||
|
||||
if (side) {
|
||||
countSql += ' AND side = ?'
|
||||
countParams.push(side)
|
||||
}
|
||||
|
||||
const { total } = db.prepare(countSql).get(...countParams) as { total: number }
|
||||
|
||||
return {
|
||||
tracks: tracks.map((track: any) => ({
|
||||
id: track.id,
|
||||
boxId: track.box_id,
|
||||
side: track.side,
|
||||
order: track.track_order,
|
||||
title: track.title,
|
||||
artist: track.artist_id,
|
||||
artistName: track.artist_name,
|
||||
start: track.start,
|
||||
link: track.link,
|
||||
coverId: track.cover_id,
|
||||
url: track.url,
|
||||
type: track.type
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,93 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { eventHandler } from 'h3'
|
||||
import { getCardFromDate } from '../../../utils/cards'
|
||||
|
||||
export default eventHandler(async (event) => {
|
||||
const dirPath = path.join(process.cwd(), '/mnt/media/files/music')
|
||||
const urlPrefix = `https://files.erudi.fr/music`
|
||||
|
||||
try {
|
||||
let allTracks: any[] = []
|
||||
|
||||
const items = await fs.promises.readdir(dirPath, { withFileTypes: true })
|
||||
|
||||
// Process files
|
||||
const files = items
|
||||
.filter((item) => item.isFile() && !item.name.startsWith('.') && !item.name.endsWith('.jpg'))
|
||||
.map((item) => item.name)
|
||||
|
||||
// Process folders
|
||||
const folders = items
|
||||
.filter((item) => item.isDirectory() && !item.name.startsWith('.'))
|
||||
.map((folder, index) => ({
|
||||
id: `folder-${index}`,
|
||||
boxId: 'ESFOLDER',
|
||||
title: folder.name.replace(/_/g, ' ').trim(),
|
||||
type: 'folder',
|
||||
order: 0,
|
||||
date: new Date(),
|
||||
card: getCardFromDate(new Date())
|
||||
}))
|
||||
|
||||
const tracks = files.map((file, index) => {
|
||||
const EXT_RE = /\.(mp3|flac|wav|opus)$/i
|
||||
const nameWithoutExt = file.replace(EXT_RE, '')
|
||||
|
||||
// On split sur __
|
||||
const parts = nameWithoutExt.split('__')
|
||||
let stamp = parts[0] || ''
|
||||
let artist = parts[1] || ''
|
||||
let title = parts[2] || ''
|
||||
|
||||
title = title.replaceAll('_', ' ')
|
||||
artist = artist.replaceAll('_', ' ')
|
||||
|
||||
// Parser la date depuis le stamp
|
||||
let year = 2020,
|
||||
month = 1,
|
||||
day = 1,
|
||||
hour = 0
|
||||
if (stamp.length === 10) {
|
||||
year = Number(stamp.slice(0, 4))
|
||||
month = Number(stamp.slice(4, 6))
|
||||
day = Number(stamp.slice(6, 8))
|
||||
hour = Number(stamp.slice(8, 10))
|
||||
}
|
||||
|
||||
const date = new Date(year, month - 1, day, hour)
|
||||
const card = getCardFromDate(date)
|
||||
const url = `${urlPrefix}/${encodeURIComponent(file)}`
|
||||
const coverId = `${urlPrefix}/cover/${encodeURIComponent(file).replace(EXT_RE, '.jpg')}`
|
||||
|
||||
return {
|
||||
id: Number(`${year}${index + 1}`),
|
||||
boxId: `ESPLAYLIST`,
|
||||
year,
|
||||
date,
|
||||
title: title.trim(),
|
||||
artist: artist.trim(),
|
||||
url,
|
||||
coverId,
|
||||
card,
|
||||
order: 0,
|
||||
type: 'playlist'
|
||||
}
|
||||
})
|
||||
|
||||
tracks.sort((a, b) => b.date.getTime() - a.date.getTime())
|
||||
// Combine folders and tracks
|
||||
const allItems = [...folders, ...tracks]
|
||||
|
||||
// Sort by date (newest first) and assign order
|
||||
allItems.sort((a, b) => b.date.getTime() - a.date.getTime())
|
||||
allItems.forEach((item, i) => (item.order = i + 1))
|
||||
|
||||
return allItems
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: (error as Error).message
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,91 +0,0 @@
|
||||
import { eventHandler, getQuery } from 'h3'
|
||||
import { getDatabase } from '../../utils/database'
|
||||
|
||||
export default eventHandler((event) => {
|
||||
const db = getDatabase()
|
||||
const query = getQuery(event)
|
||||
|
||||
const search = (query.search as string) || ''
|
||||
const page = parseInt(query.page as string) || 1
|
||||
const limit = parseInt(query.limit as string) || 50
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
// Construction de la requête de recherche
|
||||
let sql = `
|
||||
SELECT
|
||||
t.id,
|
||||
t.box_id,
|
||||
t.side,
|
||||
t.track_order,
|
||||
t.title,
|
||||
t.artist_id,
|
||||
t.start,
|
||||
t.link,
|
||||
t.cover_id,
|
||||
t.url,
|
||||
t.type,
|
||||
a.name as artist_name,
|
||||
a.url as artist_url
|
||||
FROM tracks t
|
||||
LEFT JOIN artists a ON t.artist_id = a.id
|
||||
WHERE 1=1
|
||||
`
|
||||
|
||||
const params: any[] = []
|
||||
|
||||
// Recherche par titre ou artiste
|
||||
if (search) {
|
||||
sql += ` AND (t.title LIKE ? OR a.name LIKE ?)`
|
||||
const searchPattern = `%${search}%`
|
||||
params.push(searchPattern, searchPattern)
|
||||
}
|
||||
|
||||
sql += ' ORDER BY t.box_id, t.track_order'
|
||||
sql += ' LIMIT ? OFFSET ?'
|
||||
params.push(limit, offset)
|
||||
|
||||
const tracks = db.prepare(sql).all(...params)
|
||||
|
||||
// Compter le total
|
||||
let countSql = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM tracks t
|
||||
LEFT JOIN artists a ON t.artist_id = a.id
|
||||
WHERE 1=1
|
||||
`
|
||||
|
||||
const countParams: any[] = []
|
||||
|
||||
if (search) {
|
||||
countSql += ` AND (t.title LIKE ? OR a.name LIKE ?)`
|
||||
const searchPattern = `%${search}%`
|
||||
countParams.push(searchPattern, searchPattern)
|
||||
}
|
||||
|
||||
const { total } = db.prepare(countSql).get(...countParams) as { total: number }
|
||||
|
||||
return {
|
||||
tracks: tracks.map((track: any) => ({
|
||||
id: track.id,
|
||||
boxId: track.box_id,
|
||||
side: track.side,
|
||||
order: track.track_order,
|
||||
title: track.title,
|
||||
artist: track.artist_id,
|
||||
artistName: track.artist_name,
|
||||
artistUrl: track.artist_url,
|
||||
start: track.start,
|
||||
link: track.link,
|
||||
coverId: track.cover_id,
|
||||
url: track.url,
|
||||
type: track.type
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
},
|
||||
search
|
||||
}
|
||||
})
|
||||
Binary file not shown.
@@ -1,678 +0,0 @@
|
||||
import { getDatabase } from '../utils/database'
|
||||
|
||||
// Import des données depuis vos anciens fichiers
|
||||
const boxes = [
|
||||
{
|
||||
id: 'ES01',
|
||||
type: 'compilation',
|
||||
name: '...',
|
||||
description: '...',
|
||||
state: 'box-hidden',
|
||||
duration: 3487 + 3773,
|
||||
sides: {
|
||||
A: {
|
||||
name: '...',
|
||||
description: '...',
|
||||
duration: 3487,
|
||||
color1: '#c7b3aa',
|
||||
color2: '#000100'
|
||||
},
|
||||
B: {
|
||||
name: '... B',
|
||||
description: '...',
|
||||
duration: 3773,
|
||||
color1: '#f7dd01',
|
||||
color2: '#010103'
|
||||
}
|
||||
},
|
||||
activeSide: 'A'
|
||||
},
|
||||
{
|
||||
id: 'ES00',
|
||||
type: 'compilation',
|
||||
name: 'manifeste',
|
||||
description: 'Zero is for manifesto',
|
||||
state: 'box-hidden',
|
||||
duration: 2794 + 2470,
|
||||
sides: {
|
||||
A: {
|
||||
name: 'manifeste',
|
||||
description: 'Zero is for manifesto',
|
||||
duration: 2794,
|
||||
color1: '#ffffff',
|
||||
color2: '#48959d'
|
||||
},
|
||||
B: {
|
||||
name: 'manifeste B',
|
||||
description: 'Even Zero has a b-side',
|
||||
duration: 2470,
|
||||
color1: '#0d01b9',
|
||||
color2: '#3b7589'
|
||||
}
|
||||
},
|
||||
activeSide: 'A'
|
||||
},
|
||||
{
|
||||
id: 'ESPLAYLIST',
|
||||
type: 'playlist',
|
||||
name: 'playlists',
|
||||
duration: 0,
|
||||
description: '♠♦♣♥',
|
||||
state: 'box-hidden',
|
||||
activeSide: 'A',
|
||||
color1: '#fdec50ff',
|
||||
color2: '#fdec50ff'
|
||||
}
|
||||
]
|
||||
|
||||
const artists = [
|
||||
{ id: 0, name: "L'efondras", url: 'https://leffondras.bandcamp.com/music', coverId: '0024705317' },
|
||||
{
|
||||
id: 1,
|
||||
name: 'The kundalini genie',
|
||||
url: 'https://the-kundalini-genie.bandcamp.com',
|
||||
coverId: '0012045550'
|
||||
},
|
||||
{ id: 2, name: 'Fontaines D.C.', url: 'https://fontainesdc.bandcamp.com', coverId: '0027327090' },
|
||||
{ id: 3, name: 'Fontanarosa', url: 'https://fontanarosa.bandcamp.com', coverId: '0035380235' },
|
||||
{ id: 4, name: 'Johnny mafia', url: 'https://johnnymafia.bandcamp.com', coverId: '0035009392' },
|
||||
{ id: 5, name: 'New candys', url: 'https://newcandys.bandcamp.com', coverId: '0039963261' },
|
||||
{ id: 6, name: 'Magic shoppe', url: 'https://magicshoppe.bandcamp.com', coverId: '0030748374' },
|
||||
{
|
||||
id: 7,
|
||||
name: 'Les jaguars',
|
||||
url: 'https://radiomartiko.bandcamp.com/album/surf-qu-b-cois',
|
||||
coverId: '0016551336'
|
||||
},
|
||||
{ id: 8, name: 'TRAAMS', url: 'https://traams.bandcamp.com', coverId: '0028348410' },
|
||||
{ id: 9, name: 'Blue orchid', url: 'https://blue-orchid.bandcamp.com', coverId: '0034796193' },
|
||||
{ id: 10, name: 'I love UFO', url: 'https://bruitblanc.bandcamp.com', coverId: 'a2203158939' },
|
||||
{
|
||||
id: 11,
|
||||
name: 'Kid Congo & The Pink Monkey Birds',
|
||||
url: 'https://kidcongothepinkmonkeybirds.bandcamp.com/',
|
||||
coverId: '0017196290'
|
||||
},
|
||||
{ id: 12, name: 'Firefriend', url: 'https://firefriend.bandcamp.com/', coverId: '0031072203' },
|
||||
{ id: 13, name: 'Squid', url: 'https://squiduk.bandcamp.com/', coverId: '0037649385' },
|
||||
{ id: 14, name: 'Lysistrata', url: 'https://lysistrata.bandcamp.com/', coverId: '0033900158' },
|
||||
{
|
||||
id: 15,
|
||||
name: 'Pablo X Broadcasting Services',
|
||||
url: 'https://pabloxbroadcastingservices.bandcamp.com/',
|
||||
coverId: '0036956486'
|
||||
},
|
||||
{ id: 16, name: 'Night Beats', url: 'https://nightbeats.bandcamp.com/', coverId: '0036987720' },
|
||||
{ id: 17, name: 'Deltron 3030', url: 'https://delthefunkyhomosapien.bandcamp.com/', coverId: '0005254781' },
|
||||
{
|
||||
id: 18,
|
||||
name: 'The Amorphous Androgynous',
|
||||
url: 'https://theaa.bandcamp.com/',
|
||||
coverId: '0022226700'
|
||||
},
|
||||
{ id: 19, name: 'Wooden Shjips', url: 'https://woodenshjips.bandcamp.com/', coverId: '0012406678' },
|
||||
{ id: 20, name: 'Silas J. Dirge', url: 'https://silasjdirge.bandcamp.com/', coverId: '0035751570' },
|
||||
{ id: 21, name: 'Secret Colours', url: 'https://secretcolours.bandcamp.com/', coverId: '0010661379' },
|
||||
{
|
||||
id: 22,
|
||||
name: 'Larry McNeil And The Blue Knights',
|
||||
url: 'https://www.discogs.com/artist/6528940-Larry-McNeil-And-The-Blue-Knights',
|
||||
coverId:
|
||||
'https://i.discogs.com/Yr05_neEXwzPwKlDeV7dimmTG34atkAMgpxbMBhHBkI/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTEyMTEw/ODE1LTE1Mjg1NjU1/NzQtMjcyOC5qcGVn.jpeg'
|
||||
},
|
||||
{
|
||||
id: 23,
|
||||
name: 'Hugo Blanco',
|
||||
url: 'https://elpalmasmusic.bandcamp.com/album/color-de-tr-pico-compiled-by-el-dr-gon-criollo-el-palmas',
|
||||
coverId: '0016886708'
|
||||
}
|
||||
]
|
||||
|
||||
const tracks = [
|
||||
{
|
||||
order: 1,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'The grinding wheel',
|
||||
artist: 0,
|
||||
start: 0,
|
||||
link: 'https://arakirecords.bandcamp.com/track/the-grinding-wheel',
|
||||
coverId: 'a3236746052'
|
||||
},
|
||||
{
|
||||
order: 2,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'Bleach',
|
||||
artist: 1,
|
||||
start: 392,
|
||||
link: 'https://the-kundalini-genie.bandcamp.com/track/bleach-2',
|
||||
coverId: 'a1714786533'
|
||||
},
|
||||
{
|
||||
order: 3,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'Televised mind',
|
||||
artist: 2,
|
||||
start: 896,
|
||||
link: 'https://fontainesdc.bandcamp.com/track/televised-mind',
|
||||
coverId: 'a3772806156'
|
||||
},
|
||||
{
|
||||
order: 4,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'In it',
|
||||
artist: 3,
|
||||
start: 1139,
|
||||
link: 'https://howlinbananarecords.bandcamp.com/track/in-it',
|
||||
coverId: 'a1720372066'
|
||||
},
|
||||
{
|
||||
order: 5,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'Bad michel',
|
||||
artist: 4,
|
||||
start: 1245,
|
||||
link: 'https://johnnymafia.bandcamp.com/track/bad-michel-3',
|
||||
coverId: 'a0984622869'
|
||||
},
|
||||
{
|
||||
order: 6,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'Overall',
|
||||
artist: 5,
|
||||
start: 1394,
|
||||
link: 'https://newcandys.bandcamp.com/track/overall',
|
||||
coverId: 'a0559661270'
|
||||
},
|
||||
{
|
||||
order: 7,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'Blowup',
|
||||
artist: 6,
|
||||
start: 1674,
|
||||
link: 'https://magicshoppe.bandcamp.com/track/blowup',
|
||||
coverId: 'a1444895293'
|
||||
},
|
||||
{
|
||||
order: 8,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'Guitar jet',
|
||||
artist: 7,
|
||||
start: 1880,
|
||||
link: 'https://radiomartiko.bandcamp.com/track/guitare-jet',
|
||||
coverId: 'a1494681687'
|
||||
},
|
||||
{
|
||||
order: 9,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'Intercontinental radio waves',
|
||||
artist: 8,
|
||||
start: 2024,
|
||||
link: 'https://traams.bandcamp.com/track/intercontinental-radio-waves',
|
||||
coverId: 'a0046738552'
|
||||
},
|
||||
{
|
||||
order: 10,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'Here comes the sun',
|
||||
artist: 9,
|
||||
start: 2211,
|
||||
link: 'https://blue-orchid.bandcamp.com/track/here-come-the-sun',
|
||||
coverId: 'a4102567047'
|
||||
},
|
||||
{
|
||||
order: 11,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'Like in the movies',
|
||||
artist: 10,
|
||||
start: 2560,
|
||||
link: 'https://bruitblanc.bandcamp.com/track/like-in-the-movies-2',
|
||||
coverId: 'a2203158939'
|
||||
},
|
||||
{
|
||||
order: 1,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: "Ce que révèle l'éclipse",
|
||||
artist: 0,
|
||||
start: 0,
|
||||
link: 'https://arakirecords.bandcamp.com/track/ce-que-r-v-le-l-clipse',
|
||||
coverId: 'a3236746052'
|
||||
},
|
||||
{
|
||||
order: 2,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: "Bleedin' Gums Mushrool",
|
||||
artist: 1,
|
||||
start: 263,
|
||||
link: 'https://the-kundalini-genie.bandcamp.com/track/bleedin-gums-mushroom',
|
||||
coverId: 'a1714786533'
|
||||
},
|
||||
{
|
||||
order: 3,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: 'A lucid dream',
|
||||
artist: 2,
|
||||
start: 554,
|
||||
link: 'https://fontainesdc.bandcamp.com/track/a-lucid-dream',
|
||||
coverId: 'a3772806156'
|
||||
},
|
||||
{
|
||||
order: 4,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: 'Lights off',
|
||||
artist: 3,
|
||||
start: 781,
|
||||
link: 'https://howlinbananarecords.bandcamp.com/track/lights-off',
|
||||
coverId: 'a1720372066'
|
||||
},
|
||||
{
|
||||
order: 5,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: "I'm sentimental",
|
||||
artist: 4,
|
||||
start: 969,
|
||||
link: 'https://johnnymafia.bandcamp.com/track/im-sentimental-2',
|
||||
coverId: 'a2333676849'
|
||||
},
|
||||
{
|
||||
order: 6,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: 'Thrill or trip',
|
||||
artist: 5,
|
||||
start: 1128,
|
||||
link: 'https://newcandys.bandcamp.com/track/thrill-or-trip',
|
||||
coverId: 'a0559661270'
|
||||
},
|
||||
{
|
||||
order: 7,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: 'Redhead',
|
||||
artist: 6,
|
||||
start: 1303,
|
||||
link: 'https://magicshoppe.bandcamp.com/track/redhead',
|
||||
coverId: 'a0594426943'
|
||||
},
|
||||
{
|
||||
order: 8,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: 'Supersonic twist',
|
||||
artist: 7,
|
||||
start: 1584,
|
||||
link: 'https://open.spotify.com/track/66voQIZAJ3zD3Eju2qtNjF',
|
||||
coverId: 'a1494681687'
|
||||
},
|
||||
{
|
||||
order: 9,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: 'Flowers',
|
||||
artist: 8,
|
||||
start: 1749,
|
||||
link: 'https://traams.bandcamp.com/track/flowers',
|
||||
coverId: 'a3644668199'
|
||||
},
|
||||
{
|
||||
order: 10,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: 'The shade',
|
||||
artist: 9,
|
||||
start: 1924,
|
||||
link: 'https://blue-orchid.bandcamp.com/track/the-shade',
|
||||
coverId: 'a0804204790'
|
||||
},
|
||||
{
|
||||
order: 11,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: 'Like in the movies',
|
||||
artist: 10,
|
||||
start: 2186,
|
||||
link: 'https://bruitblanc.bandcamp.com/track/like-in-the-movies',
|
||||
coverId: 'a3647322740'
|
||||
},
|
||||
{
|
||||
order: 1,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'He Walked In',
|
||||
artist: 11,
|
||||
start: 0,
|
||||
link: 'https://kidcongothepinkmonkeybirds.bandcamp.com/track/he-walked-in',
|
||||
coverId: 'a0336300523'
|
||||
},
|
||||
{
|
||||
order: 2,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'The Third Wave',
|
||||
artist: 12,
|
||||
start: 841,
|
||||
link: 'https://firefriend.bandcamp.com/track/the-third-wave',
|
||||
coverId: 'a2803689859'
|
||||
},
|
||||
{
|
||||
order: 3,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'Broadcaster',
|
||||
artist: 13,
|
||||
start: 1104.5,
|
||||
link: 'https://squiduk.bandcamp.com/track/broadcaster',
|
||||
coverId: 'a3391719769'
|
||||
},
|
||||
{
|
||||
order: 4,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'Mourn',
|
||||
artist: 14,
|
||||
start: 1441,
|
||||
link: 'https://lysistrata.bandcamp.com/track/mourn-2',
|
||||
coverId: 'a0872900041'
|
||||
},
|
||||
{
|
||||
order: 5,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'Let it Blow',
|
||||
artist: 15,
|
||||
start: 1844.8,
|
||||
link: 'https://pabloxbroadcastingservices.bandcamp.com/track/let-it-blow',
|
||||
coverId: 'a4000148031'
|
||||
},
|
||||
{
|
||||
order: 6,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'Sunday Mourning',
|
||||
artist: 16,
|
||||
start: 2091.7,
|
||||
link: 'https://nightbeats.bandcamp.com/track/sunday-mourning',
|
||||
coverId: 'a0031987121'
|
||||
},
|
||||
{
|
||||
order: 7,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: '3030 Instrumental',
|
||||
artist: 17,
|
||||
start: 2339.3,
|
||||
link: 'https://delthefunkyhomosapien.bandcamp.com/track/3030',
|
||||
coverId: 'a1948146136'
|
||||
},
|
||||
{
|
||||
order: 8,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'Immortality Break',
|
||||
artist: 18,
|
||||
start: 2530.5,
|
||||
link: 'https://theaa.bandcamp.com/track/immortality-break',
|
||||
coverId: 'a2749250329'
|
||||
},
|
||||
{
|
||||
order: 9,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'Lazy Bones',
|
||||
artist: 19,
|
||||
start: 2718,
|
||||
link: 'https://woodenshjips.bandcamp.com/track/lazy-bones',
|
||||
coverId: 'a1884221104'
|
||||
},
|
||||
{
|
||||
order: 10,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'On the Train of Aches',
|
||||
artist: 20,
|
||||
start: 2948,
|
||||
link: 'https://silasjdirge.bandcamp.com/track/on-the-train-of-aches',
|
||||
coverId: 'a1124177379'
|
||||
},
|
||||
{
|
||||
order: 11,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'Me',
|
||||
artist: 21,
|
||||
start: 3265,
|
||||
link: 'https://secretcolours.bandcamp.com/track/me',
|
||||
coverId: 'a1497022499'
|
||||
},
|
||||
{
|
||||
order: 1,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'Lady Hawke Blues',
|
||||
artist: 11,
|
||||
start: 0,
|
||||
link: 'https://kidcongothepinkmonkeybirds.bandcamp.com/track/lady-hawke-blues',
|
||||
coverId: 'a2532623230'
|
||||
},
|
||||
{
|
||||
order: 2,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'Dreamscapes',
|
||||
artist: 12,
|
||||
start: 235,
|
||||
link: 'https://littlecloudrecords.bandcamp.com/track/dreamscapes',
|
||||
coverId: 'a3498981203'
|
||||
},
|
||||
{
|
||||
order: 3,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'Crispy Skin',
|
||||
artist: 13,
|
||||
start: 644.2,
|
||||
link: 'https://squiduk.bandcamp.com/track/crispy-skin-2',
|
||||
coverId: 'a2516727021'
|
||||
},
|
||||
{
|
||||
order: 4,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'The Boy Who Stood Above The Earth',
|
||||
artist: 14,
|
||||
start: 1018,
|
||||
link: 'https://lysistrata.bandcamp.com/track/the-boy-who-stood-above-the-earth-2',
|
||||
coverId: 'a0350933426'
|
||||
},
|
||||
{
|
||||
order: 5,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'Better Off Alone',
|
||||
artist: 15,
|
||||
start: 1698,
|
||||
link: 'https://pabloxbroadcastingservices.bandcamp.com/track/better-off-alone',
|
||||
coverId: 'a4000148031'
|
||||
},
|
||||
{
|
||||
order: 6,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'Celebration #1',
|
||||
artist: 16,
|
||||
start: 2235,
|
||||
link: 'https://nightbeats.bandcamp.com/track/celebration-1',
|
||||
coverId: 'a0031987121'
|
||||
},
|
||||
{
|
||||
order: 7,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: '3030 Instrumental',
|
||||
artist: 17,
|
||||
start: 2458.3,
|
||||
link: 'https://delthefunkyhomosapien.bandcamp.com/track/3030',
|
||||
coverId: 'a1948146136'
|
||||
},
|
||||
{
|
||||
order: 8,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'The Emptiness Of Nothingness',
|
||||
artist: 18,
|
||||
start: 2864.5,
|
||||
link: 'https://theaa.bandcamp.com/track/the-emptiness-of-nothingness',
|
||||
coverId: 'a1053923875'
|
||||
},
|
||||
{
|
||||
order: 9,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'Rising',
|
||||
artist: 19,
|
||||
start: 3145,
|
||||
link: 'https://woodenshjips.bandcamp.com/track/rising',
|
||||
coverId: 'a1884221104'
|
||||
},
|
||||
{
|
||||
order: 10,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'The Last Time',
|
||||
artist: 22,
|
||||
start: 3447,
|
||||
link: 'https://www.discogs.com/release/12110815-Larry-McNeil-And-The-Blue-Knights-Jealous-Woman',
|
||||
coverId:
|
||||
'https://i.discogs.com/Yr05_neEXwzPwKlDeV7dimmTG34atkAMgpxbMBhHBkI/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTEyMTEw/ODE1LTE1Mjg1NjU1/NzQtMjcyOC5qcGVn.jpeg'
|
||||
},
|
||||
{
|
||||
order: 11,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'Guajira Con Arpa',
|
||||
artist: 23,
|
||||
start: 3586,
|
||||
link: 'https://elpalmasmusic.bandcamp.com/track/guajira-con-arpa',
|
||||
coverId: 'a3463036407'
|
||||
}
|
||||
]
|
||||
|
||||
export async function migrate() {
|
||||
console.log('🚀 Début de la migration...')
|
||||
|
||||
const db = getDatabase()
|
||||
|
||||
// Vider les tables existantes
|
||||
db.exec('DELETE FROM tracks')
|
||||
db.exec('DELETE FROM sides')
|
||||
db.exec('DELETE FROM artists')
|
||||
db.exec('DELETE FROM boxes')
|
||||
|
||||
console.log('🗑️ Tables vidées')
|
||||
|
||||
// Insérer les boxes
|
||||
const insertBox = db.prepare(`
|
||||
INSERT INTO boxes (id, type, name, description, state, duration, active_side, color1, color2)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
const insertSide = db.prepare(`
|
||||
INSERT INTO sides (box_id, side, name, description, duration, color1, color2)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
for (const box of boxes) {
|
||||
insertBox.run(
|
||||
box.id,
|
||||
box.type,
|
||||
box.name,
|
||||
box.description,
|
||||
box.state,
|
||||
box.duration,
|
||||
box.activeSide,
|
||||
box.color1 || null,
|
||||
box.color2 || null
|
||||
)
|
||||
|
||||
// Insérer les sides si c'est une compilation
|
||||
if (box.sides) {
|
||||
for (const [sideName, sideData] of Object.entries(box.sides)) {
|
||||
insertSide.run(
|
||||
box.id,
|
||||
sideName,
|
||||
sideData.name,
|
||||
sideData.description,
|
||||
sideData.duration,
|
||||
sideData.color1,
|
||||
sideData.color2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ ${boxes.length} boxes insérées`)
|
||||
|
||||
// Insérer les artists
|
||||
const insertArtist = db.prepare(`
|
||||
INSERT INTO artists (id, name, url, cover_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
for (const artist of artists) {
|
||||
insertArtist.run(artist.id, artist.name, artist.url, artist.coverId)
|
||||
}
|
||||
|
||||
console.log(`✅ ${artists.length} artistes insérés`)
|
||||
|
||||
// Insérer les tracks
|
||||
const insertTrack = db.prepare(`
|
||||
INSERT INTO tracks (box_id, side, track_order, title, artist_id, start, link, cover_id, url, type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
for (const track of tracks) {
|
||||
const url = `https://files.erudi.fr/evilspins/${track.boxId}${track.side}.mp3`
|
||||
const coverId = `https://f4.bcbits.com/img/${track.coverId}_4.jpg`
|
||||
|
||||
insertTrack.run(
|
||||
track.boxId,
|
||||
track.side,
|
||||
track.order,
|
||||
track.title,
|
||||
track.artist,
|
||||
track.start,
|
||||
track.link,
|
||||
coverId,
|
||||
url,
|
||||
'compilation'
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`✅ ${tracks.length} tracks insérées`)
|
||||
console.log('🎉 Migration terminée avec succès !')
|
||||
}
|
||||
|
||||
// Exécuter la migration si appelé directement
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
migrate()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error('❌ Erreur lors de la migration:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
-- Boxes table
|
||||
CREATE TABLE IF NOT EXISTS boxes (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
state TEXT,
|
||||
duration INTEGER,
|
||||
active_side TEXT,
|
||||
color1 TEXT,
|
||||
color2 TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Sides table (pour les compilations qui ont A et B)
|
||||
CREATE TABLE IF NOT EXISTS sides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
box_id TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
duration INTEGER,
|
||||
color1 TEXT,
|
||||
color2 TEXT,
|
||||
FOREIGN KEY (box_id) REFERENCES boxes(id) ON DELETE CASCADE,
|
||||
UNIQUE(box_id, side)
|
||||
);
|
||||
|
||||
-- Artists table
|
||||
CREATE TABLE IF NOT EXISTS artists (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT,
|
||||
cover_id TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tracks table
|
||||
CREATE TABLE IF NOT EXISTS tracks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
box_id TEXT NOT NULL,
|
||||
side TEXT,
|
||||
track_order INTEGER,
|
||||
title TEXT NOT NULL,
|
||||
artist_id INTEGER,
|
||||
start INTEGER,
|
||||
link TEXT,
|
||||
cover_id TEXT,
|
||||
url TEXT,
|
||||
type TEXT,
|
||||
year INTEGER,
|
||||
date DATETIME,
|
||||
card TEXT,
|
||||
FOREIGN KEY (box_id) REFERENCES boxes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (artist_id) REFERENCES artists(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Index pour les requêtes fréquentes
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_box_id ON tracks(box_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_artist_id ON tracks(artist_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_type ON tracks(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_year ON tracks(year);
|
||||
CREATE INDEX IF NOT EXISTS idx_sides_box_id ON sides(box_id);
|
||||
32
server/db/index.ts
Normal file
32
server/db/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { drizzle } from 'drizzle-orm/libsql'
|
||||
import * as schema from './schema'
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null
|
||||
|
||||
export function useDB() {
|
||||
if (_db) return _db
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
let dbPath = config.pathDb || process.env.PATH_DB
|
||||
|
||||
if (!dbPath) {
|
||||
throw new Error('PATH_DB is not configured')
|
||||
}
|
||||
|
||||
// Convertir le chemin en URL file:// si ce n'est pas déjà une URL
|
||||
if (!dbPath.startsWith('file:') && !dbPath.startsWith('libsql:') && !dbPath.startsWith('http')) {
|
||||
// Si c'est un chemin relatif, le rendre absolu
|
||||
if (!dbPath.startsWith('/')) {
|
||||
dbPath = `file:${process.cwd()}/${dbPath}`
|
||||
} else {
|
||||
dbPath = `file:${dbPath}`
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🗄️ Connexion à la DB:', dbPath)
|
||||
|
||||
_db = drizzle(dbPath, { schema })
|
||||
return _db
|
||||
}
|
||||
|
||||
export { schema }
|
||||
23
server/db/schema.ts
Normal file
23
server/db/schema.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { sqliteTable, text, int } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
export const cards = sqliteTable('cards', {
|
||||
id: int('id').primaryKey({ autoIncrement: true }),
|
||||
esid: text('esid').notNull(),
|
||||
url_audio: text('url_audio').notNull(),
|
||||
url_image: text('url_image').notNull(),
|
||||
year: text('year').notNull(),
|
||||
month: text('month').notNull(),
|
||||
day: text('day').notNull(),
|
||||
hour: text('hour').notNull(),
|
||||
artist: text('artist').notNull(),
|
||||
title: text('title').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
suit: text('suit').notNull(),
|
||||
rank: text('rank').notNull(),
|
||||
createdAt: int('created_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
updatedAt: int('updated_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date())
|
||||
})
|
||||
586
server/db/start.prompt
Normal file
586
server/db/start.prompt
Normal file
@@ -0,0 +1,586 @@
|
||||
Je développe une application musicale en Node.js qui gère des morceaux de musique (tracks). Voici l'architecture actuelle des données :Tracks : Morceaux de musique stockés sous forme de fichiers audio.
|
||||
Playlists : Ensembles de tracks regroupés dans un dossier spécifique sur le disque (ex. : un dossier par playlist). Actuellement, à chaque requête, le serveur scanne récursivement le dossier pour lister les tracks, ce qui génère la playlist dynamiquement.
|
||||
Compilations : Ensembles de tracks mixés ensemble, représentant un seul fichier audio unifié. Actuellement, les compilations sont hardcodées dans le code (pas de scan dynamique).
|
||||
|
||||
Problèmes actuels :Performances : Le scan des dossiers pour les playlists prend ~6 secondes par requête, ce qui est inacceptable pour une bonne UX.
|
||||
Manque d'uniformité : Les playlists sont générées dynamiquement (lourd), tandis que les compilations sont statiques (hardcodées), rendant la maintenance difficile.
|
||||
Pas de persistance : Aucune base de données, donc pas de cache ni de requêtes rapides.
|
||||
|
||||
Objectifs :Utiliser une base de données SQLite comme cache pour stocker les métadonnées des tracks, playlists et compilations, afin d'éviter les scans disque à chaque requête.
|
||||
Uniformiser l'architecture : Stocker à la fois les playlists (issues de scans de dossiers) et les compilations en base.
|
||||
Améliorer les performances : Les requêtes doivent renvoyer les données depuis la DB en <1 seconde, avec un scan disque initial ou périodique pour mise à jour.
|
||||
ORM : Utiliser Drizzle ORM pour interagir avec SQLite (facile à setup en Node.js).
|
||||
Schéma DB suggéré (à affiner si needed) :
|
||||
Table tracks : id, title, artist, duration, file_path (chemin du fichier), source_type ('playlist' ou 'compilation'), source_id (FK vers playlist ou compilation).
|
||||
Table compilations : id, name, file_path (chemin du fichier mixé), tracks_list (JSON ou relation many-to-many si needed).
|
||||
|
||||
Contraintes techniques :DB : SQLite uniquement (fichier local, pas de serveur).
|
||||
Environnement : Node.js (version récente, ex. 18+). Pas d'installation de paquets incompatibles. Deployable dans une app Nuxt 4, la partie typescript sera écrite dans le dossier server prevu dans nuxt avec h3.
|
||||
Gestion des mises à jour : Implémenter un mécanisme pour re-scanner les dossiers playlists seulement si des changements sont détectés (ex. : via fs.watch ou comparaison de timestamps). Pour les compilations, permettre un import manuel ou initial.
|
||||
Population initiale : Au démarrage de l'app, scanner les dossiers playlists et importer les compilations hardcodées actuelles en DB.
|
||||
|
||||
Output attendu :Étapes détaillées pour implémenter cela (setup DB, schéma avec Drizzle, code pour population initiale et mises à jour).
|
||||
Exemples de code Node.js : Setup de Drizzle avec SQLite.
|
||||
Fonctions pour scanner le dossier playlists
|
||||
Exemples de requêtes (ex. : getPlaylistById depuis DB, avec fallback sur scan si cache invalide).
|
||||
Gestion des erreurs et optimisations perf.
|
||||
|
||||
Si possible, un schéma DB en SQL ou Drizzle schema.ts.
|
||||
Assure-toi que la solution est scalable pour ~1000 tracks initiaux, et teste les perfs dans tes exemples.
|
||||
|
||||
|
||||
voici ma structure de projet actuelle :
|
||||
|
||||
compilation :
|
||||
import { eventHandler } from 'h3'
|
||||
|
||||
export default eventHandler(() => {
|
||||
const tracks = [
|
||||
{
|
||||
order: 1,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'The grinding wheel',
|
||||
artist: 0,
|
||||
start: 0,
|
||||
link: 'https://arakirecords.bandcamp.com/track/the-grinding-wheel',
|
||||
coverId: 'a3236746052'
|
||||
},
|
||||
{
|
||||
order: 2,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'Bleach',
|
||||
artist: 1,
|
||||
start: 392,
|
||||
link: 'https://the-kundalini-genie.bandcamp.com/track/bleach-2',
|
||||
coverId: 'a1714786533'
|
||||
},
|
||||
{
|
||||
order: 3,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'Televised mind',
|
||||
artist: 2,
|
||||
start: 896,
|
||||
link: 'https://fontainesdc.bandcamp.com/track/televised-mind',
|
||||
coverId: 'a3772806156'
|
||||
},
|
||||
{
|
||||
order: 4,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'In it',
|
||||
artist: 3,
|
||||
start: 1139,
|
||||
link: 'https://howlinbananarecords.bandcamp.com/track/in-it',
|
||||
coverId: 'a1720372066'
|
||||
},
|
||||
{
|
||||
order: 5,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'Bad michel',
|
||||
artist: 4,
|
||||
start: 1245,
|
||||
link: 'https://johnnymafia.bandcamp.com/track/bad-michel-3',
|
||||
coverId: 'a0984622869'
|
||||
},
|
||||
{
|
||||
order: 6,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'Overall',
|
||||
artist: 5,
|
||||
start: 1394,
|
||||
link: 'https://newcandys.bandcamp.com/track/overall',
|
||||
coverId: 'a0559661270'
|
||||
},
|
||||
{
|
||||
order: 7,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'Blowup',
|
||||
artist: 6,
|
||||
start: 1674,
|
||||
link: 'https://magicshoppe.bandcamp.com/track/blowup',
|
||||
coverId: 'a1444895293'
|
||||
},
|
||||
{
|
||||
order: 8,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'Guitar jet',
|
||||
artist: 7,
|
||||
start: 1880,
|
||||
link: 'https://radiomartiko.bandcamp.com/track/guitare-jet',
|
||||
coverId: 'a1494681687'
|
||||
},
|
||||
{
|
||||
order: 9,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'Intercontinental radio waves',
|
||||
artist: 8,
|
||||
start: 2024,
|
||||
link: 'https://traams.bandcamp.com/track/intercontinental-radio-waves',
|
||||
coverId: 'a0046738552'
|
||||
},
|
||||
{
|
||||
order: 10,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'Here comes the sun',
|
||||
artist: 9,
|
||||
start: 2211,
|
||||
link: 'https://blue-orchid.bandcamp.com/track/here-come-the-sun',
|
||||
coverId: 'a4102567047'
|
||||
},
|
||||
{
|
||||
order: 11,
|
||||
boxId: 'ES00',
|
||||
side: 'A',
|
||||
title: 'Like in the movies',
|
||||
artist: 10,
|
||||
start: 2560,
|
||||
link: 'https://bruitblanc.bandcamp.com/track/like-in-the-movies-2',
|
||||
coverId: 'a2203158939'
|
||||
},
|
||||
{
|
||||
order: 1,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: "Ce que révèle l'éclipse",
|
||||
artist: 0,
|
||||
start: 0,
|
||||
link: 'https://arakirecords.bandcamp.com/track/ce-que-r-v-le-l-clipse',
|
||||
coverId: 'a3236746052'
|
||||
},
|
||||
{
|
||||
order: 2,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: "Bleedin' Gums Mushrool",
|
||||
artist: 1,
|
||||
start: 263,
|
||||
link: 'https://the-kundalini-genie.bandcamp.com/track/bleedin-gums-mushroom',
|
||||
coverId: 'a1714786533'
|
||||
},
|
||||
{
|
||||
order: 3,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: 'A lucid dream',
|
||||
artist: 2,
|
||||
start: 554,
|
||||
link: 'https://fontainesdc.bandcamp.com/track/a-lucid-dream',
|
||||
coverId: 'a3772806156'
|
||||
},
|
||||
{
|
||||
order: 4,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: 'Lights off',
|
||||
artist: 3,
|
||||
start: 781,
|
||||
link: 'https://howlinbananarecords.bandcamp.com/track/lights-off',
|
||||
coverId: 'a1720372066'
|
||||
},
|
||||
{
|
||||
order: 5,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: "I'm sentimental",
|
||||
artist: 4,
|
||||
start: 969,
|
||||
link: 'https://johnnymafia.bandcamp.com/track/im-sentimental-2',
|
||||
coverId: 'a2333676849'
|
||||
},
|
||||
{
|
||||
order: 6,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: 'Thrill or trip',
|
||||
artist: 5,
|
||||
start: 1128,
|
||||
link: 'https://newcandys.bandcamp.com/track/thrill-or-trip',
|
||||
coverId: 'a0559661270'
|
||||
},
|
||||
{
|
||||
order: 7,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: 'Redhead',
|
||||
artist: 6,
|
||||
start: 1303,
|
||||
link: 'https://magicshoppe.bandcamp.com/track/redhead',
|
||||
coverId: 'a0594426943'
|
||||
},
|
||||
{
|
||||
order: 8,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: 'Supersonic twist',
|
||||
artist: 7,
|
||||
start: 1584,
|
||||
link: 'https://open.spotify.com/track/66voQIZAJ3zD3Eju2qtNjF',
|
||||
coverId: 'a1494681687'
|
||||
},
|
||||
{
|
||||
order: 9,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: 'Flowers',
|
||||
artist: 8,
|
||||
start: 1749,
|
||||
link: 'https://traams.bandcamp.com/track/flowers',
|
||||
coverId: 'a3644668199'
|
||||
},
|
||||
{
|
||||
order: 10,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: 'The shade',
|
||||
artist: 9,
|
||||
start: 1924,
|
||||
link: 'https://blue-orchid.bandcamp.com/track/the-shade',
|
||||
coverId: 'a0804204790'
|
||||
},
|
||||
{
|
||||
order: 11,
|
||||
boxId: 'ES00',
|
||||
side: 'B',
|
||||
title: 'Like in the movies',
|
||||
artist: 10,
|
||||
start: 2186,
|
||||
link: 'https://bruitblanc.bandcamp.com/track/like-in-the-movies',
|
||||
coverId: 'a3647322740'
|
||||
},
|
||||
{
|
||||
order: 1,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'He Walked In',
|
||||
artist: 11,
|
||||
start: 0,
|
||||
link: 'https://kidcongothepinkmonkeybirds.bandcamp.com/track/he-walked-in',
|
||||
coverId: 'a0336300523'
|
||||
},
|
||||
{
|
||||
order: 2,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'The Third Wave',
|
||||
artist: 12,
|
||||
start: 841,
|
||||
link: 'https://firefriend.bandcamp.com/track/the-third-wave',
|
||||
coverId: 'a2803689859'
|
||||
},
|
||||
{
|
||||
order: 3,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'Broadcaster',
|
||||
artist: 13,
|
||||
start: 1104.5,
|
||||
link: 'https://squiduk.bandcamp.com/track/broadcaster',
|
||||
coverId: 'a3391719769'
|
||||
},
|
||||
{
|
||||
order: 4,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'Mourn',
|
||||
artist: 14,
|
||||
start: 1441,
|
||||
link: 'https://lysistrata.bandcamp.com/track/mourn-2',
|
||||
coverId: 'a0872900041'
|
||||
},
|
||||
{
|
||||
order: 5,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'Let it Blow',
|
||||
artist: 15,
|
||||
start: 1844.8,
|
||||
link: 'https://pabloxbroadcastingservices.bandcamp.com/track/let-it-blow',
|
||||
coverId: 'a4000148031'
|
||||
},
|
||||
{
|
||||
order: 6,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'Sunday Mourning',
|
||||
artist: 16,
|
||||
start: 2091.7,
|
||||
link: 'https://nightbeats.bandcamp.com/track/sunday-mourning',
|
||||
coverId: 'a0031987121'
|
||||
},
|
||||
{
|
||||
order: 7,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: '3030 Instrumental',
|
||||
artist: 17,
|
||||
start: 2339.3,
|
||||
link: 'https://delthefunkyhomosapien.bandcamp.com/track/3030',
|
||||
coverId: 'a1948146136'
|
||||
},
|
||||
{
|
||||
order: 8,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'Immortality Break',
|
||||
artist: 18,
|
||||
start: 2530.5,
|
||||
link: 'https://theaa.bandcamp.com/track/immortality-break',
|
||||
coverId: 'a2749250329'
|
||||
},
|
||||
{
|
||||
order: 9,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'Lazy Bones',
|
||||
artist: 19,
|
||||
start: 2718,
|
||||
link: 'https://woodenshjips.bandcamp.com/track/lazy-bones',
|
||||
coverId: 'a1884221104'
|
||||
},
|
||||
{
|
||||
order: 10,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'On the Train of Aches',
|
||||
artist: 20,
|
||||
start: 2948,
|
||||
link: 'https://silasjdirge.bandcamp.com/track/on-the-train-of-aches',
|
||||
coverId: 'a1124177379'
|
||||
},
|
||||
{
|
||||
order: 11,
|
||||
boxId: 'ES01',
|
||||
side: 'A',
|
||||
title: 'Me',
|
||||
artist: 21,
|
||||
start: 3265,
|
||||
link: 'https://secretcolours.bandcamp.com/track/me',
|
||||
coverId: 'a1497022499'
|
||||
},
|
||||
{
|
||||
order: 1,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'Lady Hawke Blues',
|
||||
artist: 11,
|
||||
start: 0,
|
||||
link: 'https://kidcongothepinkmonkeybirds.bandcamp.com/track/lady-hawke-blues',
|
||||
coverId: 'a2532623230'
|
||||
},
|
||||
{
|
||||
order: 2,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'Dreamscapes',
|
||||
artist: 12,
|
||||
start: 235,
|
||||
link: 'https://littlecloudrecords.bandcamp.com/track/dreamscapes',
|
||||
coverId: 'a3498981203'
|
||||
},
|
||||
{
|
||||
order: 3,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'Crispy Skin',
|
||||
artist: 13,
|
||||
start: 644.2,
|
||||
link: 'https://squiduk.bandcamp.com/track/crispy-skin-2',
|
||||
coverId: 'a2516727021'
|
||||
},
|
||||
{
|
||||
order: 4,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'The Boy Who Stood Above The Earth',
|
||||
artist: 14,
|
||||
start: 1018,
|
||||
link: 'https://lysistrata.bandcamp.com/track/the-boy-who-stood-above-the-earth-2',
|
||||
coverId: 'a0350933426'
|
||||
},
|
||||
{
|
||||
order: 5,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'Better Off Alone',
|
||||
artist: 15,
|
||||
start: 1698,
|
||||
link: 'https://pabloxbroadcastingservices.bandcamp.com/track/better-off-alone',
|
||||
coverId: 'a4000148031'
|
||||
},
|
||||
{
|
||||
order: 6,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'Celebration #1',
|
||||
artist: 16,
|
||||
start: 2235,
|
||||
link: 'https://nightbeats.bandcamp.com/track/celebration-1',
|
||||
coverId: 'a0031987121'
|
||||
},
|
||||
{
|
||||
order: 7,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: '3030 Instrumental',
|
||||
artist: 17,
|
||||
start: 2458.3,
|
||||
link: 'https://delthefunkyhomosapien.bandcamp.com/track/3030',
|
||||
coverId: 'a1948146136'
|
||||
},
|
||||
{
|
||||
order: 8,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'The Emptiness Of Nothingness',
|
||||
artist: 18,
|
||||
start: 2864.5,
|
||||
link: 'https://theaa.bandcamp.com/track/the-emptiness-of-nothingness',
|
||||
coverId: 'a1053923875'
|
||||
},
|
||||
{
|
||||
order: 9,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'Rising',
|
||||
artist: 19,
|
||||
start: 3145,
|
||||
link: 'https://woodenshjips.bandcamp.com/track/rising',
|
||||
coverId: 'a1884221104'
|
||||
},
|
||||
{
|
||||
order: 10,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'The Last Time',
|
||||
artist: 22,
|
||||
start: 3447,
|
||||
link: 'https://www.discogs.com/release/12110815-Larry-McNeil-And-The-Blue-Knights-Jealous-Woman',
|
||||
coverId:
|
||||
'https://i.discogs.com/Yr05_neEXwzPwKlDeV7dimmTG34atkAMgpxbMBhHBkI/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTEyMTEw/ODE1LTE1Mjg1NjU1/NzQtMjcyOC5qcGVn.jpeg'
|
||||
},
|
||||
{
|
||||
order: 11,
|
||||
boxId: 'ES01',
|
||||
side: 'B',
|
||||
title: 'Guajira Con Arpa',
|
||||
artist: 23,
|
||||
start: 3586,
|
||||
link: 'https://elpalmasmusic.bandcamp.com/track/guajira-con-arpa',
|
||||
coverId: 'a3463036407'
|
||||
}
|
||||
]
|
||||
|
||||
return tracks.map((track, index) => ({
|
||||
id: index + 1,
|
||||
...track,
|
||||
filePath: `https://files.erudi.fr/evilspins/${track.boxId}${track.side}.mp3`,
|
||||
coverId: `https://f4.bcbits.com/img/${track.coverId}_4.jpg`,
|
||||
type: 'compilation'
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
playlist:
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import 'dotenv/config'
|
||||
import { eventHandler } from 'h3'
|
||||
import { getCardFromDate } from '../../../utils/cards'
|
||||
|
||||
export default eventHandler(async (event) => {
|
||||
const dirPath = path.join(process.env.AUDIO_FILES_BASE_PATH)
|
||||
const urlPrefix = `https://files.erudi.fr/music`
|
||||
|
||||
try {
|
||||
let allTracks: any[] = []
|
||||
|
||||
const items = await fs.promises.readdir(dirPath, { withFileTypes: true })
|
||||
|
||||
// Process files
|
||||
const files = items
|
||||
.filter((item) => item.isFile() && !item.name.startsWith('.') && !item.name.endsWith('.jpg'))
|
||||
.map((item) => item.name)
|
||||
|
||||
// Process folders
|
||||
const folders = items
|
||||
.filter((item) => item.isDirectory() && !item.name.startsWith('.'))
|
||||
.map((folder, index) => ({
|
||||
id: `folder-${index}`,
|
||||
boxId: 'ESFOLDER',
|
||||
title: folder.name.replace(/_/g, ' ').trim(),
|
||||
type: 'folder',
|
||||
order: 0,
|
||||
date: new Date(),
|
||||
card: getCardFromDate(new Date())
|
||||
}))
|
||||
|
||||
const tracks = files.map((file, index) => {
|
||||
const EXT_RE = /\.(mp3|flac|wav|opus)$/i
|
||||
const nameWithoutExt = file.replace(EXT_RE, '')
|
||||
|
||||
// On split sur __
|
||||
const parts = nameWithoutExt.split('__')
|
||||
let stamp = parts[0] || ''
|
||||
let artist = parts[1] || ''
|
||||
let title = parts[2] || ''
|
||||
|
||||
title = title.replaceAll('_', ' ')
|
||||
artist = artist.replaceAll('_', ' ')
|
||||
|
||||
// Parser la date depuis le stamp
|
||||
let year = 2020,
|
||||
month = 1,
|
||||
day = 1,
|
||||
hour = 0
|
||||
if (stamp.length === 10) {
|
||||
year = Number(stamp.slice(0, 4))
|
||||
month = Number(stamp.slice(4, 6))
|
||||
day = Number(stamp.slice(6, 8))
|
||||
hour = Number(stamp.slice(8, 10))
|
||||
}
|
||||
|
||||
const date = new Date(year, month - 1, day, hour)
|
||||
const card = getCardFromDate(date)
|
||||
const filePath = `${urlPrefix}/${encodeURIComponent(file)}`
|
||||
const coverId = `${urlPrefix}/cover/${encodeURIComponent(file).replace(EXT_RE, '.jpg')}`
|
||||
|
||||
return {
|
||||
id: Number(`${year}${index + 1}`),
|
||||
boxId: `ESPLAYLIST`,
|
||||
year,
|
||||
date,
|
||||
title: title.trim(),
|
||||
artist: artist.trim(),
|
||||
filePath,
|
||||
coverId,
|
||||
card,
|
||||
order: 0,
|
||||
type: 'playlist'
|
||||
}
|
||||
})
|
||||
|
||||
tracks.sort((a, b) => b.date.getTime() - a.date.getTime())
|
||||
// Combine folders and tracks
|
||||
const allItems = [...folders, ...tracks]
|
||||
|
||||
// Sort by date (newest first) and assign order
|
||||
allItems.sort((a, b) => b.date.getTime() - a.date.getTime())
|
||||
allItems.forEach((item, i) => (item.order = i + 1))
|
||||
|
||||
return allItems
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: (error as Error).message
|
||||
}
|
||||
}
|
||||
})
|
||||
21
server/plugins/initialSync.ts
Normal file
21
server/plugins/initialSync.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { syncCardsWithDatabase } from '../services/cardSync.service'
|
||||
|
||||
export default defineNitroPlugin(async (nitroApp) => {
|
||||
const config = useRuntimeConfig()
|
||||
const folderPath = config.pathFiles || process.env.PATH_FILES
|
||||
|
||||
if (!folderPath) {
|
||||
console.warn('⚠️ PATH_FILES non configuré')
|
||||
return
|
||||
}
|
||||
|
||||
// Sync au démarrage
|
||||
console.log('🚀 Synchronisation initiale au démarrage...')
|
||||
|
||||
try {
|
||||
const result = await syncCardsWithDatabase(folderPath)
|
||||
console.log('✅ Synchronisation initiale terminée:', result)
|
||||
} catch (error: any) {
|
||||
console.error('❌ Erreur lors de la sync initiale:', error)
|
||||
}
|
||||
})
|
||||
58
server/services/cardSync.service.ts
Normal file
58
server/services/cardSync.service.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { eq, notInArray } from 'drizzle-orm'
|
||||
import { useDB, schema } from '../db'
|
||||
import { scanMusicFolder } from '../utils/fileScanner'
|
||||
|
||||
const { cards } = schema
|
||||
|
||||
export async function syncCardsWithDatabase(folderPath: string) {
|
||||
const db = useDB()
|
||||
const scannedCards = await scanMusicFolder(folderPath)
|
||||
|
||||
console.log(`📁 ${scannedCards.length} cards trouvées dans le dossier`)
|
||||
|
||||
// 1. Récupérer les cards existantes en DB
|
||||
const existingCards = await db.select().from(cards).all()
|
||||
const existingEsids = new Set(existingCards.map((t) => t.esid))
|
||||
|
||||
// 2. Identifier les nouvelles cards à ajouter
|
||||
const cardsToInsert = scannedCards.filter((card) => !existingEsids.has(card.esid))
|
||||
|
||||
// 3. Identifier les cards à supprimer
|
||||
const scannedEsids = new Set(scannedCards.map((t) => t.esid))
|
||||
const cardsToDelete = existingCards.filter((t) => !scannedEsids.has(t.esid))
|
||||
|
||||
// 4. Insérer les nouvelles cards
|
||||
if (cardsToInsert.length > 0) {
|
||||
// Dans la fonction syncCardsWithDatabase
|
||||
await db.insert(cards).values(
|
||||
cardsToInsert.map((card) => ({
|
||||
url_audio: card.url_audio,
|
||||
url_image: card.url_image,
|
||||
year: card.year,
|
||||
month: card.month,
|
||||
day: card.day,
|
||||
hour: card.hour,
|
||||
artist: card.artist,
|
||||
title: card.title,
|
||||
esid: card.esid,
|
||||
slug: card.slug,
|
||||
createdAt: card.createdAt,
|
||||
suit: card.suit,
|
||||
rank: card.rank
|
||||
}))
|
||||
)
|
||||
console.log(`✅ ${cardsToInsert.length} cards ajoutées`)
|
||||
}
|
||||
|
||||
// 5. Supprimer les cards obsolètes avec une requête distincte pour chaque esid
|
||||
for (const cardToDelete of cardsToDelete) {
|
||||
await db.delete(cards).where(eq(cards.esid, cardToDelete.esid))
|
||||
console.log(`🗑️ ${cardsToDelete.length} cards supprimées`)
|
||||
}
|
||||
|
||||
return {
|
||||
added: cardsToInsert.length,
|
||||
deleted: cardsToDelete.length,
|
||||
total: scannedCards.length
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { Database } from 'sqlite'
|
||||
import { fileURLToPath } from 'url'
|
||||
import chokidar from 'chokidar'
|
||||
import { db } from '../database'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const PLAYLISTS_DIR = path.join(process.cwd(), 'public/ESPLAYLISTS')
|
||||
const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.m4a', '.flac']
|
||||
|
||||
export class PlaylistSyncService {
|
||||
private watcher: chokidar.FSWatcher | null = null
|
||||
|
||||
constructor(private db: Database) {}
|
||||
|
||||
async initialize() {
|
||||
await this.scanPlaylists()
|
||||
this.setupWatcher()
|
||||
}
|
||||
|
||||
private async scanPlaylists() {
|
||||
try {
|
||||
if (!fs.existsSync(PLAYLISTS_DIR)) {
|
||||
console.warn(`Playlists directory not found: ${PLAYLISTS_DIR}`)
|
||||
return
|
||||
}
|
||||
|
||||
const playlists = fs
|
||||
.readdirSync(PLAYLISTS_DIR, { withFileTypes: true })
|
||||
.filter((dirent) => dirent.isDirectory())
|
||||
.map((dirent) => dirent.name)
|
||||
|
||||
for (const playlistName of playlists) {
|
||||
await this.syncPlaylist(playlistName)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error scanning playlists:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private async syncPlaylist(playlistName: string) {
|
||||
const playlistPath = path.join(PLAYLISTS_DIR, playlistName)
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(playlistPath)
|
||||
|
||||
// Vérifier ou créer la playlist dans la base de données
|
||||
let playlist = await this.db.get('SELECT * FROM playlists WHERE name = ?', [playlistName])
|
||||
|
||||
if (!playlist) {
|
||||
const result = await this.db.run(
|
||||
'INSERT INTO playlists (name, path, last_modified) VALUES (?, ?, ?)',
|
||||
[playlistName, playlistPath, new Date(stats.mtime).toISOString()]
|
||||
)
|
||||
playlist = await this.db.get('SELECT * FROM playlists WHERE id = ?', [result.lastID])
|
||||
}
|
||||
|
||||
// Récupérer les pistes actuelles
|
||||
const currentTracks = fs
|
||||
.readdirSync(playlistPath)
|
||||
.filter((file) => {
|
||||
const ext = path.extname(file).toLowerCase()
|
||||
return AUDIO_EXTENSIONS.includes(ext)
|
||||
})
|
||||
.map((file, index) => ({
|
||||
path: path.join(playlistName, file),
|
||||
order: index
|
||||
}))
|
||||
|
||||
// Mettre à jour les pistes dans la base de données
|
||||
await this.db.run('BEGIN TRANSACTION')
|
||||
try {
|
||||
// Supprimer les anciennes entrées
|
||||
await this.db.run('DELETE FROM playlist_tracks WHERE playlist_id = ?', [playlist.id])
|
||||
|
||||
// Ajouter les nouvelles pistes
|
||||
for (const track of currentTracks) {
|
||||
await this.db.run(
|
||||
'INSERT INTO playlist_tracks (playlist_id, track_path, track_order) VALUES (?, ?, ?)',
|
||||
[playlist.id, track.path, track.order]
|
||||
)
|
||||
}
|
||||
|
||||
// Mettre à jour la date de modification
|
||||
await this.db.run('UPDATE playlists SET last_modified = ? WHERE id = ?', [
|
||||
new Date().toISOString(),
|
||||
playlist.id
|
||||
])
|
||||
|
||||
await this.db.run('COMMIT')
|
||||
console.log(`Playlist "${playlistName}" synchronized with ${currentTracks.length} tracks`)
|
||||
} catch (error) {
|
||||
await this.db.run('ROLLBACK')
|
||||
console.error(`Error syncing playlist ${playlistName}:`, error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error accessing playlist directory ${playlistPath}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
private setupWatcher() {
|
||||
if (!fs.existsSync(PLAYLISTS_DIR)) {
|
||||
console.warn(`Playlists directory not found, watcher not started: ${PLAYLISTS_DIR}`)
|
||||
return
|
||||
}
|
||||
|
||||
this.watcher = chokidar.watch(PLAYLISTS_DIR, {
|
||||
ignored: /(^|[\/\\])\../, // ignore les fichiers cachés
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
depth: 2 // surveille un seul niveau de sous-dossiers
|
||||
})
|
||||
|
||||
this.watcher
|
||||
.on('add', (filePath) => this.handleFileChange('add', filePath))
|
||||
.on('change', (filePath) => this.handleFileChange('change', filePath))
|
||||
.on('unlink', (filePath) => this.handleFileChange('unlink', filePath))
|
||||
.on('addDir', (dirPath) => this.handleDirChange('addDir', dirPath))
|
||||
.on('unlinkDir', (dirPath) => this.handleDirChange('unlinkDir', dirPath))
|
||||
.on('error', (error) => console.error('Watcher error:', error))
|
||||
}
|
||||
|
||||
private async handleFileChange(event: string, filePath: string) {
|
||||
const relativePath = path.relative(PLAYLISTS_DIR, filePath)
|
||||
const playlistName = path.dirname(relativePath)
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
|
||||
// Ignorer les fichiers qui ne sont pas des fichiers audio
|
||||
if (playlistName === '.' || !AUDIO_EXTENSIONS.includes(ext)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`File ${event}: ${relativePath}`)
|
||||
await this.syncPlaylist(playlistName)
|
||||
}
|
||||
|
||||
private async handleDirChange(event: string, dirPath: string) {
|
||||
const relativePath = path.relative(PLAYLISTS_DIR, dirPath)
|
||||
|
||||
if (relativePath === '') return // Ignorer le dossier racine
|
||||
|
||||
console.log(`Directory ${event}: ${relativePath}`)
|
||||
if (event === 'addDir') {
|
||||
await this.syncPlaylist(relativePath)
|
||||
} else if (event === 'unlinkDir') {
|
||||
// Supprimer la playlist de la base de données
|
||||
await this.db.run('DELETE FROM playlists WHERE name = ?', [relativePath])
|
||||
console.log(`Removed playlist from database: ${relativePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.watcher) {
|
||||
return this.watcher.close()
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const playlistSyncService = new PlaylistSyncService(db)
|
||||
23
server/tasks/syncCards.ts
Normal file
23
server/tasks/syncCards.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { syncCardsWithDatabase } from '../services/cardSync.service'
|
||||
|
||||
export default defineTask({
|
||||
meta: {
|
||||
name: 'sync-tracks',
|
||||
description: 'Synchronise les tracks avec le système de fichiers'
|
||||
},
|
||||
async run() {
|
||||
const config = useRuntimeConfig()
|
||||
const folderPath = config.pathFiles || process.env.PATH_FILES || 'mnt/media/files/music'
|
||||
|
||||
console.log('⏰ [TASK] Démarrage de la synchronisation planifiée...')
|
||||
|
||||
try {
|
||||
const result = await syncCardsWithDatabase(folderPath)
|
||||
console.log('✅ [TASK] Synchronisation terminée:', result)
|
||||
return { result }
|
||||
} catch (error: any) {
|
||||
console.error('❌ [TASK] Erreur lors de la synchronisation:', error)
|
||||
return { error: error.message }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "../.nuxt/tsconfig.server.json"
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// Types pour les tables de la base de données
|
||||
|
||||
export interface Box {
|
||||
id: string
|
||||
type: 'compilation' | 'playlist'
|
||||
name: string
|
||||
description?: string
|
||||
state?: string
|
||||
duration: number
|
||||
activeSide?: string
|
||||
color1?: string
|
||||
color2?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface Side {
|
||||
id: number
|
||||
boxId: string
|
||||
side: string
|
||||
name?: string
|
||||
description?: string
|
||||
duration: number
|
||||
color1?: string
|
||||
color2?: string
|
||||
}
|
||||
|
||||
export interface Artist {
|
||||
id: number
|
||||
name: string
|
||||
url?: string
|
||||
coverId?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
id: number
|
||||
boxId: string
|
||||
side?: string
|
||||
trackOrder: number
|
||||
title: string
|
||||
artistId?: number
|
||||
start: number
|
||||
link?: string
|
||||
coverId?: string
|
||||
url?: string
|
||||
type: 'compilation' | 'playlist'
|
||||
year?: number
|
||||
date?: string
|
||||
card?: string
|
||||
}
|
||||
|
||||
// Types pour les réponses API
|
||||
|
||||
export interface BoxWithSides extends Box {
|
||||
sides?: Record<string, Omit<Side, 'id' | 'boxId'>>
|
||||
}
|
||||
|
||||
export interface TrackWithArtist extends Track {
|
||||
artistName?: string
|
||||
artistUrl?: string
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
pagination: {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface TracksSearchResponse {
|
||||
tracks: TrackWithArtist[]
|
||||
pagination: {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
search?: string
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
export function getDatabase(): Database.Database {
|
||||
if (db) return db
|
||||
|
||||
const dbDir = path.join(process.cwd(), 'server/database')
|
||||
const dbPath = path.join(dbDir, 'evilspins.db')
|
||||
|
||||
// Créer le dossier si nécessaire
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Connexion à la base
|
||||
db = new Database(dbPath, {
|
||||
verbose: process.env.NODE_ENV === 'development' ? console.log : undefined
|
||||
})
|
||||
|
||||
// Activer les clés étrangères
|
||||
db.pragma('foreign_keys = ON')
|
||||
|
||||
// Initialiser le schéma si la DB est vide
|
||||
initializeSchema(db)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
function initializeSchema(database: Database.Database) {
|
||||
const schemaPath = path.join(process.cwd(), 'server/database/schema.sql')
|
||||
|
||||
// Vérifier si les tables existent déjà
|
||||
const tableCheck = database
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='tracks'")
|
||||
.get()
|
||||
|
||||
if (!tableCheck) {
|
||||
console.log('🔧 Initialisation du schéma de la base de données...')
|
||||
const schema = fs.readFileSync(schemaPath, 'utf-8')
|
||||
|
||||
// Exécuter chaque statement SQL
|
||||
const statements = schema
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
|
||||
for (const statement of statements) {
|
||||
database.exec(statement)
|
||||
}
|
||||
|
||||
console.log('✅ Schéma initialisé avec succès')
|
||||
}
|
||||
}
|
||||
|
||||
export function closeDatabase() {
|
||||
if (db) {
|
||||
db.close()
|
||||
db = null
|
||||
}
|
||||
}
|
||||
119
server/utils/fileScanner.ts
Normal file
119
server/utils/fileScanner.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { join, extname, basename } from 'node:path'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { slugify } from './slugify'
|
||||
import { getCardFromDate } from './getCardFromDate'
|
||||
import type { Card } from '@/types/types'
|
||||
|
||||
const listAudioExts = ['.mp3', '.opus', 'flac']
|
||||
const listImageExts = ['.jpg', '.jpeg', '.webp']
|
||||
|
||||
export async function scanMusicFolder(folderPath: string): Promise<Card[]> {
|
||||
try {
|
||||
const files = await readdir(folderPath)
|
||||
const cardMap = new Map<string, Card>()
|
||||
|
||||
// D'abord, on traite tous les fichiers audio
|
||||
for (const file of files) {
|
||||
const ext = extname(file).toLowerCase()
|
||||
|
||||
// On ne traite que les fichiers audio
|
||||
if (!listAudioExts.includes(ext)) continue
|
||||
|
||||
const parsed = await parseFilename(join(folderPath, file))
|
||||
if (parsed) {
|
||||
// On vérifie s'il existe une image avec le même nom de base
|
||||
const baseName = basename(file, ext)
|
||||
let imageUrl = ''
|
||||
|
||||
// On cherche une image correspondante
|
||||
for (const imgExt of listImageExts) {
|
||||
const potentialImage = baseName + imgExt
|
||||
if (files.includes(potentialImage)) {
|
||||
imageUrl = process.env.URL_PREFIX + baseName + imgExt
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cardMap.set(parsed.esid, {
|
||||
...parsed,
|
||||
url_audio: process.env.URL_PREFIX + baseName + ext,
|
||||
url_image: imageUrl,
|
||||
suit: parsed.suit,
|
||||
rank: parsed.rank
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(cardMap.values())
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du scan du dossier:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function parseFilename(
|
||||
filename: string
|
||||
): Promise<Omit<Card, 'url_audio' | 'url_image'> | null> {
|
||||
// Format: yyyymmddhh__artist__title.ext
|
||||
const nameWithoutExt = basename(filename, extname(filename))
|
||||
const parts = nameWithoutExt.split('__')
|
||||
|
||||
if (parts.length !== 3) {
|
||||
console.warn(`Nom de fichier invalide: ${filename}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const [datetime, artist, title] = parts
|
||||
|
||||
if (!datetime || !artist || !title) {
|
||||
console.warn(`Format de fichier invalide: ${filename} - manque des parties`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (datetime.length !== 10) {
|
||||
console.warn(`Format de date invalide: ${filename}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Utilisation d'un hash basé sur le contenu du fichier pour un ESID stable
|
||||
let fileHash = ''
|
||||
try {
|
||||
const fileContent = await readFile(filename)
|
||||
fileHash = createHash('md5').update(fileContent).digest('hex').substring(0, 8)
|
||||
} catch (error) {
|
||||
console.warn(`Impossible de lire le fichier pour générer le hash: ${filename}`)
|
||||
fileHash = createHash('md5').update(filename).digest('hex').substring(0, 8)
|
||||
}
|
||||
|
||||
const year = datetime.substring(0, 4)
|
||||
const month = datetime.substring(4, 6)
|
||||
const day = datetime.substring(6, 8)
|
||||
const hour = datetime.substring(8, 10)
|
||||
// Créer l'ID unique pour la card
|
||||
const esid = createHash('md5')
|
||||
.update(`${year}${month}${day}${hour}${artist}${title}`)
|
||||
.digest('hex')
|
||||
|
||||
const date = new Date(
|
||||
parseInt(year, 10),
|
||||
parseInt(month, 10) - 1,
|
||||
parseInt(day, 10),
|
||||
parseInt(hour, 10)
|
||||
)
|
||||
const card = getCardFromDate(date)
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
artist: artist.replace(/_/g, ' '), // Remplacer les _ par des espaces
|
||||
title: title.replace(/_/g, ' '),
|
||||
esid,
|
||||
slug: slugify(`${artist} ${title}`),
|
||||
createdAt: date,
|
||||
suit: card.suit,
|
||||
rank: card.rank
|
||||
}
|
||||
}
|
||||
21
server/utils/getCardFromDate.ts
Normal file
21
server/utils/getCardFromDate.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Suit, Rank } from '../../types/types'
|
||||
|
||||
export function getCardFromDate(date: Date): { suit: Suit; rank: Rank } {
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
const hour = date.getHours()
|
||||
|
||||
const suit: Suit =
|
||||
month >= 12 || month <= 2
|
||||
? '♠'
|
||||
: month >= 3 && month <= 5
|
||||
? '♥'
|
||||
: month >= 6 && month <= 8
|
||||
? '♦'
|
||||
: '♣'
|
||||
|
||||
const ranks: Rank[] = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
|
||||
const rank = ranks[(day + hour) % ranks.length]
|
||||
|
||||
return { suit, rank }
|
||||
}
|
||||
9
server/utils/slugify.ts
Normal file
9
server/utils/slugify.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function slugify(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+/, '')
|
||||
.replace(/-+$/, '')
|
||||
}
|
||||
Reference in New Issue
Block a user