25 Commits

Author SHA1 Message Date
valere
b8cc3d277d clean CI
All checks were successful
Deploy App / build (push) Successful in 1m57s
Deploy App / deploy (push) Successful in 31s
2026-02-03 15:08:43 +01:00
valere
19d19edb1c deploy 1
All checks were successful
Deploy App / build (push) Successful in 2m24s
Deploy App / deploy (push) Successful in 24s
2026-02-02 21:34:07 +01:00
valere
98e6213fa1 sub domain deploy for branches 22
All checks were successful
Deploy App / build (push) Successful in 18s
Deploy App / deploy (push) Successful in 19s
2026-02-02 21:21:00 +01:00
valere
8e78511738 sub domain deploy for branches 21
All checks were successful
Deploy App / build (push) Successful in 21s
Deploy App / deploy (push) Successful in 28s
2026-02-02 21:14:24 +01:00
valere
dbd992854b sub domain deploy for branches 20
All checks were successful
Deploy App / build (push) Successful in 11s
Deploy App / deploy (push) Successful in 12s
2026-02-02 21:13:21 +01:00
valere
b9a3d0184f yeah
All checks were successful
Deploy App / build (push) Successful in 10s
Deploy App / deploy (push) Successful in 11s
2026-02-02 21:00:28 +01:00
valere
e257e076c4 sub domain deploy for branches 19
All checks were successful
Deploy App / build (push) Successful in 10s
Deploy App / deploy (push) Successful in 10s
2026-02-02 20:58:48 +01:00
valere
57df3dbd5b sub domain deploy for branches 18
All checks were successful
Deploy App / build (push) Successful in 10s
Deploy App / deploy (push) Successful in 10s
2026-02-02 20:57:48 +01:00
valere
4ff3da8380 sub domain deploy for branches 17 2026-02-02 20:57:28 +01:00
valere
c5e5f1abf5 sub domain deploy for branches 16
All checks were successful
Deploy App / build (push) Successful in 1m37s
Deploy App / deploy (push) Successful in 23s
2026-02-02 20:53:25 +01:00
valere
a0fce1a8d3 sub domain deploy for branches 1(
Some checks failed
Deploy App / build (push) Failing after 10s
Deploy App / deploy (push) Has been skipped
2026-02-02 20:50:38 +01:00
valere
0cbdfdeff1 sub domain deploy for branches 14
Some checks failed
Deploy App / build (push) Failing after 26s
Deploy App / deploy (push) Has been skipped
2026-02-02 20:42:49 +01:00
valere
a48cd4049d sub domain deploy for branches 13
Some checks failed
Deploy App / build (push) Waiting to run
Deploy App / deploy (push) Has been cancelled
2026-02-02 20:39:09 +01:00
valere
0b709ff0dc sub domain deploy for branches 12
All checks were successful
Deploy App / build (push) Successful in 11s
Deploy App / deploy (push) Successful in 10s
2026-02-02 20:37:23 +01:00
valere
75507452ac sub domain deploy for branches 11
All checks were successful
Deploy App / build (push) Successful in 11s
Deploy App / deploy (push) Successful in 10s
2026-02-02 20:31:49 +01:00
valere
8337eb9e4c sub domain deploy for branches 10
Some checks failed
Deploy App / build (push) Failing after 3m14s
Deploy App / deploy (push) Has been skipped
2026-02-02 20:25:18 +01:00
valere
1310210ac9 sub domain deploy for branches 9
Some checks failed
Deploy App / build (push) Failing after 1m8s
Deploy App / deploy (push) Has been skipped
2026-02-02 20:03:11 +01:00
valere
deeff36440 sub domain deploy for branches 8
Some checks failed
Deploy App / build (push) Failing after 53s
Deploy App / deploy (push) Has been skipped
2026-02-02 20:01:26 +01:00
valere
f896a8a828 sub domain deploy for branches 7
All checks were successful
Deploy App / build (push) Successful in 16s
Deploy App / deploy (push) Successful in 13s
2026-02-02 19:58:24 +01:00
valere
b769eac9cc sub domain deploy for branches 6
All checks were successful
Deploy App / build (push) Successful in 29s
Deploy App / deploy (push) Successful in 14s
2026-02-02 19:53:39 +01:00
valere
64eb4d09b9 sub domain deploy for branches 5
Some checks failed
Deploy App / build (push) Waiting to run
Deploy App / deploy (push) Has been cancelled
2026-02-02 19:38:31 +01:00
valere
0a587b5e69 sub domain deploy for branches 4
Some checks failed
Deploy App / build (push) Failing after 13s
Deploy App / deploy (push) Has been skipped
2026-02-02 19:37:36 +01:00
valere
7a9f4d369c sub domain deploy for branches 3
All checks were successful
Deploy App / build (push) Successful in 34s
Deploy App / deploy (push) Successful in 27s
2026-02-02 15:34:59 +01:00
valere
d40ca3b1d1 sub domain deploy for branches 2
All checks were successful
Deploy App / build (push) Successful in 14s
Deploy App / deploy (push) Successful in 11s
2026-02-02 12:00:57 +01:00
valere
8573165e4b sub domain deploy for branches
All checks were successful
Deploy App / build (push) Successful in 1m46s
Deploy App / deploy (push) Successful in 35s
2026-02-02 11:41:26 +01:00
103 changed files with 5193 additions and 3766 deletions

7
.env
View File

@@ -1,3 +1,10 @@
DOMAIN=evilspins.com
PORT=7901
PORT_EXPOSED=3000
PATH_FILES=mnt/media/files/music
PATH_DB=data/music.db
EXT_TRACK=mp3
EXT_COVER=jpg
URL_PREFIX=https://files.erudi.fr/music/
NODE_ENV=production
ENABLE_WATCHER=true

View File

@@ -1,35 +0,0 @@
name: Deploy App
on: [push]
jobs:
build:
runs-on: ubuntu-22.04
container:
volumes:
- /var/docker-web:/var/docker-web
steps:
- uses: actions/checkout@v4
- name: Prepare and build app
run: |
REPO_NAME="${GITHUB_REPOSITORY##*/}"
APP_DIR="/var/docker-web/apps/${REPO_NAME}"
bash /var/docker-web/src/cli.sh down "${REPO_NAME}"
rm -rf "$APP_DIR"
mkdir "$APP_DIR"
cp -a $(find . -mindepth 1 -maxdepth 1 ! -name '.git' ! -name 'node_modules') "$APP_DIR/"
export COMPOSE_BAKE=false
docker rmi "local/${REPO_NAME}" 2>/dev/null || true
bash /var/docker-web/src/cli.sh build "${REPO_NAME}"
deploy:
runs-on: ubuntu-22.04
needs: build
container:
volumes:
- /var/docker-web:/var/docker-web
steps:
- uses: actions/checkout@v4
- name: Deploy with docker-web
run: |
REPO_NAME="${GITHUB_REPOSITORY##*/}"
bash /var/docker-web/src/cli.sh up "${REPO_NAME}"

23
.github/workflows/merge.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Delete Merged Branches
on:
pull_request:
types: [closed]
jobs:
delete-branch:
runs-on: ubuntu-22.04
container:
volumes:
- /var/docker-web:/var/docker-web
if: github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- name: Delete merged branch
run: |
BRANCH_NAME="${{ github.event.pull_request.head.ref || github.event.inputs.branch_name }}"
APP_NAME="${BRANCH_NAME}_${GITHUB_REPOSITORY##*/}"
if [ "$BRANCH_NAME" != "main" ] && [ "$BRANCH_NAME" != "develop" ] && [ -n "$APP_NAME" ]; then
bash /var/docker-web/src/cli.sh rm -y "${APP_NAME}"
else
echo "Cannot delete protected branch: $BRANCH_NAME"
fi

41
.github/workflows/push.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: Deploy App
on: [push]
jobs:
build:
runs-on: ubuntu-22.04
container:
volumes:
- /var/docker-web:/var/docker-web
steps:
- uses: actions/checkout@v4
- name: Build app
run: |
bash ./.github/workflows/setup-env.sh
set -a && source .env && set +a
if [ -n "$APP_NAME" ]; then
set -a && source .env && set +a
bash /var/docker-web/src/cli.sh down "${APP_NAME}"
rm -rf "$APP_DIR"
mkdir "$APP_DIR"
cp -a $(find . -mindepth 1 -maxdepth 1 ! -name '.git' ! -name 'node_modules') "$APP_DIR/"
export COMPOSE_BAKE=false
docker rmi "local/${APP_NAME}" 2>/dev/null || true
bash /var/docker-web/src/cli.sh build "${APP_NAME}"
fi
deploy:
runs-on: ubuntu-22.04
needs: build
container:
volumes:
- /var/docker-web:/var/docker-web
steps:
- uses: actions/checkout@v4
- name: Deploy
run: |
bash ./.github/workflows/setup-env.sh
set -a && source .env && set +a
if [ -n "$APP_NAME" ]; then
bash /var/docker-web/src/cli.sh up "${APP_NAME}"
fi

34
.github/workflows/setup-env.sh vendored Normal file
View File

@@ -0,0 +1,34 @@
#!/bin/bash
set -a && source .env && set +a
changeEnvVar() {
local var_name="$1"
local new_value="$2"
local env_file=".env"
if grep -q "^${var_name}=" "$env_file"; then
sed -i "s|${var_name}=.*|${var_name}=${new_value}|" "$env_file"
else
echo "${var_name}=${new_value}" >> "$env_file"
fi
}
# Variables GitHub
APP_NAME="${GITHUB_REPOSITORY##*/}"
BRANCH_NAME=$GITHUB_REF_NAME
# Configuration pour les branches non-principales
if [ "$BRANCH_NAME" != "main" ] && [ "$BRANCH_NAME" != "master" ]; then
DOMAIN="$BRANCH_NAME.$DOMAIN"
APP_NAME="${BRANCH_NAME}_${APP_NAME}"
PORT=$(bash /var/docker-web/src/cli.sh ALLOCATE_PORT)
sed -i "s|${GITHUB_REPOSITORY##*/}|$APP_NAME|g" docker-compose.yml
fi
APP_DIR="/var/docker-web/apps/$APP_NAME"
changeEnvVar "DOMAIN" $DOMAIN
changeEnvVar "APP_NAME" $APP_NAME
changeEnvVar "PORT" $PORT
changeEnvVar "APP_DIR" $APP_DIR

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@
.nitro
.cache
dist
drizzle
# Node dependencies
node_modules

View File

@@ -1,5 +1,5 @@
# Builder
FROM node:20-alpine AS builder
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
@@ -9,12 +9,12 @@ COPY . .
RUN npm run build
# Runtime
FROM node:20-alpine AS runner
RUN apk add --no-cache python3 make g++ sqlite
FROM node:20 AS runner
RUN apt-get update && apt-get install -y sqlite3 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/.output ./.output
COPY package*.json ./
COPY ./server/database ./server/database
COPY ./data ./data
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

View File

@@ -1,82 +1,8 @@
<template>
<div>
<div class="min-h-screen bg-gray-100">
<NuxtRouteAnnouncer />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>
<script setup>
import { useUiStore } from '~/store/ui'
import { usePlayerStore } from '~/store/player'
import { watch, computed } from 'vue'
const ui = useUiStore()
const player = usePlayerStore()
useHead({
bodyAttrs: {
class: 'bg-slate-100'
}
})
const router = useRouter()
const route = useRoute()
watch(
() => player.currentTrack?.id,
(id) => {
if (!id) {
if (route.name === 'track-id') router.replace({ path: '/' })
return
}
const currentParam = Number(
Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
)
if (route.name === 'track-id' && currentParam === id) return
router.replace({ name: 'track-id', params: { id } })
},
{ flush: 'post' }
)
// Keep URL in sync with selected box: /box/:id when a box is selected, back to / when none
const selectedBoxId = computed(() => ui.getSelectedBox?.id)
watch(
() => selectedBoxId.value,
(id) => {
if (import.meta.client) {
if (!id) {
// Back to root path without navigation to preserve UI state/animations
if (location.pathname.startsWith('/box/')) {
history.replaceState(null, '', '/')
}
return
}
const currentId = location.pathname.startsWith('/box/')
? location.pathname.split('/').pop()
: null
if (currentId === id) return
requestAnimationFrame(() => {
history.replaceState(null, '', `/box/${id}`)
})
}
},
{ flush: 'post' }
)
</script>
<style>
button,
input {
@apply px-4 py-2 m-4 rounded-md text-center font-bold;
}
input[type='email'] {
@apply bg-slate-900 text-esyellow;
}
img {
user-drag: none;
user-select: none;
}
</style>

View File

@@ -1,56 +1,39 @@
<template>
<article v-bind="attrs" :role="props.role" :draggable="isFaceUp" :class="[
<article :role="props.role" :class="[
'card cursor-pointer',
isFaceUp ? 'face-up' : 'face-down',
{ 'current-track': playerStore.currentTrack?.id === track.id },
{ 'is-dragging': isDragging }
]" :tabindex="props.tabindex" :aria-disabled="false" @click.stop="handleClick" @keydown.enter.stop="handleClick"
@keydown.space.prevent.stop="handleClick" @dragstart="handleDragStart" @dragend="handleDragEnd"
@drag="handleDragMove" @touchstart.passive="!isFaceUp" @touchmove.passive="!isFaceUp">
isFaceUp ? 'face-up' : 'face-down'
]" :tabindex="props.tabindex" :aria-disabled="false" @click="$emit('click', $event)"
@keydown.enter="$emit('click', $event)" @keydown.space.prevent="$emit('click', $event)">
<div class="flip-inner" ref="cardElement">
<!-- Face-Up -->
<main
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 hover:shadow-xl hover:scale-110 transition-all rounded-2xl shadow-lg flex flex-col overflow-hidden">
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 right-7"
@click.stop="clickCardSymbol">
<div class="flex items-center justify-center size-7 absolute top-7 right-7">
<div class="suit text-7xl absolute"
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.card?.suit]">
<img :src="`/${props.card?.suit}.svg`" />
</div>
<div class="rank text-white font-bold absolute -mt-1">
{{ props.track.card?.rank }}
</div>
</div>
<div v-else class="flex items-center justify-center size-7 absolute top-6 left-6">
<div class="rank text-white font-bold absolute -mt-1">
{{ props.track.order }}
{{ props.card?.rank }}
</div>
</div>
<!-- Cover -->
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer">
<playButton :objectToPlay="track" />
<img draggable="false" v-if="isFaceUp" :src="coverUrl" alt="Pochette de l'album" loading="lazy"
<playButton :objectToPlay="card" />
<img v-if="isFaceUp" :src="props.card.url_image" alt="Pochette de l'album" loading="lazy"
class="w-full h-full object-cover object-center" />
</figure>
<!-- Body -->
<div
class="card-body p-3 text-center bg-white rounded-b-xl opacity-0 -mt-16 hover:opacity-100 hover:-mt-0 transition-all duration-300">
<div v-if="isOrder" class="label">
{{ props.track.order }}
</div>
<h2 class="select-text text-sm text-neutral-500 first-letter:uppercase truncate">
{{ props.track.title || 'title' }}
{{ props.card.title || 'title' }}
</h2>
<p v-if="isPlaylistTrack && track.artist && typeof track.artist === 'object'"
class="select-text text-base text-neutral-800 font-bold capitalize truncate">
{{ track.artist.name || 'artist' }}
</p>
<p v-else-if="isPlaylistTrack" class="select-text text-base text-neutral-800 font-bold capitalize truncate">
{{ typeof track.artist === 'string' ? track.artist : 'artist' }}
<p class="select-text text-base text-neutral-800 font-bold capitalize truncate">
{{ props.card.artist || 'artist' }}
</p>
</div>
</main>
@@ -60,317 +43,35 @@
class="face-down backdrop-blur-sm z-10 card w-56 h-80 p-3 bg-opacity-10 bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden select-none">
<figure class="h-full flex text-center rounded-xl justify-center items-center"
:style="{ backgroundColor: cardColor }">
<playButton :objectToPlay="track" />
<img draggable="false" src="/face-down.svg" />
<playButton :objectToPlay="card" />
<img src="/face-down.svg" />
</figure>
</footer>
</div>
</article>
<!-- Clone fantôme unifié pour drag souris ET tactile -->
<Teleport to="body">
<div v-if="isDragging && touchClone" ref="ghostElement"
class="ghost-card fixed pointer-events-none z-[9999] w-56 h-80" :style="{
left: touchClone.x + 'px',
top: touchClone.y + 'px',
transform: 'translate(-50%, -50%)'
}">
<div class="flip-inner">
<main
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 rounded-2xl shadow-2xl flex flex-col overflow-hidden bg-white bg-opacity-90">
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 left-7">
<div class="suit text-7xl absolute"
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
</div>
<div class="rank text-white font-bold absolute -mt-1">
{{ props.track.card?.rank }}
</div>
</div>
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl">
<img draggable="false" :src="coverUrl" alt="Pochette de l'album"
class="w-full h-full object-cover object-center" />
</figure>
</main>
</div>
</div>
</Teleport>
<!-- Modal de partage -->
<ModalSharer v-if="showModalSharer" ref="modalSharer" />
</template>
<script setup lang="ts">
import type { Track } from '~~/types/types'
import { usePlayerStore } from '~/store/player'
import { useDataStore } from '~/store/data'
import { useNuxtApp } from '#app'
import ModalSharer from '~/components/ui/ModalSharer.vue'
import type { Card } from '~~/types/types'
const emit = defineEmits(['click'])
const props = withDefaults(defineProps<{
track: Track;
card: Card;
isFaceUp?: boolean;
role?: string;
tabindex?: string | number;
'onUpdate:isFaceUp'?: (value: boolean) => void;
}>(), {
isFaceUp: true,
isFaceUp: false,
role: 'button',
tabindex: '0'
})
// Use useAttrs to get all other attributes
const attrs = useAttrs()
import { getYearColor } from '~/utils/colors'
const modalSharer = ref<InstanceType<typeof ModalSharer> | null>(null)
const showModalSharer = ref(false)
const cardColor = computed(() => getYearColor(props.card.year || 0))
const isRedCard = computed(() => (props.card?.suit === '♥' || props.card?.suit === '♦'))
const emit = defineEmits<{
(e: 'update:isFaceUp', value: boolean): void;
(e: 'cardClick', track: Track): void;
(e: 'clickCardSymbol', track: Track): void;
(e: 'dragstart', event: DragEvent): void;
(e: 'dragend', event: DragEvent): void;
(e: 'drag', event: DragEvent): void;
(e: 'click', event: MouseEvent): void;
}>()
// Handle click events (mouse and keyboard)
const handleClick = (event: MouseEvent | KeyboardEvent) => {
if (!isDragging.value && !hasMovedDuringPress.value) {
emit('cardClick', props.track);
emit('click', event as MouseEvent);
}
hasMovedDuringPress.value = false;
}
const clickCardSymbol = (event: MouseEvent) => {
event.stopPropagation();
// Afficher la modale
showModalSharer.value = true;
// Donner le focus à la modale après le rendu
nextTick(() => {
if (modalSharer.value) {
modalSharer.value.open(props.track);
}
});
emit('clickCardSymbol', props.track);
}
// Handle drag start with proper event emission
const handleDragStart = (event: DragEvent) => {
if (!props.isFaceUp) {
event.preventDefault();
return;
}
const { $bodyClass } = useNuxtApp()
$bodyClass.add('card-dragging')
dragStart(event);
emit('dragstart', event);
}
// Handle drag end with proper event emission
const handleDragEnd = (event: DragEvent) => {
if (!props.isFaceUp) {
event.preventDefault();
return;
}
const { $bodyClass } = useNuxtApp()
$bodyClass.remove('card-dragging')
dragEnd(event);
emit('dragend', event);
}
// Handle drag move with proper event emission
const handleDragMove = (event: DragEvent) => {
if (!props.isFaceUp) {
event.preventDefault();
return;
}
dragMove(event);
emit('drag', event);
}
const playerStore = usePlayerStore()
const isManifesto = computed(() => props.track.boxId.startsWith('ES00'))
const isOrder = computed(() => props.track.order && !isManifesto)
const isPlaylistTrack = computed(() => props.track.type === 'playlist')
const isRedCard = computed(() => (props.track.card?.suit === '♥' || props.track.card?.suit === '♦'))
const dataStore = useDataStore()
const cardColor = computed(() => dataStore.getYearColor(props.track.year || 0))
const coverUrl = computed(() => props.track.coverId || '/card-dock.svg')
const isDragging = ref(false)
const cardElement = ref<HTMLElement | null>(null)
const ghostElement = ref<HTMLElement | null>(null)
// État unifié pour souris et tactile
const touchClone = ref<{ x: number, y: number } | null>(null)
const touchStartPos = ref<{ x: number, y: number } | null>(null)
const longPressTimer = ref<number | null>(null)
const LONG_PRESS_DURATION = 200 // ms
const hasMovedDuringPress = ref(false)
// Drag desktop - utilise maintenant ghostElement
const dragStart = (event: DragEvent) => {
if (event.dataTransfer && cardElement.value) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('application/json', JSON.stringify(props.track));
// Créer une image transparente pour masquer l'image par défaut du navigateur
const img = new Image();
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0);
// Activer le clone fantôme
isDragging.value = true
touchClone.value = {
x: event.clientX,
y: event.clientY
}
}
};
// Nouveau: suivre le mouvement de la souris pendant le drag
const dragMove = (event: DragEvent) => {
if (isDragging.value && touchClone.value && event.clientX !== 0 && event.clientY !== 0) {
touchClone.value = {
x: event.clientX,
y: event.clientY
}
}
}
const instance = getCurrentInstance();
const dragEnd = (event: DragEvent) => {
isDragging.value = false
touchClone.value = null
if (event.dataTransfer?.dropEffect === 'move' && instance?.vnode?.el?.parentNode) {
instance.vnode.el.parentNode.removeChild(instance.vnode.el);
const parent = instance.parent;
if (parent?.update) {
parent.update();
}
}
}
// Touch events
const touchStart = (event: TouchEvent) => {
const touch = event.touches[0];
if (!touch) return;
touchStartPos.value = { x: touch.clientX, y: touch.clientY };
hasMovedDuringPress.value = false;
// Démarrer un timer pour le long press
longPressTimer.value = window.setTimeout(() => {
startTouchDrag(touch);
}, LONG_PRESS_DURATION);
}
const startTouchDrag = (touch: Touch) => {
if (!touch) return;
isDragging.value = true;
touchClone.value = {
x: touch.clientX,
y: touch.clientY
};
// Vibration feedback si disponible
if (navigator.vibrate) {
navigator.vibrate(50);
}
}
const touchMove = (event: TouchEvent) => {
const touch = event.touches[0];
if (!touch || !longPressTimer.value) return;
// Annuler le long press si l'utilisateur bouge trop
const dx = touch.clientX - (touchStartPos.value?.x || 0);
const dy = touch.clientY - (touchStartPos.value?.y || 0);
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 10) { // Seuil de tolérance pour un tap
clearTimeout(longPressTimer.value)
longPressTimer.value = null
hasMovedDuringPress.value = true
}
if (isDragging.value && touchClone.value) {
event.preventDefault()
const touch = event.touches[0]
touchClone.value = {
x: touch.clientX,
y: touch.clientY
}
// Déterminer l'élément sous le doigt
checkDropTarget(touch.clientX, touch.clientY)
}
}
const touchEnd = (event: TouchEvent) => {
// Annuler le timer de long press
if (longPressTimer.value) {
clearTimeout(longPressTimer.value);
longPressTimer.value = null;
}
// Vérifier si c'était un tap simple (pas de déplacement)
if (!hasMovedDuringPress.value && touchStartPos.value) {
const touch = event.changedTouches[0];
if (touch) {
const dx = touch.clientX - touchStartPos.value.x;
const dy = touch.clientY - touchStartPos.value.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 10) { // Seuil de tolérance pour un tap
handleClick(new MouseEvent('click'));
}
}
}
// Réinitialiser l'état de glisser-déposer
if (isDragging.value) {
// Vérifier si on est au-dessus d'une cible de dépôt
const touch = event.changedTouches[0];
if (touch) {
checkDropTarget(touch.clientX, touch.clientY);
}
}
// Nettoyer
isDragging.value = false;
touchClone.value = null;
touchStartPos.value = null;
hasMovedDuringPress.value = false;
}
const checkDropTarget = (x: number, y: number): HTMLElement | null => {
const element = document.elementFromPoint(x, y);
if (element) {
const dropZone = element.closest('[data-drop-zone]');
if (dropZone) {
return dropZone as HTMLElement;
}
}
return null;
}
// Cleanup
onUnmounted(() => {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
}
})
</script>
<style lang="scss">
@@ -391,7 +92,6 @@ onUnmounted(() => {
.card {
perspective: 1000px;
@apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80;
touch-action: none;
.flip-inner {
position: relative;
@@ -440,7 +140,7 @@ onUnmounted(() => {
}
&:focus,
&.current-track {
&.current-card {
@apply z-50 scale-110;
outline: none;
@@ -453,7 +153,7 @@ onUnmounted(() => {
}
&:focus,
&.current-track {
&.current-card {
.play-button {
@apply opacity-100;
}
@@ -474,7 +174,7 @@ onUnmounted(() => {
}
.play-button {
@apply absolute bottom-1/2 top-24 opacity-0 hover:opacity-100;
@apply absolute bottom-1/2 top-28 opacity-0 hover:opacity-100;
}
.pochette:active,
@@ -498,22 +198,4 @@ onUnmounted(() => {
}
}
}
/* Ghost card styles - maintenant unifié pour souris et tactile */
.ghost-card {
transition: none;
.card {
@apply shadow-2xl scale-95 rotate-6;
.play-button,
.card-body {
display: none;
}
}
.flip-inner {
perspective: 1000px;
}
}
</style>

View File

@@ -12,25 +12,12 @@
</template>
<script setup lang="ts">
import { usePlatineStore } from '~/store/platine'
import type { Box, Track } from '~/../types/types'
const platineStore = usePlatineStore()
const props = defineProps<{ objectToPlay: Box | Track }>()
const isCurrentTrack = computed(() => {
if (!('activeSide' in props.objectToPlay)) {
return platineStore.currentTrack?.id === props.objectToPlay.id
}
return false
})
const isPlaying = computed(() => {
return platineStore.isPlaying && isCurrentTrack.value
})
const isLoading = computed(() => {
return platineStore.isLoadingTrack && isCurrentTrack.value
const props = withDefaults(defineProps<{
isLoading?: boolean;
isPlaying?: boolean;
}>(), {
isLoading: false,
isPlaying: false
})
</script>

View File

@@ -1,18 +1,5 @@
<template>
<div>
<h1
class="text-white pt-6 text-lg md:text-xl lg:text-2xl text-center font-bold tracking-widest text-shadow"
>
{{ error?.statusCode }}
</h1>
<NuxtLink to="/">Go back home</NuxtLink>
error
</div>
</template>
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps({
error: Object as () => NuxtError
})
</script>
</template>

View File

@@ -1,55 +1,3 @@
<template>
<slot />
<Bucket />
<Platine v-if="playerStore.currentTrack" />
</template>
<script setup lang="ts">
import type { Track } from '~~/types/types'
import { usePlayerStore } from '~/store/player'
const playerStore = usePlayerStore()
const onCardDropped = (card: Track) => {
console.log('Carte déposée dans le bucket:', card)
}
</script>
<style scoped>
.bucket,
.platine {
position: fixed;
bottom: -100%;
right: 0;
height: auto;
}
.bucket {
z-index: 70;
bottom: -260px;
width: 100%;
overflow-x: scroll;
transition: bottom .3s ease;
&:hover,
.card-dragging & {
bottom: 0;
}
.bucket-card-wrapper {
width: 100%;
}
}
.platine {
bottom: -100%;
transition: bottom 2s ease;
&.mounted {
z-index: 80;
bottom: 0;
width: 100%;
max-width: 450px;
}
}
</style>

43
app/pages/card/[slug].vue Normal file
View File

@@ -0,0 +1,43 @@
<template>
<section class="screen-centered">
<Card :card="card" :isFaceUp="isFaceUp" @click="clickOnSlugCard" />
</section>
</template>
<script setup lang="ts">
import type { Card } from '~~/types/types'
const route = useRoute()
const slug = route.params.slug as string
const isFaceUp = ref(false)
const { data: card, pending, error } = await useFetch<Card>(`/api/card/${slug}`)
useHead({
title: computed(() =>
card.value ? `${card.value.artist} - ${card.value.title}` : 'Loading...'
)
})
const clickOnSlugCard = () => {
isFaceUp.value = true
const audio = new Audio(card.value?.url_audio)
audio.play()
}
onMounted(() => {
setTimeout(() => {
clickOnSlugCard()
}, 700)
})
</script>
<style>
.screen-centered {
position: fixed;
inset: 0;
height: 100dvh;
width: 100dvw;
display: grid;
place-items: center;
}
</style>

View File

@@ -1,27 +1,5 @@
<template>
<boxes />
<div>
here is the New New front
</div>
</template>
<script setup>
import { useUiStore } from '~/store/ui'
import { useDataStore } from '~/store/data'
// Configuration du layout
definePageMeta({
layout: 'default'
})
const uiStore = useUiStore()
onMounted(async () => {
const dataStore = useDataStore()
await dataStore.loadData()
uiStore.listBoxes()
})
</script>
<style>
.logo {
filter: drop-shadow(3px 3px 0 rgb(0 0 0 / 0.7));
}
</style>

39
app/utils/colors.ts Normal file
View File

@@ -0,0 +1,39 @@
export const getYearColor = (year: number): string => {
// Palette élargie avec des différences plus marquées
const colorMap: Record<number, string> = {
// Années récentes - teintes froides et claires
2025: '#3a4a6c', // bleu-gris clair
2024: '#1e3a7a', // bleu vif
2023: '#1a4d5c', // bleu-vert émeraude
2022: '#3a4a6a', // bleu-gris moyen
// Années 2020-2021 - transition
2021: '#3a2e6a', // bleu-violet
2020: '#2a467a', // bleu-gris chaud
// Années 2010-2019 - teintes moyennes
2019: '#2a2a7a', // bleu nuit profond
2018: '#1e2a8a', // bleu roi
2017: '#1a5a6a', // bleu canard vif
2016: '#1a5a4a', // vert bleuté
2015: '#1a3a7a', // bleu marine
2014: '#4a1e7a', // violet profond
2013: '#1a5a4a', // vert émeraude
2012: '#1e3a9a', // bleu ciel profond
// Années 2000-2011 - teintes chaudes et foncées
2011: '#1e293b', // slate-800 de base
2010: '#2a467a', // bleu-gris chaud
2009: '#3a4a6a', // bleu-gris moyen
2008: '#1a3a8a', // bleu nuit clair
2007: '#5a2a4a', // bordeaux
2006: '#5a1e6a', // violet profond
2005: '#3a1a7a', // bleu-violet foncé
2004: '#2a1a5a', // bleu nuit profond
2003: '#3a3a5a', // bleu-gris foncé
2002: '#1a5a4a', // vert foncé
2001: '#5a3a2a', // marron chaud
2000: '#3a3a5a' // bleu-gris foncé
}
return colorMap[year] || '#1e293b' // slate-800 par défaut
}

82
appOLDD/app.vue Normal file
View File

@@ -0,0 +1,82 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>
<script setup>
import { useUiStore } from '~/store/ui'
import { usePlayerStore } from '~/store/player'
import { watch, computed } from 'vue'
const ui = useUiStore()
const player = usePlayerStore()
useHead({
bodyAttrs: {
class: 'bg-slate-100'
}
})
const router = useRouter()
const route = useRoute()
watch(
() => player.currentTrack?.id,
(id) => {
if (!id) {
if (route.name === 'track-id') router.replace({ path: '/' })
return
}
const currentParam = Number(
Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
)
if (route.name === 'track-id' && currentParam === id) return
router.replace({ name: 'track-id', params: { id } })
},
{ flush: 'post' }
)
// Keep URL in sync with selected box: /box/:id when a box is selected, back to / when none
const selectedBoxId = computed(() => ui.getSelectedBox?.id)
watch(
() => selectedBoxId.value,
(id) => {
if (import.meta.client) {
if (!id) {
// Back to root path without navigation to preserve UI state/animations
if (location.pathname.startsWith('/box/')) {
history.replaceState(null, '', '/')
}
return
}
const currentId = location.pathname.startsWith('/box/')
? location.pathname.split('/').pop()
: null
if (currentId === id) return
requestAnimationFrame(() => {
history.replaceState(null, '', `/box/${id}`)
})
}
},
{ flush: 'post' }
)
</script>
<style>
button,
input {
@apply px-4 py-2 m-4 rounded-md text-center font-bold;
}
input[type='email'] {
@apply bg-slate-900 text-esyellow;
}
img {
user-drag: none;
user-select: none;
}
</style>

View File

@@ -43,7 +43,7 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
import type { Box } from '~~/types/types'
import type { Box } from '~~/types'
import { useDataStore } from '~/store/data'
interface Props {

View File

@@ -0,0 +1,9 @@
<template>
<div>
wait for it
</div>
</template>
<script>
console.log('wait for it')
</script>

View File

@@ -15,7 +15,7 @@
</template>
<script lang="ts" setup>
import type { Box } from '~~/types/types'
import type { Box } from '~~/types'
import { useDataStore } from '~/store/data'
import { usePlayerStore } from '~/store/player'
import { useUiStore } from '~/store/ui'

519
appOLDD/components/Card.vue Normal file
View File

@@ -0,0 +1,519 @@
<template>
<article v-bind="attrs" :role="props.role" :draggable="isFaceUp" :class="[
'card cursor-pointer',
isFaceUp ? 'face-up' : 'face-down',
{ 'current-track': playerStore.currentTrack?.id === track.id },
{ 'is-dragging': isDragging }
]" :tabindex="props.tabindex" :aria-disabled="false" @click.stop="handleClick" @keydown.enter.stop="handleClick"
@keydown.space.prevent.stop="handleClick" @dragstart="handleDragStart" @dragend="handleDragEnd"
@drag="handleDragMove" @touchstart.passive="!isFaceUp" @touchmove.passive="!isFaceUp">
<div class="flip-inner" ref="cardElement">
<!-- Face-Up -->
<main
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 hover:shadow-xl hover:scale-110 transition-all rounded-2xl shadow-lg flex flex-col overflow-hidden">
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 right-7"
@click.stop="clickCardSymbol">
<div class="suit text-7xl absolute"
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
</div>
<div class="rank text-white font-bold absolute -mt-1">
{{ props.track.card?.rank }}
</div>
</div>
<div v-else class="flex items-center justify-center size-7 absolute top-6 left-6">
<div class="rank text-white font-bold absolute -mt-1">
{{ props.track.order }}
</div>
</div>
<!-- Cover -->
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer">
<playButton :objectToPlay="track" />
<img draggable="false" v-if="isFaceUp" :src="coverUrl" alt="Pochette de l'album" loading="lazy"
class="w-full h-full object-cover object-center" />
</figure>
<!-- Body -->
<div
class="card-body p-3 text-center bg-white rounded-b-xl opacity-0 -mt-16 hover:opacity-100 hover:-mt-0 transition-all duration-300">
<div v-if="isOrder" class="label">
{{ props.track.order }}
</div>
<h2 class="select-text text-sm text-neutral-500 first-letter:uppercase truncate">
{{ props.track.title || 'title' }}
</h2>
<p v-if="isPlaylistTrack && track.artist && typeof track.artist === 'object'"
class="select-text text-base text-neutral-800 font-bold capitalize truncate">
{{ track.artist.name || 'artist' }}
</p>
<p v-else-if="isPlaylistTrack" class="select-text text-base text-neutral-800 font-bold capitalize truncate">
{{ typeof track.artist === 'string' ? track.artist : 'artist' }}
</p>
</div>
</main>
<!-- Face-Down -->
<footer
class="face-down backdrop-blur-sm z-10 card w-56 h-80 p-3 bg-opacity-10 bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden select-none">
<figure class="h-full flex text-center rounded-xl justify-center items-center"
:style="{ backgroundColor: cardColor }">
<playButton :objectToPlay="track" />
<img draggable="false" src="/face-down.svg" />
</figure>
</footer>
</div>
</article>
<!-- Clone fantôme unifié pour drag souris ET tactile -->
<Teleport to="body">
<div v-if="isDragging && touchClone" ref="ghostElement"
class="ghost-card fixed pointer-events-none z-[9999] w-56 h-80" :style="{
left: touchClone.x + 'px',
top: touchClone.y + 'px',
transform: 'translate(-50%, -50%)'
}">
<div class="flip-inner">
<main
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 rounded-2xl shadow-2xl flex flex-col overflow-hidden bg-white bg-opacity-90">
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 left-7">
<div class="suit text-7xl absolute"
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
</div>
<div class="rank text-white font-bold absolute -mt-1">
{{ props.track.card?.rank }}
</div>
</div>
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl">
<img draggable="false" :src="coverUrl" alt="Pochette de l'album"
class="w-full h-full object-cover object-center" />
</figure>
</main>
</div>
</div>
</Teleport>
<!-- Modal de partage -->
<ModalSharer v-if="showModalSharer" ref="modalSharer" />
</template>
<script setup lang="ts">
import type { Track } from '~~/types'
import { usePlayerStore } from '~/store/player'
import { useDataStore } from '~/store/data'
import { useNuxtApp } from '#app'
import ModalSharer from '~/components/ui/ModalSharer.vue'
const props = withDefaults(defineProps<{
track: Track;
isFaceUp?: boolean;
role?: string;
tabindex?: string | number;
'onUpdate:isFaceUp'?: (value: boolean) => void;
}>(), {
isFaceUp: true,
role: 'button',
tabindex: '0'
})
// Use useAttrs to get all other attributes
const attrs = useAttrs()
const modalSharer = ref<InstanceType<typeof ModalSharer> | null>(null)
const showModalSharer = ref(false)
const emit = defineEmits<{
(e: 'update:isFaceUp', value: boolean): void;
(e: 'cardClick', track: Track): void;
(e: 'clickCardSymbol', track: Track): void;
(e: 'dragstart', event: DragEvent): void;
(e: 'dragend', event: DragEvent): void;
(e: 'drag', event: DragEvent): void;
(e: 'click', event: MouseEvent): void;
}>()
// Handle click events (mouse and keyboard)
const handleClick = (event: MouseEvent | KeyboardEvent) => {
if (!isDragging.value && !hasMovedDuringPress.value) {
emit('cardClick', props.track);
emit('click', event as MouseEvent);
}
hasMovedDuringPress.value = false;
}
const clickCardSymbol = (event: MouseEvent) => {
event.stopPropagation();
// Afficher la modale
showModalSharer.value = true;
// Donner le focus à la modale après le rendu
nextTick(() => {
if (modalSharer.value) {
modalSharer.value.open(props.track);
}
});
emit('clickCardSymbol', props.track);
}
// Handle drag start with proper event emission
const handleDragStart = (event: DragEvent) => {
if (!props.isFaceUp) {
event.preventDefault();
return;
}
const { $bodyClass } = useNuxtApp()
$bodyClass.add('card-dragging')
dragStart(event);
emit('dragstart', event);
}
// Handle drag end with proper event emission
const handleDragEnd = (event: DragEvent) => {
if (!props.isFaceUp) {
event.preventDefault();
return;
}
const { $bodyClass } = useNuxtApp()
$bodyClass.remove('card-dragging')
dragEnd(event);
emit('dragend', event);
}
// Handle drag move with proper event emission
const handleDragMove = (event: DragEvent) => {
if (!props.isFaceUp) {
event.preventDefault();
return;
}
dragMove(event);
emit('drag', event);
}
const playerStore = usePlayerStore()
const isManifesto = computed(() => props.track.boxId.startsWith('ES00'))
const isOrder = computed(() => props.track.order && !isManifesto)
const isPlaylistTrack = computed(() => props.track.type === 'playlist')
const isRedCard = computed(() => (props.track.card?.suit === '♥' || props.track.card?.suit === '♦'))
const dataStore = useDataStore()
const cardColor = computed(() => dataStore.getYearColor(props.track.year || 0))
const coverUrl = computed(() => props.track.coverId || '/card-dock.svg')
const isDragging = ref(false)
const cardElement = ref<HTMLElement | null>(null)
const ghostElement = ref<HTMLElement | null>(null)
// État unifié pour souris et tactile
const touchClone = ref<{ x: number, y: number } | null>(null)
const touchStartPos = ref<{ x: number, y: number } | null>(null)
const longPressTimer = ref<number | null>(null)
const LONG_PRESS_DURATION = 200 // ms
const hasMovedDuringPress = ref(false)
// Drag desktop - utilise maintenant ghostElement
const dragStart = (event: DragEvent) => {
if (event.dataTransfer && cardElement.value) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('application/json', JSON.stringify(props.track));
// Créer une image transparente pour masquer l'image par défaut du navigateur
const img = new Image();
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0);
// Activer le clone fantôme
isDragging.value = true
touchClone.value = {
x: event.clientX,
y: event.clientY
}
}
};
// Nouveau: suivre le mouvement de la souris pendant le drag
const dragMove = (event: DragEvent) => {
if (isDragging.value && touchClone.value && event.clientX !== 0 && event.clientY !== 0) {
touchClone.value = {
x: event.clientX,
y: event.clientY
}
}
}
const instance = getCurrentInstance();
const dragEnd = (event: DragEvent) => {
isDragging.value = false
touchClone.value = null
if (event.dataTransfer?.dropEffect === 'move' && instance?.vnode?.el?.parentNode) {
instance.vnode.el.parentNode.removeChild(instance.vnode.el);
const parent = instance.parent;
if (parent?.update) {
parent.update();
}
}
}
// Touch events
const touchStart = (event: TouchEvent) => {
const touch = event.touches[0];
if (!touch) return;
touchStartPos.value = { x: touch.clientX, y: touch.clientY };
hasMovedDuringPress.value = false;
// Démarrer un timer pour le long press
longPressTimer.value = window.setTimeout(() => {
startTouchDrag(touch);
}, LONG_PRESS_DURATION);
}
const startTouchDrag = (touch: Touch) => {
if (!touch) return;
isDragging.value = true;
touchClone.value = {
x: touch.clientX,
y: touch.clientY
};
// Vibration feedback si disponible
if (navigator.vibrate) {
navigator.vibrate(50);
}
}
const touchMove = (event: TouchEvent) => {
const touch = event.touches[0];
if (!touch || !longPressTimer.value) return;
// Annuler le long press si l'utilisateur bouge trop
const dx = touch.clientX - (touchStartPos.value?.x || 0);
const dy = touch.clientY - (touchStartPos.value?.y || 0);
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 10) { // Seuil de tolérance pour un tap
clearTimeout(longPressTimer.value)
longPressTimer.value = null
hasMovedDuringPress.value = true
}
if (isDragging.value && touchClone.value) {
event.preventDefault()
const touch = event.touches[0]
touchClone.value = {
x: touch.clientX,
y: touch.clientY
}
// Déterminer l'élément sous le doigt
checkDropTarget(touch.clientX, touch.clientY)
}
}
const touchEnd = (event: TouchEvent) => {
// Annuler le timer de long press
if (longPressTimer.value) {
clearTimeout(longPressTimer.value);
longPressTimer.value = null;
}
// Vérifier si c'était un tap simple (pas de déplacement)
if (!hasMovedDuringPress.value && touchStartPos.value) {
const touch = event.changedTouches[0];
if (touch) {
const dx = touch.clientX - touchStartPos.value.x;
const dy = touch.clientY - touchStartPos.value.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 10) { // Seuil de tolérance pour un tap
handleClick(new MouseEvent('click'));
}
}
}
// Réinitialiser l'état de glisser-déposer
if (isDragging.value) {
// Vérifier si on est au-dessus d'une cible de dépôt
const touch = event.changedTouches[0];
if (touch) {
checkDropTarget(touch.clientX, touch.clientY);
}
}
// Nettoyer
isDragging.value = false;
touchClone.value = null;
touchStartPos.value = null;
hasMovedDuringPress.value = false;
}
const checkDropTarget = (x: number, y: number): HTMLElement | null => {
const element = document.elementFromPoint(x, y);
if (element) {
const dropZone = element.closest('[data-drop-zone]');
if (dropZone) {
return dropZone as HTMLElement;
}
}
return null;
}
// Cleanup
onUnmounted(() => {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
}
})
</script>
<style lang="scss">
.label {
@apply rounded-full size-7 p-2 bg-esyellow leading-3 -mt-6;
font-weight: bold;
text-align: center;
}
.,
.,
.,
. {
@apply text-5xl size-14;
}
/* Flip effect */
.card {
perspective: 1000px;
@apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80;
touch-action: none;
.flip-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.6s;
transform-style: preserve-3d;
transform-origin: center;
}
.face-down,
.face-up {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
will-change: transform;
background-color: rgba(255, 255, 255, 0.5);
}
.face-up {
transform: rotateY(0deg);
transition: box-shadow 0.6s;
}
.face-down {
transform: rotateY(-180deg);
}
&.face-down .flip-inner {
transform: rotateY(180deg);
}
&.face-up .flip-inner {
transform: rotateY(0deg);
}
&.face-down:hover {
.play-button {
opacity: 1;
}
.flip-inner {
transform: rotateY(170deg);
}
}
&:focus,
&.current-track {
@apply z-50 scale-110;
outline: none;
.face-up {
@apply shadow-2xl;
transition:
box-shadow 0.6s,
transform 0.6s;
}
}
&:focus,
&.current-track {
.play-button {
@apply opacity-100;
}
}
.play-button {
opacity: 0;
}
.face-up:hover {
.play-button {
opacity: 1;
}
.flip-inner {
transform: rotateY(-170deg);
}
}
.play-button {
@apply absolute bottom-1/2 top-24 opacity-0 hover:opacity-100;
}
.pochette:active,
.face-down:active {
.play-button {
@apply scale-90;
}
}
&.is-dragging {
@apply opacity-50 scale-95 rotate-6;
cursor: grabbing !important;
.face-up {
@apply shadow-2xl;
}
.play-button,
.card-body {
display: none;
}
}
}
/* Ghost card styles - maintenant unifié pour souris et tactile */
.ghost-card {
transition: none;
.card {
@apply shadow-2xl scale-95 rotate-6;
.play-button,
.card-body {
display: none;
}
}
.flip-inner {
perspective: 1000px;
}
}
</style>

View File

@@ -27,7 +27,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { usePlatineStore } from '~/store/platine'
import type { Track } from '~~/types/types'
import type { Track } from '~~/types'
const props = defineProps<{ track?: Track }>()
const platineStore = usePlatineStore()
@@ -72,22 +72,6 @@ watch(() => props.track, (newTrack) => {
bottom: 0;
transform: translate(-50%, 50%);
}
.cover {
position: absolute;
top: 0;
left: 0;
border-radius: 100%;
object-fit: cover;
width: 100%;
height: 100%;
transition: opacity 3s ease;
.loading & {
opacity: 0;
transition: opacity 0.3s ease;
}
}
}
.disc {
@@ -177,6 +161,22 @@ watch(() => props.track, (newTrack) => {
border-radius: 50%;
}
.cover {
position: absolute;
top: 0;
left: 0;
border-radius: 100%;
object-fit: cover;
width: 100%;
height: 100%;
transition: opacity 3s ease;
.loading & {
opacity: 0;
transition: opacity 0.3s ease;
}
}
.spinner {
width: 40px;
height: 40px;

View File

@@ -0,0 +1,42 @@
<template>
<button tabindex="-1"
class="play-button pointer-events-none rounded-full size-24 flex items-center justify-center text-esyellow backdrop-blur-sm bg-black/25 transition-all duration-200 ease-in-out transform active:scale-90 scale-110 text-4xl font-bold"
:class="{ loading: isLoading }" :disabled="isLoading">
<template v-if="isLoading">
<img src="/loader.svg" alt="Chargement" class="size-16" />
</template>
<template v-else>
{{ isPlaying ? 'I I' : '' }}
</template>
</button>
</template>
<script setup lang="ts">
import { usePlatineStore } from '~/store/platine'
import type { Box, Track } from '~/../types/types'
const platineStore = usePlatineStore()
const props = defineProps<{ objectToPlay: Box | Track }>()
const isCurrentTrack = computed(() => {
if (!('activeSide' in props.objectToPlay)) {
return platineStore.currentTrack?.id === props.objectToPlay.id
}
return false
})
const isPlaying = computed(() => {
return platineStore.isPlaying && isCurrentTrack.value
})
const isLoading = computed(() => {
return platineStore.isLoadingTrack && isCurrentTrack.value
})
</script>
<style>
.loading,
.play-button-changed {
opacity: 1 !important;
}
</style>

View File

@@ -24,7 +24,7 @@ import { useDataStore } from '~/store/data'
import { useCardStore } from '~/store/card'
import { usePlayerStore } from '~/store/player'
import { useUiStore } from '~/store/ui'
import type { Box } from '~~/types/types'
import type { Box } from '~~/types'
const uiStore = useUiStore()

View File

@@ -24,7 +24,7 @@ import { useDataStore } from '~/store/data'
import { useCardStore } from '~/store/card'
import { usePlayerStore } from '~/store/player'
import { useUiStore } from '~/store/ui'
import type { Box, Track } from '~~/types/types'
import type { Box, Track } from '~~/types'
import SelectCardSuit from '~/components/ui/SelectCardSuit.vue'
import SelectCardRank from '~/components/ui/SelectCardRank.vue'
import SearchInput from '~/components/ui/SearchInput.vue'

View File

@@ -58,7 +58,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useUiStore } from '~/store/ui'
import type { Track } from '~~/types/types'
import type { Track } from '~~/types'
const uiStore = useUiStore()
const currentTrack = ref<Track | null>(null)

18
appOLDD/error.vue Normal file
View File

@@ -0,0 +1,18 @@
<template>
<div>
<h1
class="text-white pt-6 text-lg md:text-xl lg:text-2xl text-center font-bold tracking-widest text-shadow"
>
{{ error?.statusCode }}
</h1>
<NuxtLink to="/">Go back home</NuxtLink>
</div>
</template>
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps({
error: Object as () => NuxtError
})
</script>

View File

@@ -0,0 +1,55 @@
<template>
<slot />
<Bucket />
<Platine v-if="playerStore.currentTrack" />
</template>
<script setup lang="ts">
import type { Track } from '~~/types'
import { usePlayerStore } from '~/store/player'
const playerStore = usePlayerStore()
const onCardDropped = (card: Track) => {
console.log('Carte déposée dans le bucket:', card)
}
</script>
<style scoped>
.bucket,
.platine {
position: fixed;
bottom: -100%;
right: 0;
height: auto;
}
.bucket {
z-index: 70;
bottom: -260px;
width: 100%;
overflow-x: scroll;
transition: bottom .3s ease;
&:hover,
.card-dragging & {
bottom: 0;
}
.bucket-card-wrapper {
width: 100%;
}
}
.platine {
bottom: -100%;
transition: bottom 2s ease;
&.mounted {
z-index: 80;
bottom: 0;
width: 100%;
max-width: 450px;
}
}
</style>

View File

@@ -22,7 +22,7 @@ import { useRoute } from 'vue-router'
import { usePlayerStore } from '~/store/player'
import { useCardStore } from '~/store/card'
import { useDataStore } from '~/store/data'
import type { Track } from '~~/types/types'
import type { Track } from '~~/types'
const route = useRoute()
const playerStore = usePlayerStore()

27
appOLDD/pages/index.vue Normal file
View File

@@ -0,0 +1,27 @@
<template>
<boxes />
</template>
<script setup>
import { useUiStore } from '~/store/ui'
import { useDataStore } from '~/store/data'
// Configuration du layout
definePageMeta({
layout: 'default'
})
const uiStore = useUiStore()
onMounted(async () => {
const dataStore = useDataStore()
await dataStore.loadData()
uiStore.listBoxes()
})
</script>
<style>
.logo {
filter: drop-shadow(3px 3px 0 rgb(0 0 0 / 0.7));
}
</style>

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import type { Track } from '~~/types/types'
import type { Track } from '~~/types'
export const useCardStore = defineStore('card', {
state: () => ({

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import type { Track } from '~~/types/types'
import type { Track } from '~~/types'
import Disc from '~/platine-tools/disc'
import Sampler from '~/platine-tools/sampler'
import { useCardStore } from '~/store/card'
@@ -74,7 +74,7 @@ export const usePlatineStore = defineStore('platine', () => {
isLoadingTrack.value = true
try {
await sampler.value.loadTrack(track.url)
await sampler.value.loadTrack(track.filePath)
if (disc.value) {
disc.value.setDuration(sampler.value.duration)
updateTurns()

146
data/Artists.json Normal file
View File

@@ -0,0 +1,146 @@
[
{
"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"
}
]

44
data/Compilations.json Normal file
View File

@@ -0,0 +1,44 @@
[
{
"id": "ES00",
"type": "compilation",
"name": "manifeste",
"description": "Zero is for manifesto",
"duration": 5264,
"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"
}
}
},
{
"id": "ES01",
"type": "compilation",
"name": "...",
"description": "...",
"duration": 7260,
"sides": {
"A": {
"duration": 3487,
"color1": "#c7b3aa",
"color2": "#000100"
},
"B": {
"duration": 3773,
"color1": "#f7dd01",
"color2": "#010103"
}
}
}
]

486
data/Songs.json Normal file
View File

@@ -0,0 +1,486 @@
[
{
"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",
"year": 2024
},
{
"order": 2,
"boxId": "ES00",
"side": "A",
"title": "Bleach",
"artist": 1,
"start": 392,
"link": "https://the-kundalini-genie.bandcamp.com/track/bleach-2",
"coverId": "a1714786533",
"year": 2024
},
{
"order": 3,
"boxId": "ES00",
"side": "A",
"title": "Televised mind",
"artist": 2,
"start": 896,
"link": "https://fontainesdc.bandcamp.com/track/televised-mind",
"coverId": "a3772806156",
"year": 2024
},
{
"order": 4,
"boxId": "ES00",
"side": "A",
"title": "In it",
"artist": 3,
"start": 1139,
"link": "https://howlinbananarecords.bandcamp.com/track/in-it",
"coverId": "a1720372066",
"year": 2024
},
{
"order": 5,
"boxId": "ES00",
"side": "A",
"title": "Bad michel",
"artist": 4,
"start": 1245,
"link": "https://johnnymafia.bandcamp.com/track/bad-michel-3",
"coverId": "a0984622869",
"year": 2024
},
{
"order": 6,
"boxId": "ES00",
"side": "A",
"title": "Overall",
"artist": 5,
"start": 1394,
"link": "https://newcandys.bandcamp.com/track/overall",
"coverId": "a0559661270",
"year": 2024
},
{
"order": 7,
"boxId": "ES00",
"side": "A",
"title": "Blowup",
"artist": 6,
"start": 1674,
"link": "https://magicshoppe.bandcamp.com/track/blowup",
"coverId": "a1444895293",
"year": 2024
},
{
"order": 8,
"boxId": "ES00",
"side": "A",
"title": "Guitar jet",
"artist": 7,
"start": 1880,
"link": "https://radiomartiko.bandcamp.com/track/guitare-jet",
"coverId": "a1494681687",
"year": 2024
},
{
"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",
"year": 2024
},
{
"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",
"year": 2024
},
{
"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",
"year": 2024
},
{
"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",
"year": 2024
},
{
"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",
"year": 2024
},
{
"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",
"year": 2024
},
{
"order": 4,
"boxId": "ES00",
"side": "B",
"title": "Lights off",
"artist": 3,
"start": 781,
"link": "https://howlinbananarecords.bandcamp.com/track/lights-off",
"coverId": "a1720372066",
"year": 2024
},
{
"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",
"year": 2024
},
{
"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",
"year": 2024
},
{
"order": 7,
"boxId": "ES00",
"side": "B",
"title": "Redhead",
"artist": 6,
"start": 1303,
"link": "https://magicshoppe.bandcamp.com/track/redhead",
"coverId": "a0594426943",
"year": 2024
},
{
"order": 8,
"boxId": "ES00",
"side": "B",
"title": "Supersonic twist",
"artist": 7,
"start": 1584,
"link": "https://open.spotify.com/track/66voQIZAJ3zD3Eju2qtNjF",
"coverId": "a1494681687",
"year": 2024
},
{
"order": 9,
"boxId": "ES00",
"side": "B",
"title": "Flowers",
"artist": 8,
"start": 1749,
"link": "https://traams.bandcamp.com/track/flowers",
"coverId": "a3644668199",
"year": 2024
},
{
"order": 10,
"boxId": "ES00",
"side": "B",
"title": "The shade",
"artist": 9,
"start": 1924,
"link": "https://blue-orchid.bandcamp.com/track/the-shade",
"coverId": "a0804204790",
"year": 2024
},
{
"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",
"year": 2024
},
{
"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",
"year": 2025
},
{
"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",
"year": 2025
},
{
"order": 3,
"boxId": "ES01",
"side": "A",
"title": "Broadcaster",
"artist": 13,
"start": 1104.5,
"link": "https://squiduk.bandcamp.com/track/broadcaster",
"coverId": "a3391719769",
"year": 2025
},
{
"order": 4,
"boxId": "ES01",
"side": "A",
"title": "Mourn",
"artist": 14,
"start": 1441,
"link": "https://lysistrata.bandcamp.com/track/mourn-2",
"coverId": "a0872900041",
"year": 2025
},
{
"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",
"year": 2025
},
{
"order": 6,
"boxId": "ES01",
"side": "A",
"title": "Sunday Mourning",
"artist": 16,
"start": 2091.7,
"link": "https://nightbeats.bandcamp.com/track/sunday-mourning",
"coverId": "a0031987121",
"year": 2025
},
{
"order": 7,
"boxId": "ES01",
"side": "A",
"title": "3030 Instrumental",
"artist": 17,
"start": 2339.3,
"link": "https://delthefunkyhomosapien.bandcamp.com/track/3030",
"coverId": "a1948146136",
"year": 2025
},
{
"order": 8,
"boxId": "ES01",
"side": "A",
"title": "Immortality Break",
"artist": 18,
"start": 2530.5,
"link": "https://theaa.bandcamp.com/track/immortality-break",
"coverId": "a2749250329",
"year": 2025
},
{
"order": 9,
"boxId": "ES01",
"side": "A",
"title": "Lazy Bones",
"artist": 19,
"start": 2718,
"link": "https://woodenshjips.bandcamp.com/track/lazy-bones",
"coverId": "a1884221104",
"year": 2025
},
{
"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",
"year": 2025
},
{
"order": 11,
"boxId": "ES01",
"side": "A",
"title": "Me",
"artist": 21,
"start": 3265,
"link": "https://secretcolours.bandcamp.com/track/me",
"coverId": "a1497022499",
"year": 2025
},
{
"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",
"year": 2025
},
{
"order": 2,
"boxId": "ES01",
"side": "B",
"title": "Dreamscapes",
"artist": 12,
"start": 235,
"link": "https://littlecloudrecords.bandcamp.com/track/dreamscapes",
"coverId": "a3498981203",
"year": 2025
},
{
"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",
"year": 2025
},
{
"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",
"year": 2025
},
{
"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",
"year": 2025
},
{
"order": 6,
"boxId": "ES01",
"side": "B",
"title": "Celebration #1",
"artist": 16,
"start": 2235,
"link": "https://nightbeats.bandcamp.com/track/celebration-1",
"coverId": "a0031987121",
"year": 2025
},
{
"order": 7,
"boxId": "ES01",
"side": "B",
"title": "3030 Instrumental",
"artist": 17,
"start": 2458.3,
"link": "https://delthefunkyhomosapien.bandcamp.com/track/3030",
"coverId": "a1948146136",
"year": 2025
},
{
"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",
"year": 2025
},
{
"order": 9,
"boxId": "ES01",
"side": "B",
"title": "Rising",
"artist": 19,
"start": 3145,
"link": "https://woodenshjips.bandcamp.com/track/rising",
"coverId": "a1884221104",
"year": 2025
},
{
"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",
"year": 2025
},
{
"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",
"year": 2025
}
]

BIN
data/music.db Normal file

Binary file not shown.

View File

@@ -8,14 +8,18 @@ services:
restart: unless-stopped
working_dir: /app
ports:
- "${PORT}:${PORT_EXPOSED}"
- '${PORT}:${PORT_EXPOSED}'
volumes:
- $MEDIA_DIR:/app/mnt/media
- evilspins:/app/data
environment:
VIRTUAL_HOST: "${DOMAIN}"
LETSENCRYPT_HOST: "${DOMAIN}"
PUID: "${PUID}"
PGID: "${PGID}"
VIRTUAL_HOST: '${DOMAIN}'
LETSENCRYPT_HOST: '${DOMAIN}'
PUID: '${PUID}'
PGID: '${PGID}'
volumes:
evilspins:
networks:
default:

11
drizzle.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import 'dotenv/config'
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
out: './drizzle',
schema: './server/db/schema.ts',
dialect: 'sqlite',
dbCredentials: {
url: process.env.PATH_DB!
}
})

View File

@@ -4,9 +4,26 @@ import tsconfigPaths from 'vite-tsconfig-paths'
const isProd = process.env.NODE_ENV === 'production'
export default defineNuxtConfig({
runtimeConfig: {
pathFiles: process.env.PATH_FILES,
pathDb: process.env.PATH_DB
},
nitro: {
experimental: {
tasks: true
},
scheduledTasks: {
'*/5 * * * *': ['syncTracks']
}
},
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
modules: ['@nuxt/eslint', '@nuxtjs/tailwindcss', '@pinia/nuxt'],
typescript: {
tsConfig: {
include: ['types/**/*.ts']
}
},
vite: {
plugins: [tsconfigPaths()]
},

View File

@@ -1,6 +1,7 @@
{
"name": "nuxt-app",
"name": "evilspins",
"type": "module",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nuxt build",
@@ -12,18 +13,18 @@
"lint:fix": "eslint . --fix",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"migrate": "tsx server/database/migrate.ts",
"db:reset": "rm -f server/database/evilspins.db && npm run migrate"
"db:push": "drizzle-kit push",
"db:reset": "rm -rf drizzle data/music.db && drizzle-kit push",
"db:sync": "curl -X POST -H \"Content-Type: application/json\" -d '{}' http://localhost:7901/api/test/test-db-sync"
},
"dependencies": {
"@libsql/client": "^0.17.0",
"@nuxt/eslint": "1.9.0",
"@nuxtjs/tailwindcss": "6.14.0",
"@pinia/nuxt": "0.11.2",
"@types/chokidar": "^2.1.7",
"atropos": "^2.0.2",
"better-sqlite3": "^12.5.0",
"chokidar": "^5.0.0",
"nuxt": "^4.2.0",
"drizzle-orm": "^0.45.1",
"nuxt": "^4.3.0",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-router": "^4.5.1",
@@ -32,15 +33,16 @@
"engines": {
"pnpm": ">=10 <11"
},
"packageManager": "pnpm@10.27.0",
"packageManager": "pnpm@10.28.0",
"devDependencies": {
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.1",
"@nuxt/eslint-config": "^1.10.0",
"@nuxtjs/eslint-config-typescript": "^12.1.0",
"@types/better-sqlite3": "^7.6.13",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.31.8",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
@@ -50,6 +52,7 @@
"patch-package": "^8.0.1",
"sass-embedded": "^1.93.2",
"tsx": "^4.21.0",
"typescript": "^5.7.3",
"vite-tsconfig-paths": "^5.1.4"
}
}

3691
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

7
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,7 @@
ignoredBuiltDependencies:
- esbuild
onlyBuiltDependencies:
- '@parcel/watcher'
- better-sqlite3
- unrs-resolver

1
public/cassette.svg Normal file
View File

@@ -0,0 +1 @@
<svg data-v-161a534a="" data-v-ef1138a9="" width="20" height="20" viewBox="0 0 20 20" fill="none" role="img" class="icon cassette-tape-outline-icon" aria-hidden="true" style="--v7dd280ad: var(--gray700);"><g data-v-161a534a=""><path data-v-161a534a="" fill-rule="evenodd" clip-rule="evenodd" d="M17.7 4.3H2.3L2.3 15.7H17.7V4.3ZM2.3 3.5C1.85817 3.5 1.5 3.85817 1.5 4.3V15.7C1.5 16.1418 1.85817 16.5 2.3 16.5H17.7C18.1418 16.5 18.5 16.1418 18.5 15.7V4.3C18.5 3.85817 18.1418 3.5 17.7 3.5H2.3Z" stroke="none"></path><path data-v-161a534a="" fill-rule="evenodd" clip-rule="evenodd" d="M8.5 9C8.5 10.2703 7.47025 11.3 6.2 11.3C4.92974 11.3 3.9 10.2703 3.9 9C3.9 7.72974 4.92974 6.7 6.2 6.7C7.47025 6.7 8.5 7.72974 8.5 9ZM6.2 10.5C7.02842 10.5 7.7 9.82843 7.7 9C7.7 8.17157 7.02842 7.5 6.2 7.5C5.37157 7.5 4.7 8.17157 4.7 9C4.7 9.82843 5.37157 10.5 6.2 10.5Z" stroke="none"></path><path data-v-161a534a="" fill-rule="evenodd" clip-rule="evenodd" d="M16.2 9C16.2 10.2703 15.1702 11.3 13.9 11.3C12.6297 11.3 11.6 10.2703 11.6 9C11.6 7.72974 12.6297 6.7 13.9 6.7C15.1702 6.7 16.2 7.72974 16.2 9ZM13.9 10.5C14.7284 10.5 15.4 9.82843 15.4 9C15.4 8.17157 14.7284 7.5 13.9 7.5C13.0716 7.5 12.4 8.17157 12.4 9C12.4 9.82843 13.0716 10.5 13.9 10.5Z" stroke="none"></path><path data-v-161a534a="" fill-rule="evenodd" clip-rule="evenodd" d="M6.36642 14C6.22966 14 6.10238 14.0698 6.02896 14.1852L4.73746 16.2147L4.06253 15.7852L5.35403 13.7557C5.5743 13.4096 5.95614 13.2 6.36642 13.2H13.6336C14.0438 13.2 14.4257 13.4096 14.646 13.7557L15.9375 15.7852L15.2625 16.2147L13.971 14.1852C13.8976 14.0698 13.7703 14 13.6336 14H6.36642Z" stroke="none"></path></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

17
public/disc.svg Normal file
View File

@@ -0,0 +1,17 @@
<svg data-v-daf08565="" data-v-ef1138a9="" width="20" height="20" viewBox="0 0 20 20" fill="none" role="img"
class="icon vinyl-record-outline-icon" aria-hidden="true" style="--v0954d04a: var(--gray700);">
<g data-v-daf08565="">
<path data-v-daf08565=""
d="M10 11C10.5523 11 11 10.5523 11 10C11 9.44772 10.5523 9 10 9C9.44772 9 9 9.44772 9 10C9 10.5523 9.44772 11 10 11Z"
stroke="none"></path>
<path data-v-daf08565="" fill-rule="evenodd" clip-rule="evenodd"
d="M13 10C13 11.6569 11.6569 13 10 13C8.34315 13 7 11.6569 7 10C7 8.34315 8.34315 7 10 7C11.6569 7 13 8.34315 13 10ZM10 12.1892C11.2091 12.1892 12.1892 11.2091 12.1892 10C12.1892 8.79094 11.2091 7.81081 10 7.81081C8.79094 7.81081 7.81081 8.79094 7.81081 10C7.81081 11.2091 8.79094 12.1892 10 12.1892Z"
stroke="none"></path>
<path data-v-daf08565=""
d="M12.3606 13.4425L12.0793 12.9862L9.80855 14.512L10.9231 16.2177L13.1938 14.6919L13.0425 14.3444L14.7376 13.1105C15.1903 12.7894 17.0431 11.2251 17.2951 9.62497L17.5002 8.39218L16.6563 6.35491L16.5832 7.60639C16.5764 7.75055 16.5615 7.92059 16.5332 8.11074C16.3877 9.08943 15.8891 10.6011 14.3388 11.8603L13.8788 12.234C13.5086 12.5349 13.2133 12.7749 12.9854 12.9589C12.686 13.2007 12.5025 13.3462 12.4167 13.4079C12.4012 13.419 12.3888 13.4274 12.3796 13.4331C12.37 13.4391 12.3636 13.4422 12.3606 13.4425Z"
stroke="none"></path>
<path data-v-daf08565="" fill-rule="evenodd" clip-rule="evenodd"
d="M10 17.2C13.9765 17.2 17.2 13.9765 17.2 10C17.2 6.02355 13.9765 2.8 10 2.8C6.02355 2.8 2.8 6.02355 2.8 10C2.8 13.9765 6.02355 17.2 10 17.2ZM10 18C14.4183 18 18 14.4183 18 10C18 5.58172 14.4183 2 10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18Z"
stroke="none"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

65
scripts/update-esid.ts Normal file
View File

@@ -0,0 +1,65 @@
import { db } from '../server/services/db'
import { tracks, compilations, playlists, sql } from '../server/schema'
import { eq, and } from 'drizzle-orm'
async function updateEsid() {
console.log('🚀 Démarrage de la mise à jour des esid')
try {
// 1. Mettre à jour les esid pour les compilations
console.log('📋 Mise à jour des esid pour les compilations...')
await db.run(sql`
UPDATE tracks
SET esid = (
SELECT c.box_id || c.side || t."order"
FROM compilations c
WHERE c.id = tracks.source_id AND tracks.source_type = 'compilation'
)
WHERE source_type = 'compilation';
`)
// 2. Mettre à jour les esid pour les playlists
console.log('📋 Mise à jour des esid pour les playlists...')
// Récupérer toutes les playlists
const allPlaylists = await db.select().from(playlists).all()
for (const playlist of allPlaylists) {
// Récupérer les tracks de la playlist triés par ordre
const playlistTracks = await db
.select()
.from(tracks)
.where(and(eq(tracks.sourceType, 'playlist'), eq(tracks.sourceId, playlist.id)))
.orderBy(tracks.order)
// Mettre à jour chaque track avec le bon esid
for (let i = 0; i < playlistTracks.length; i++) {
const track = playlistTracks[i]
const esidSuffix =
i < 26
? String.fromCharCode(65 + i) // A-Z pour les 26 premières pistes
: (i + 1).toString() // Numéros pour les suivantes
await db
.update(tracks)
.set({ esid: `${playlist.name}${esidSuffix}` })
.where(eq(tracks.id, track.id))
}
console.log(`✅ Playlist ${playlist.name} mise à jour (${playlistTracks.length} pistes)`)
}
console.log('\n✅ Tous les esid ont été mis à jour avec succès !')
} catch (error) {
console.error('❌ Erreur lors de la mise à jour des esid:', error)
process.exit(1)
}
}
// Exécuter le script
updateEsid()
.then(() => process.exit(0))
.catch((error) => {
console.error('Erreur inattendue:', error)
process.exit(1)
})

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}/${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

@@ -1,11 +1,11 @@
import type { CardSuit, CardRank } from '~/types/cards'
import type { Suit, Rank } from '../../types/types'
export function getCardFromDate(date: Date): { suit: CardSuit; rank: CardRank } {
export function getCardFromDate(date: Date): { suit: Suit; rank: Rank } {
const month = date.getMonth() + 1
const day = date.getDate()
const hour = date.getHours()
const suit: CardSuit =
const suit: Suit =
month >= 12 || month <= 2
? '♠'
: month >= 3 && month <= 5
@@ -14,7 +14,7 @@ export function getCardFromDate(date: Date): { suit: CardSuit; rank: CardRank }
? '♦'
: '♣'
const ranks: CardRank[] = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
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(/-+$/, '')
}

Some files were not shown because too many files have changed in this diff Show More