yeah
All checks were successful
Deploy App / build (push) Successful in 10s
Deploy App / deploy (push) Successful in 11s

This commit is contained in:
valere
2026-02-02 21:00:28 +01:00
parent e257e076c4
commit b9a3d0184f
98 changed files with 5068 additions and 3713 deletions

View File

@@ -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

View File

@@ -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`
}))
})

View File

@@ -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
View 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
}
})

View 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
View 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
}
}
})

View 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
})
}
})

View 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
}
}
})

View 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
}
}
})

View File

@@ -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)
}
}
})

View File

@@ -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
}
}
})

View File

@@ -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.

View File

@@ -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)
})
}

View File

@@ -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
View 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
View 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
View 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
}
}
})

View 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)
}
})

View 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
}
}

View File

@@ -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
View 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 }
}
}
})

View File

@@ -1,3 +0,0 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}

View File

@@ -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
}

View File

@@ -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
View 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
}
}

View 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
View 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(/-+$/, '')
}