diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..aa1934b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +# Dépendances et build locaux : refaits dans l'image (binaires du poste de dev inutilisables sous Linux) +node_modules +dist +npm-debug.log* +*.log + +# Fichiers d'environnement locaux (secrets éventuels) ; .env / .env.production restent disponibles pour le build Vite +.env.local +.env.*.local + +# Dépôt git et fichiers sans intérêt pour l'image +.git +.gitignore +.vscode +.idea +Dockerfile +.dockerignore +docker-compose.yml +README.md diff --git a/Dockerfile b/Dockerfile index ec3aa29..0c6fbed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ -FROM node:lts +# 1. Construction du site (Vite) +FROM node:lts AS build WORKDIR /app @@ -10,8 +11,18 @@ COPY . . RUN npm run build +# 2. Image finale : uniquement le dist/ construit et le serveur statique +FROM node:lts-alpine + +WORKDIR /app + RUN npm install -g http-server +COPY --from=build /app/dist ./dist + EXPOSE 4001 -CMD ["http-server", "dist", "-p", "4001", "-P", "http://localhost:4001?/"] \ No newline at end of file +# -c-1 : pas de cache navigateur longue durée, sinon après un déploiement index.html (mis en cache 1 h) +# peut référencer des assets hachés qui n'existent plus +# -P : toute route inconnue renvoie index.html (routeur Vue en mode history) +CMD ["http-server", "dist", "-p", "4001", "-c-1", "-P", "http://localhost:4001?/"] diff --git a/docker-compose.yml b/docker-compose.yml index f0b819f..fa384c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,13 +6,12 @@ services: container_name: subsonics-frontend ports: - "4001:4001" - volumes: - - subsonics-frontend:/app/dist + # Pas de volume sur /app/dist : un volume nommé n'est rempli qu'à sa création, + # il masquait donc chaque nouveau build (le site servait toujours la première version déployée). + # Ancien volume à supprimer une fois : docker volume rm _subsonics-frontend restart: unless-stopped logging: driver: "json-file" options: max-size: "10m" max-file: "3" -volumes: - subsonics-frontend: \ No newline at end of file diff --git a/package.json b/package.json index ec3d744..24076cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chopin-frontend", - "version": "1.5.0", + "version": "2.0.0", "private": true, "scripts": { "dev": "vite --host --port 8080", diff --git a/src/App.vue b/src/App.vue index d16b35d..4ce31f3 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,8 +1,10 @@ \ No newline at end of file diff --git a/src/components/Admin/AdminCard.vue b/src/components/Admin/AdminCard.vue new file mode 100644 index 0000000..f78976e --- /dev/null +++ b/src/components/Admin/AdminCard.vue @@ -0,0 +1,73 @@ + + + diff --git a/src/components/Admin/AdminConfig.vue b/src/components/Admin/AdminConfig.vue new file mode 100644 index 0000000..a1da7fd --- /dev/null +++ b/src/components/Admin/AdminConfig.vue @@ -0,0 +1,587 @@ + + + diff --git a/src/components/Admin/AdminConfirm.vue b/src/components/Admin/AdminConfirm.vue new file mode 100644 index 0000000..499a042 --- /dev/null +++ b/src/components/Admin/AdminConfirm.vue @@ -0,0 +1,168 @@ + + + diff --git a/src/components/Admin/AdminDashboard.vue b/src/components/Admin/AdminDashboard.vue new file mode 100644 index 0000000..310f408 --- /dev/null +++ b/src/components/Admin/AdminDashboard.vue @@ -0,0 +1,289 @@ + + + diff --git a/src/components/Admin/AdminKv.vue b/src/components/Admin/AdminKv.vue new file mode 100644 index 0000000..62ad394 --- /dev/null +++ b/src/components/Admin/AdminKv.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/components/Admin/AdminLogs.vue b/src/components/Admin/AdminLogs.vue new file mode 100644 index 0000000..84688fb --- /dev/null +++ b/src/components/Admin/AdminLogs.vue @@ -0,0 +1,698 @@ + + + diff --git a/src/components/Admin/AdminMaintenance.vue b/src/components/Admin/AdminMaintenance.vue new file mode 100644 index 0000000..d244e4b --- /dev/null +++ b/src/components/Admin/AdminMaintenance.vue @@ -0,0 +1,232 @@ + + + diff --git a/src/components/Admin/AdminMetrics.vue b/src/components/Admin/AdminMetrics.vue new file mode 100644 index 0000000..c7529c6 --- /dev/null +++ b/src/components/Admin/AdminMetrics.vue @@ -0,0 +1,247 @@ + + + diff --git a/src/components/Admin/AdminOverview.vue b/src/components/Admin/AdminOverview.vue new file mode 100644 index 0000000..bfd4ceb --- /dev/null +++ b/src/components/Admin/AdminOverview.vue @@ -0,0 +1,282 @@ + + + diff --git a/src/components/Admin/AdminPills.vue b/src/components/Admin/AdminPills.vue new file mode 100644 index 0000000..a3a0967 --- /dev/null +++ b/src/components/Admin/AdminPills.vue @@ -0,0 +1,72 @@ + + + diff --git a/src/components/Admin/AdminPlayerCard.vue b/src/components/Admin/AdminPlayerCard.vue new file mode 100644 index 0000000..50a2fdb --- /dev/null +++ b/src/components/Admin/AdminPlayerCard.vue @@ -0,0 +1,410 @@ + + + diff --git a/src/components/Admin/AdminPlayers.vue b/src/components/Admin/AdminPlayers.vue new file mode 100644 index 0000000..1db3e06 --- /dev/null +++ b/src/components/Admin/AdminPlayers.vue @@ -0,0 +1,90 @@ + + + diff --git a/src/components/Admin/AdminPlaylists.vue b/src/components/Admin/AdminPlaylists.vue new file mode 100644 index 0000000..6d11fff --- /dev/null +++ b/src/components/Admin/AdminPlaylists.vue @@ -0,0 +1,327 @@ + + + diff --git a/src/components/Admin/AdminSection.vue b/src/components/Admin/AdminSection.vue new file mode 100644 index 0000000..d2b4f9a --- /dev/null +++ b/src/components/Admin/AdminSection.vue @@ -0,0 +1,77 @@ + + + diff --git a/src/components/Admin/AdminServerDetail.vue b/src/components/Admin/AdminServerDetail.vue new file mode 100644 index 0000000..cd6d412 --- /dev/null +++ b/src/components/Admin/AdminServerDetail.vue @@ -0,0 +1,401 @@ + + + diff --git a/src/components/Admin/AdminServers.vue b/src/components/Admin/AdminServers.vue new file mode 100644 index 0000000..22af00f --- /dev/null +++ b/src/components/Admin/AdminServers.vue @@ -0,0 +1,158 @@ + + + diff --git a/src/components/Admin/AdminStat.vue b/src/components/Admin/AdminStat.vue new file mode 100644 index 0000000..5578b61 --- /dev/null +++ b/src/components/Admin/AdminStat.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/components/Admin/AdminState.vue b/src/components/Admin/AdminState.vue new file mode 100644 index 0000000..d6b4718 --- /dev/null +++ b/src/components/Admin/AdminState.vue @@ -0,0 +1,68 @@ + + + diff --git a/src/components/Admin/AdminToasts.vue b/src/components/Admin/AdminToasts.vue new file mode 100644 index 0000000..746a0f1 --- /dev/null +++ b/src/components/Admin/AdminToasts.vue @@ -0,0 +1,97 @@ + + + diff --git a/src/components/Admin/AdminUserDetail.vue b/src/components/Admin/AdminUserDetail.vue new file mode 100644 index 0000000..6f3e5e1 --- /dev/null +++ b/src/components/Admin/AdminUserDetail.vue @@ -0,0 +1,468 @@ + + + diff --git a/src/components/Admin/AdminUserLine.vue b/src/components/Admin/AdminUserLine.vue new file mode 100644 index 0000000..71e9fa0 --- /dev/null +++ b/src/components/Admin/AdminUserLine.vue @@ -0,0 +1,107 @@ + + + diff --git a/src/components/Admin/AdminUsers.vue b/src/components/Admin/AdminUsers.vue new file mode 100644 index 0000000..7e8b046 --- /dev/null +++ b/src/components/Admin/AdminUsers.vue @@ -0,0 +1,241 @@ + + + diff --git a/src/components/Admin/AdminWarp.vue b/src/components/Admin/AdminWarp.vue new file mode 100644 index 0000000..210cefd --- /dev/null +++ b/src/components/Admin/AdminWarp.vue @@ -0,0 +1,318 @@ + + + diff --git a/src/components/Admin/AdminYoutube.vue b/src/components/Admin/AdminYoutube.vue new file mode 100644 index 0000000..a4c4546 --- /dev/null +++ b/src/components/Admin/AdminYoutube.vue @@ -0,0 +1,1428 @@ + + + diff --git a/src/components/Admin/adminFormat.js b/src/components/Admin/adminFormat.js new file mode 100644 index 0000000..b7f4900 --- /dev/null +++ b/src/components/Admin/adminFormat.js @@ -0,0 +1,143 @@ +/** + * [Subsonics Chopin - Frontend] + * File: adminFormat.js + * Description: Fonctions de formatage de l'interface d'administration (toutes protégées contre NaN) + */ + +export function safeNum(value, fallback = 0) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +// 1536 -> "1.5 Ko" +export function formatBytes(bytes) { + let value = safeNum(bytes); + const units = ["o", "Ko", "Mo", "Go", "To"]; + let i = 0; + while (value >= 1024 && i < units.length - 1) { + value /= 1024; + i++; + } + return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`; +} + +// Durée longue en secondes -> "2j 03h 04m" / "3h 05m 12s" / "45s" +export function formatUptime(seconds) { + let s = Math.max(0, Math.floor(safeNum(seconds))); + const d = Math.floor(s / 86400); s -= d * 86400; + const h = Math.floor(s / 3600); s -= h * 3600; + const m = Math.floor(s / 60); s -= m * 60; + const pad = (n) => String(n).padStart(2, "0"); + if (d > 0) return `${d}j ${pad(h)}h ${pad(m)}m`; + if (h > 0) return `${h}h ${pad(m)}m ${pad(s)}s`; + if (m > 0) return `${m}m ${pad(s)}s`; + return `${s}s`; +} + +// Position en millisecondes -> "3:05" / "1:02:05" +export function formatClock(ms) { + const total = Math.max(0, Math.floor(safeNum(ms) / 1000)); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + const pad = (n) => String(n).padStart(2, "0"); + return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; +} + +function toDate(value) { + if (value === null || value === undefined || value === "") return null; + const date = new Date(typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value); + return isNaN(date.getTime()) ? null : date; +} + +// Date ISO / timestamp -> "06/10/2026 21:31:11" +export function formatDate(value) { + const date = toDate(value); + if (!date) return "Inconnue"; + return date.toLocaleString("fr-FR"); +} + +// Date ISO / timestamp -> "il y a 5 min" / "dans 3 min" +export function formatRelative(value, now = Date.now()) { + const date = toDate(value); + if (!date) return "Jamais"; + const diff = Math.round((date.getTime() - now) / 1000); + const abs = Math.abs(diff); + let text; + if (abs < 5) return "à l'instant"; + if (abs < 60) text = `${abs} s`; + else if (abs < 3600) text = `${Math.floor(abs / 60)} min`; + else if (abs < 86400) text = `${Math.floor(abs / 3600)} h`; + else text = `${Math.floor(abs / 86400)} j`; + return diff < 0 ? `il y a ${text}` : `dans ${text}`; +} + +// Statut d'un player (état v2) -> libellé + couleur de Tag +export function playerStatus(status) { + switch (status) { + case 'playing': return { label: "En lecture", color: "var(--text-success)", icon: "fa-solid fa-play" }; + case 'paused': return { label: "En pause", color: "var(--text-warning)", icon: "fa-solid fa-pause" }; + case 'loading': return { label: "Chargement", color: "var(--admin-color)", icon: "fa-solid fa-spinner" }; + case 'stalled': return { label: "Bloqué", color: "var(--text-error)", icon: "fa-solid fa-triangle-exclamation" }; + case 'idle': return { label: "Inactif", color: "var(--text-secondary)", icon: "fa-solid fa-circle" }; + case 'offline': return { label: "Hors ligne", color: "var(--text-error)", icon: "fa-solid fa-circle" }; + default: return { label: status || "Inconnu", color: "var(--text-secondary)", icon: "fa-solid fa-circle" }; + } +} + +// Avatar Discord d'un utilisateur (repli sur l'avatar par défaut si absent) +export function userAvatar(user) { + if (user?.avatar) return `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}`; + let index = 0; + try { index = Number((BigInt(user?.id || 0) >> BigInt(22)) % BigInt(6)); } catch (e) { index = 0; } + return `https://cdn.discordapp.com/embed/avatars/${index}.png`; +} + +// Classes d'erreur yt-dlp (backend/src/youtube/errors.js) -> libellé français +export const YOUTUBE_CLASSES = { + BOT_CHECK: "Vérification anti-robot", + IP_BLOCKED: "IP bloquée par YouTube", + CAPTCHA: "Captcha demandé", + RATE_LIMITED: "Limite de requêtes YouTube", + HTTP_403: "Erreur 403 (accès refusé)", + HTTP_429: "Erreur 429 (trop de requêtes)", + AGE_RESTRICTED: "Restriction d'âge", + LOGIN_REQUIRED: "Connexion requise", + MEMBERS_ONLY: "Réservé aux membres", + PRIVATE: "Vidéo privée", + UNAVAILABLE: "Vidéo indisponible", + GEO_BLOCKED: "Bloquée dans ce pays", + POT_MISSING: "PO token manquant", + SABR: "Flux SABR uniquement", + JS_CHALLENGE: "Défi JavaScript non résolu", + FORMAT_UNAVAILABLE: "Format indisponible", + PAGE_RELOAD: "Page à recharger", + COOKIES_ROTATED: "Cookies invalidés (rotation)", + EMPTY_DOWNLOAD: "Téléchargement vide", + BINARY_MISSING: "yt-dlp introuvable", + TIMEOUT: "Délai dépassé", + UNKNOWN: "Erreur inconnue" +}; + +export function youtubeClass(cls) { + if (!cls) return "Aucune"; + return YOUTUBE_CLASSES[cls] || cls; +} + +// Origine des cookies YouTube ("paste", "file", "legacy", "browser:firefox") +export function cookieSource(source) { + if (!source) return "Inconnue"; + if (source === "paste") return "Texte collé"; + if (source === "file") return "Fichier importé"; + if (source === "legacy") return "Ancien fichier (reprise automatique)"; + if (source.startsWith("browser:")) return "Navigateur (" + source.slice(8) + ")"; + return source; +} + +// État du compte YouTube (cookies.getStatus() ou overview.ytdlp.cookies) -> libellé + couleur de Tag +export function youtubeAccount(cookies) { + if (!cookies?.present) return { key: 'none', label: "Aucun compte", color: "var(--text-secondary)", icon: "fa-solid fa-user-slash" }; + if (cookies.invalidatedAt) return { key: 'invalid', label: "Invalidé", color: "var(--text-error)", icon: "fa-solid fa-circle-xmark" }; + if (!cookies.loggedIn) return { key: 'anonymous', label: "Non connecté", color: "var(--text-warning)", icon: "fa-solid fa-triangle-exclamation" }; + return { key: 'valid', label: "Connecté", color: "var(--text-success)", icon: "fa-solid fa-circle-check" }; +} diff --git a/src/components/Layout/Account.vue b/src/components/Layout/Account.vue index 05ee068..3b12317 100644 --- a/src/components/Layout/Account.vue +++ b/src/components/Layout/Account.vue @@ -1,14 +1,13 @@ @@ -381,6 +440,14 @@ watch(() => video.value, () => { gap: 10px; } +/* Salon + pastilles d'état (chargement, connexion instable, erreur) */ +.player-tags { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; +} + @media screen and (max-width: 768px), screen and (max-height: 607px) { diff --git a/src/components/Layout/Playlist.vue b/src/components/Layout/Playlist.vue index 5dd422e..00d5d29 100644 --- a/src/components/Layout/Playlist.vue +++ b/src/components/Layout/Playlist.vue @@ -17,14 +17,14 @@ @@ -34,12 +34,14 @@ import { IORequest } from '@/utils/IORequest'; import Button from '../UI/Button.vue'; import PlaylistItem from '../UI/PlaylistItem.vue'; import { onMounted, ref, onUnmounted } from 'vue'; +import { useRouter } from 'vue-router'; import Modal from '../UI/Modal.vue'; import Error from '../UI/Error.vue'; import Events from '@/utils/Events'; import { useGlobalStore } from '@/stores/globalStore'; import { useUserStore } from '@/stores/userStore'; -import PlaylistView from '@/components/Widget/View/Playlist/PlaylistView.vue'; +import { socket } from '@/socket'; +import { refreshPlaylist } from '@/utils/Logic'; const modal = ref(null); const playlists = ref([]); @@ -51,8 +53,8 @@ const error = ref(null); const globalStore = useGlobalStore(); const userStore = useUserStore(); +const router = useRouter(); -//TODO: Faire la synchro google //TODO: Faire la possibilité d'envoyer une playlist @@ -69,12 +71,32 @@ Events.on("playlist:init", (data) => { playlists.value = data; }) +// Playlists modifiées hors de cette page (synchronisation YouTube depuis les paramètres, autre appareil, playlist reçue). +// routes.js retire tous les écouteurs socket à chaque navigation : réenregistrement après chacune. +function onPlaylistsUpdate() { + refreshPlaylist(); +} + +function listenPlaylistsUpdate() { + socket.off("/PLAYLISTS/UPDATE", onPlaylistsUpdate); + socket.on("/PLAYLISTS/UPDATE", onPlaylistsUpdate); +} + +let removeHook = null; + onMounted(() => { Events.emit("logic:init"); + listenPlaylistsUpdate(); + removeHook = router.afterEach(() => listenPlaylistsUpdate()); +}); + +onUnmounted(() => { + removeHook?.(); + socket.off("/PLAYLISTS/UPDATE", onPlaylistsUpdate); }); function addPlaylist() { - if ((!isYoutube && newPlaylistTitle.value?.trim() === '') || (isYoutube && urlLink.value?.trim() === '')) { + if ((!isYoutube.value && !newPlaylistTitle.value?.trim()) || (isYoutube.value && !urlLink.value?.trim())) { return; } if(isYoutube.value) newPlaylistTitle.value = urlLink.value; @@ -137,7 +159,7 @@ label { } @media screen and (max-width: 768px), - screen and (max-height: 607) { + screen and (max-height: 607px) { .playlist-items { flex-direction: column; } diff --git a/src/components/Layout/Queue.vue b/src/components/Layout/Queue.vue index e6f10e7..a13d7a5 100644 --- a/src/components/Layout/Queue.vue +++ b/src/components/Layout/Queue.vue @@ -6,7 +6,7 @@
- +
@@ -16,39 +16,25 @@ import { useRouter } from 'vue-router'; import { useGlobalStore } from '@/stores/globalStore'; import { useUserStore } from '@/stores/userStore'; - import { IORequest } from '@/utils/IORequest'; import Glider from '../UI/Glider.vue'; - import VideoList from '../UI/VideoList.vue'; - import { onMounted, ref, watch } from 'vue'; - import Events from '@/utils/Events'; - import IconAction from '../UI/IconAction.vue'; - import { useId } from "vue" - import VideoQueue from '../UI/VideoQueue.vue'; + import { computed, ref, watch } from 'vue'; import NextList from '../Widget/Queue/NextList.vue'; import PreviousList from '../Widget/Queue/PreviousList.vue'; + import { usePlayerStore } from '@/stores/playerStore'; const router = useRouter(); const globalStore = useGlobalStore(); const userStore = useUserStore(); - const online = ref(false); - const nextList = ref(null) - const previousList = ref(null) + // Files lues directement dans le store du player (plus d'écoute d'événement à retirer) + const playerStore = usePlayerStore(); + const online = computed(() => playerStore.online) + const nextList = computed(() => playerStore.next) + const nextCount = computed(() => playerStore.nextCount) + const previousList = computed(() => playerStore.previous) const direction = ref('forward') - const id = useId() const selectedTab = ref(0) - Events.on("player:update", (player) => { - if(player) nextList.value = player.next; - if(player) previousList.value = player.previous; - if(player) online.value = player.playerState - }) - - onMounted(() => { - Events.emit("player:needUpdate"); - - }) - watch(selectedTab, (newTab, oldTab) => { if (newTab > oldTab) { direction.value = 'forward'; // glide vers la gauche diff --git a/src/components/UI/Announcement.vue b/src/components/UI/Announcement.vue new file mode 100644 index 0000000..ab53a4f --- /dev/null +++ b/src/components/UI/Announcement.vue @@ -0,0 +1,191 @@ + + + diff --git a/src/components/UI/DurationBar.vue b/src/components/UI/DurationBar.vue index a21bb2b..58dcceb 100644 --- a/src/components/UI/DurationBar.vue +++ b/src/components/UI/DurationBar.vue @@ -1,18 +1,19 @@ @@ -180,6 +202,11 @@ defineExpose({ background: var(--tertiary); border-radius: 3px; cursor: pointer; + touch-action: none; +} + +.progress-bar-locked { + cursor: default; } .progress-bar-mobile { diff --git a/src/components/UI/Metric.vue b/src/components/UI/Metric.vue index 8551fd1..f0c4a83 100644 --- a/src/components/UI/Metric.vue +++ b/src/components/UI/Metric.vue @@ -1,19 +1,19 @@ @@ -51,4 +92,4 @@ Events.on("GUILD_JOINED", () => { max-height: 30vh; } - \ No newline at end of file + diff --git a/src/components/Widget/Queue/NextList.vue b/src/components/Widget/Queue/NextList.vue index 56a3dd4..5c502a3 100644 --- a/src/components/Widget/Queue/NextList.vue +++ b/src/components/Widget/Queue/NextList.vue @@ -10,6 +10,8 @@ + +

+ {{ hiddenCount }} titre{{ hiddenCount > 1 ? 's' : '' }} dans la liste de lecture

Aucun élément dans la liste de lecture

@@ -70,8 +72,14 @@ const localNextList = ref([]) const isAFile = computed(() => { if(selectedIndex.value == null) return false; - const video = props.nextList[selectedIndex.value]; - return video.type === "attachment"; + const video = props.nextList?.[selectedIndex.value]; + return video?.type === "attachment"; +}) + +// Titres présents côté serveur mais non envoyés (nextCount = taille réelle de la file) +const hiddenCount = computed(() => { + const shown = props.nextList ? props.nextList.length : 0; + return Number.isFinite(props.nextCount) ? Math.max(0, props.nextCount - shown) : 0; }) const dragOptions = { @@ -90,15 +98,17 @@ const dragOptions = { const props = defineProps({ selectedTab: Number, online: {}, - nextList: Array + nextList: Array, + nextCount: Number }) +// Copie locale : le drag réordonne cette copie sans modifier l'état du store watch(() => props.nextList, (newList) => { - localNextList.value = newList + localNextList.value = newList ? [...newList] : [] }) onMounted(() => { - localNextList.value = props.nextList + localNextList.value = props.nextList ? [...props.nextList] : [] }) @@ -204,6 +214,13 @@ function onDragEnd(event) { display: flex; } +.more-items { + margin: 0; + text-align: center; + font-size: 0.8em; + color: var(--text-secondary); +} + diff --git a/src/components/Widget/Queue/PreviousList.vue b/src/components/Widget/Queue/PreviousList.vue index b6696b1..a402aaf 100644 --- a/src/components/Widget/Queue/PreviousList.vue +++ b/src/components/Widget/Queue/PreviousList.vue @@ -1,6 +1,6 @@ diff --git a/src/components/Widget/View/Player/ActualChannel.vue b/src/components/Widget/View/Player/ActualChannel.vue index c2cd8ce..1e60876 100644 --- a/src/components/Widget/View/Player/ActualChannel.vue +++ b/src/components/Widget/View/Player/ActualChannel.vue @@ -1,19 +1,31 @@ @@ -37,4 +49,4 @@ onMounted(() => { white-space: nowrap; } - \ No newline at end of file + diff --git a/src/components/Widget/View/Search/PlaylistHeader.vue b/src/components/Widget/View/Search/PlaylistHeader.vue index 8722aed..566517b 100644 --- a/src/components/Widget/View/Search/PlaylistHeader.vue +++ b/src/components/Widget/View/Search/PlaylistHeader.vue @@ -5,7 +5,7 @@

{{ results.title }}

-

{{ results.songs.length }} titres{{ results.views }} vuesDurée : {{ results.readduration }}

+

{{ results.truncated ? `Aperçu : ${results.songs.length} titres sur ${results.totalTracks}` : `${results.songs.length} titres` }}{{ results.views }} vuesDurée : {{ results.readduration }}

Author Thumbnail
diff --git a/src/routes.js b/src/routes.js index a952efa..0cd83d0 100644 --- a/src/routes.js +++ b/src/routes.js @@ -11,6 +11,7 @@ import { useLoginStore } from './stores/loginStore' import { useGlobalStore } from './stores/globalStore' import Interface from './views/Interface.vue' import events from './utils/Events' +import OAuthCallback from './views/OAuthCallback.vue' const router = createRouter({ history: createWebHistory(), @@ -22,6 +23,10 @@ const router = createRouter({ {path: "/redirect", component: Redirect}, {path: "/servers", component: Servers}, {path: "/servers/:guildId", component: Interface, props: true}, + // Chargée à la demande : le tableau de bord d'administration n'alourdit pas le bundle principal + {path: "/admin", component: () => import('@/views/Admin.vue')}, + // Retour du consentement Google (URI de redirection déclarée : /oauth2callback) + {path: "/oauth2callback", component: OAuthCallback}, {path: "/:pathMatch(.*)*", component: PageError, props: {message: "404 - Page inexistante"}}, ] }) @@ -62,6 +67,18 @@ router.beforeEach((to, from, next) => { return } + // Administration : connexion requise (les droits sont vérifiés par le serveur à chaque requête) + if (to.path === "/admin" && !loginStore.token) { + next("/login") + return + } + + // Liaison Google : réservée à un utilisateur connecté (le code est transmis par son socket) + if (to.path === "/oauth2callback" && !loginStore.token) { + next("/login?error=" + encodeURIComponent("Connectez-vous avant de lier votre compte YouTube")) + return + } + if (to.path === "/login" && localStorage.getItem("token")) { next("/") return diff --git a/src/stores/adminStore.js b/src/stores/adminStore.js new file mode 100644 index 0000000..6544062 --- /dev/null +++ b/src/stores/adminStore.js @@ -0,0 +1,298 @@ +/** + * [Subsonics Chopin - Frontend] + * File: adminStore.js + * Description: État partagé de l'interface d'administration (vue d'ensemble, players, serveurs, WARP, YouTube, toasts) + */ + +import { defineStore } from 'pinia'; +import { ref, computed } from 'vue'; +import { adminRequest, adminListen, describeError } from '@/utils/AdminRequest'; + +const SECTION_KEY = "adminSection"; + +function readSection() { + try { return localStorage.getItem(SECTION_KEY) || "overview"; } catch (e) { return "overview"; } +} + +/** + * Point d'ancrage pour extrapoler la position d'un player localement (sans dérive) : + * affiché = min(durationMs, ms + rate * (performance.now() - t0)) + */ +function makeAnchor(player) { + const pos = player?.position; + const playing = player?.status ? player.status === 'playing' : (player?.playing && !player?.paused); + if (pos && typeof pos === 'object') { + return { ms: Number(pos.ms) || 0, rate: pos.rate === 1 && playing ? 1 : 0, t0: performance.now() }; + } + // Ancien format : position en secondes + const seconds = typeof pos === 'number' ? pos : Number(player?.duration) || 0; + return { ms: seconds * 1000, rate: playing ? 1 : 0, t0: performance.now() }; +} + +export const useAdminStore = defineStore('admin', () => { + + // Section active (conservée si SocketEnvironment remonte la page après une reconnexion) + const section = ref(readSection()); + + function setSection(id) { + section.value = id; + try { localStorage.setItem(SECTION_KEY, id); } catch (e) { /* stockage indisponible */ } + } + + // ----- Toasts ----- + const toasts = ref([]); + let toastId = 0; + + function dismiss(id) { + toasts.value = toasts.value.filter(t => t.id !== id); + } + + function notify(type, text, duration) { + const id = ++toastId; + toasts.value.push({ id, type, text }); + if (toasts.value.length > 5) toasts.value.shift(); + setTimeout(() => dismiss(id), duration ?? (type === 'error' ? 8000 : 4000)); + } + + function notifyError(error, context) { + notify('error', (context ? context + " : " : "") + describeError(error)); + } + + // ----- Vue d'ensemble ----- + const overview = ref(null); + const overviewLoading = ref(false); + const overviewError = ref(null); + + async function fetchOverview() { + if (overviewLoading.value) return; + overviewLoading.value = true; + try { + overview.value = await adminRequest("/ADMIN/OVERVIEW"); + overviewError.value = null; + } catch (e) { + overviewError.value = describeError(e); + } finally { + overviewLoading.value = false; + } + } + + // ----- Players ----- + const players = ref([]); + const playersLoaded = ref(false); + const playersLoading = ref(false); + const playersError = ref(null); + + // Fusionne un PlayerSummary : un état plus ancien (même instance, seq inférieur) est ignoré, + // un état de même seq garde l'ancre existante pour ne pas faire sauter la position. + function mergePlayer(old, incoming) { + if (!incoming) return old; + const sameInstance = old && incoming.instanceId && old.instanceId === incoming.instanceId; + const hasSeq = typeof incoming.seq === 'number' && typeof old?.seq === 'number'; + if (sameInstance && hasSeq && incoming.seq < old.seq) return old; + if (sameInstance && hasSeq && incoming.seq === old.seq) return { ...incoming, anchor: old.anchor }; + return { ...incoming, anchor: makeAnchor(incoming) }; + } + + function ingestPlayers(list) { + if (!Array.isArray(list)) return; + const previous = new Map(players.value.map(p => [p.guildId, p])); + players.value = list + .filter(p => p && p.guildId && p.status !== 'offline') + .map(p => mergePlayer(previous.get(p.guildId), p)); + playersLoaded.value = true; + playersError.value = null; + } + + function ingestPlayer(guildId, summary) { + if (!summary || summary.status === 'offline') { + players.value = players.value.filter(p => p.guildId !== guildId); + return; + } + const index = players.value.findIndex(p => p.guildId === summary.guildId); + if (index === -1) players.value = [...players.value, mergePlayer(null, summary)]; + else { + const copy = [...players.value]; + copy[index] = mergePlayer(copy[index], summary); + players.value = copy; + } + } + + async function fetchPlayers() { + if (playersLoading.value) return; + playersLoading.value = true; + try { + ingestPlayers(await adminRequest("/ADMIN/PLAYERS/LIST")); + } catch (e) { + playersError.value = describeError(e); + } finally { + playersLoading.value = false; + } + } + + // value : état voulu pour loop / shuffle (idempotent : un double clic ne rebascule pas) + async function playerAction(guildId, action, value) { + try { + const payload = { guildId, action }; + if (typeof value === 'boolean') payload.value = value; + const summary = await adminRequest("/ADMIN/PLAYERS/ACTION", payload); + ingestPlayer(guildId, summary); + return true; + } catch (e) { + notifyError(e, "Action « " + action + " »"); + return false; + } + } + + // ----- Serveurs ----- + const guilds = ref([]); + const guildsLoaded = ref(false); + const guildsLoading = ref(false); + const guildsError = ref(null); + + async function fetchGuilds(refresh = false) { + if (guildsLoading.value) return; + guildsLoading.value = true; + try { + const list = await adminRequest(refresh ? "/ADMIN/GUILDS/REFRESH" : "/ADMIN/GUILDS/LIST", {}, refresh ? 30000 : 15000); + guilds.value = Array.isArray(list) ? list : []; + guildsLoaded.value = true; + guildsError.value = null; + if (refresh) notify('success', "Liste des serveurs rafraîchie"); + } catch (e) { + guildsError.value = describeError(e); + } finally { + guildsLoading.value = false; + } + } + + // ----- WARP ----- + const warp = ref(null); + const warpLoading = ref(false); + const warpError = ref(null); + const warpChecking = ref(false); + + async function fetchWarp() { + if (warpLoading.value) return; + warpLoading.value = true; + try { + warp.value = await adminRequest("/ADMIN/WARP/STATUS"); + warpError.value = null; + } catch (e) { + warpError.value = describeError(e); + } finally { + warpLoading.value = false; + } + } + + // Statut complet (poussée, réponse de /ADMIN/WARP/CONFIG...) + function setWarp(data) { + if (data && typeof data === 'object') { + warp.value = data; + warpError.value = null; + } + } + + async function checkWarp() { + if (warpChecking.value) return; + warpChecking.value = true; + try { + warp.value = await adminRequest("/ADMIN/WARP/CHECK", {}, 20000); + notify(warp.value?.healthy ? 'success' : 'error', warp.value?.healthy ? "WARP opérationnel" : "WARP injoignable"); + } catch (e) { + notifyError(e, "Vérification WARP"); + } finally { + warpChecking.value = false; + } + } + + // ----- YouTube (cookies, yt-dlp, réglages, diagnostics) ----- + const youtube = ref(null); + const youtubeLoading = ref(false); + const youtubeError = ref(null); + + async function fetchYoutube() { + if (youtubeLoading.value) return; + youtubeLoading.value = true; + try { + youtube.value = await adminRequest("/ADMIN/YOUTUBE/STATUS"); + youtubeError.value = null; + } catch (e) { + youtubeError.value = describeError(e); + } finally { + youtubeLoading.value = false; + } + } + + // Statut complet renvoyé par une action (import, réglages...) ou poussé : même forme que STATUS + function setYoutube(data) { + if (data && typeof data === 'object') { + youtube.value = data; + youtubeError.value = null; + } + } + + // ----- Configuration (config.json, secrets masqués) et intégrations ----- + const config = ref(null); + const configLoading = ref(false); + const configError = ref(null); + + async function fetchConfig() { + if (configLoading.value) return; + configLoading.value = true; + try { + setConfig(await adminRequest("/ADMIN/CONFIG/GET")); + } catch (e) { + configError.value = describeError(e); + } finally { + configLoading.value = false; + } + } + + // Même forme pour GET, la réponse de SET et la poussée : { config, restartRequired, integrations } + function setConfig(data) { + if (data && typeof data === 'object' && data.config) { + config.value = data; + configError.value = null; + } + } + + // ----- Présence des comptes (poussée "/ADMIN/USERS/UPDATE") ----- + const presence = ref(null); + const onlineIds = computed(() => new Set((presence.value?.online || []).map(u => u.id))); + + // ----- Poussées temps réel ----- + // Enregistrées au montage de la vue Admin, retirées au démontage (routes.js fait removeAllListeners) + const offs = []; + + function attach() { + detach(); + offs.push(adminListen("/ADMIN/OVERVIEW/UPDATE", (data) => { + if (data) { overview.value = data; overviewError.value = null; } + })); + offs.push(adminListen("/ADMIN/PLAYERS/UPDATE", ingestPlayers)); + offs.push(adminListen("/ADMIN/WARP/UPDATE", setWarp)); + offs.push(adminListen("/ADMIN/CONFIG/UPDATE", setConfig)); + offs.push(adminListen("/ADMIN/GUILDS/UPDATE", (data) => { + if (Array.isArray(data)) { guilds.value = data; guildsLoaded.value = true; } + })); + offs.push(adminListen("/ADMIN/USERS/UPDATE", (data) => { if (data) presence.value = data; })); + offs.push(adminListen("/ADMIN/YOUTUBE/UPDATE", setYoutube)); + } + + function detach() { + while (offs.length) offs.pop()(); + } + + return { + section, setSection, + toasts, notify, notifyError, dismiss, + overview, overviewLoading, overviewError, fetchOverview, + players, playersLoaded, playersLoading, playersError, fetchPlayers, playerAction, ingestPlayer, + guilds, guildsLoaded, guildsLoading, guildsError, fetchGuilds, + warp, warpLoading, warpError, warpChecking, fetchWarp, checkWarp, setWarp, + config, configLoading, configError, fetchConfig, setConfig, + youtube, youtubeLoading, youtubeError, fetchYoutube, setYoutube, + presence, onlineIds, + attach, detach + }; +}); diff --git a/src/stores/playerStore.js b/src/stores/playerStore.js new file mode 100644 index 0000000..9c7d8ba --- /dev/null +++ b/src/stores/playerStore.js @@ -0,0 +1,319 @@ +/** + * [Subsonics Chopin - Frontend] + * File: playerStore.js + * Description: État du player de la guilde courante (source unique pour Player, DurationBar, Queue, History...) + * + * - Alimenté par /PLAYER/UPDATE (poussé par le serveur) et /PLAYER/STATE (à la demande). + * - Filtre les états périmés : même instanceId et seq <= dernier seq reçu => ignoré. + * - Position extrapolée depuis une ancre { ms, rate, t0 = performance.now() } : aucune dérive, + * aucun compteur +1, valeur juste au retour sur l'onglet. + * - Compatible avec l'ancien format d'état (sans v/status/position) : statut dérivé de playerState. + */ + +import { defineStore } from 'pinia'; +import { ref, shallowRef, computed } from 'vue'; +import { socket } from '@/socket'; +import { IORequestAsync } from '@/utils/IORequest'; +import { useGlobalStore } from '@/stores/globalStore'; + +// Rafraîchissement de l'horloge d'affichage (ms) : suffisant pour l'affichage à la seconde et la barre +const TICK_MS = 250; +const STATUSES = ['offline', 'idle', 'loading', 'playing', 'paused', 'stalled']; + +// Durée en secondes (Song.duration) -> ms, null si live / inconnue +function songDurationMs(song) { + const d = Number(song?.duration); + return Number.isFinite(d) && d > 0 ? d * 1000 : null; +} + +// Statut déduit de l'ancien format (playerState = statut AudioPlayer ou false) +function legacyStatus(raw) { + if (!raw.playerState) return 'offline'; + switch (raw.playerState) { + case 'playing': return 'playing'; + case 'paused': return 'paused'; + case 'autopaused': + case 'buffering': return 'stalled'; + // Idle avec un morceau courant = chargement du morceau (l'ancien backend ne l'exposait pas) + case 'idle': return raw.current ? 'loading' : 'idle'; + default: return raw.current ? 'stalled' : 'idle'; + } +} + +// Convertit un état reçu (v2 ou ancien format) en objet complet et cohérent +function normalize(raw) { + const isV2 = raw.v >= 2 && STATUSES.includes(raw.status); + const current = raw.current || null; + const status = isV2 ? raw.status : legacyStatus(raw); + + let durationMs; + if (raw.durationMs !== undefined) { + const d = Number(raw.durationMs); + durationMs = Number.isFinite(d) && d > 0 ? d : null; + } else { + durationMs = songDurationMs(current); + } + + let position; + if (raw.position && Number.isFinite(Number(raw.position.ms))) { + position = { ms: Number(raw.position.ms), rate: raw.position.rate === 1 ? 1 : 0 }; + } else { + // Ancien format : duration = position en secondes + const sec = Number(raw.duration); + position = { ms: Number.isFinite(sec) ? sec * 1000 : 0, rate: 1 }; + } + // Garde-fou : la position n'avance que si le morceau joue réellement + if (status !== 'playing') position.rate = 0; + position.ms = Math.max(0, durationMs ? Math.min(position.ms, durationMs) : position.ms); + + const next = Array.isArray(raw.next) ? raw.next : []; + const previous = Array.isArray(raw.previous) ? raw.previous : []; + + return { + v: isV2 ? raw.v : 1, + guildId: raw.guildId ?? null, + instanceId: raw.instanceId ?? null, + seq: Number.isFinite(raw.seq) ? raw.seq : null, + serverTime: Number.isFinite(raw.serverTime) ? raw.serverTime : null, + status, + connection: raw.connection || { + status: raw.connectionState || null, + channelId: raw.channelId ?? null, + channelName: raw.channelName ?? null + }, + current, + position, + durationMs, + seekable: typeof raw.seekable === 'boolean' ? raw.seekable : !!(current && durationMs), + loop: !!raw.loop, + shuffle: !!raw.shuffle, + pendingPause: !!raw.pendingPause, + loading: raw.loading || null, + lastError: raw.lastError || null, + next, + nextCount: Number.isFinite(raw.nextCount) ? raw.nextCount : next.length, + previous, + autoLeaveAt: Number.isFinite(raw.autoLeaveAt) ? raw.autoLeaveAt : null + }; +} + +export const usePlayerStore = defineStore('player', () => { + const globalStore = useGlobalStore(); + + // Dernier état accepté (normalisé), null tant qu'aucun état n'a été reçu + const state = shallowRef(null); + // Ancre d'extrapolation de la position + const anchor = shallowRef(null); + // Horloge réactive (performance.now()), avancée seulement quand la position bouge + const clock = ref(performance.now()); + // Compteur d'états acceptés (permet de savoir qu'un état « plus récent » est arrivé) + const revision = ref(0); + // Décalage estimé horloge serveur - horloge locale (ms) + const clockOffset = ref(0); + + // Filtrage des états périmés (non réactif) + let instanceId = null; + let lastSeq = -1; + + // === HORLOGE (requestAnimationFrame, naturellement suspendu quand l'onglet est caché) === + let rafId = null; + let lastTick = 0; + + function clockShouldRun() { + return anchor.value?.rate === 1 && !document.hidden; + } + + function tick() { + rafId = null; + const now = performance.now(); + if (now - lastTick >= TICK_MS) { + lastTick = now; + clock.value = now; + } + if (clockShouldRun()) rafId = requestAnimationFrame(tick); + } + + function startClock() { + if (rafId === null && clockShouldRun()) rafId = requestAnimationFrame(tick); + } + + function stopClock() { + if (rafId !== null) cancelAnimationFrame(rafId); + rafId = null; + } + + // Au retour sur l'onglet : la position est recalculée depuis l'ancre, donc juste immédiatement + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + stopClock(); + } else { + clock.value = performance.now(); + startClock(); + } + }); + + function setAnchor(position) { + const now = performance.now(); + anchor.value = { ms: position.ms, rate: position.rate, t0: now }; + clock.value = now; + lastTick = now; + if (position.rate === 1) startClock(); else stopClock(); + } + + // === RÉCEPTION D'UN ÉTAT === + function apply(normalized) { + state.value = normalized; + if (normalized.serverTime) clockOffset.value = normalized.serverTime - Date.now(); + setAnchor(normalized.position); + revision.value++; + } + + // Renvoie true si l'état a été accepté + function ingest(raw) { + if (!raw || typeof raw !== 'object') return false; + // État d'une autre guilde (réponse tardive après un changement de serveur) + const guild = globalStore.lastGuild; + if (raw.guildId && guild && guild !== 'null' && raw.guildId !== guild) return false; + // État périmé de la même instance de player + if (raw.instanceId && raw.instanceId === instanceId && Number.isFinite(raw.seq) && raw.seq <= lastSeq) { + console.log(`Player : état ignoré (seq ${raw.seq} <= ${lastSeq})`); + return false; + } + if (raw.instanceId) { + instanceId = raw.instanceId; + lastSeq = Number.isFinite(raw.seq) ? raw.seq : -1; + } + apply(normalize(raw)); + return true; + } + + function setOffline() { + instanceId = null; + lastSeq = -1; + apply(normalize({ v: 2, status: 'offline', guildId: globalStore.lastGuild })); + } + + // Oublie tout (démontage du player, changement de serveur) + function reset() { + stopClock(); + instanceId = null; + lastSeq = -1; + state.value = null; + anchor.value = null; + } + + // === SOCKET === + // Un seul listener /PLAYER/UPDATE actif à la fois : un nouvel appel remplace le précédent. + // La fonction renvoyée ne retire le listener que s'il est toujours celui de l'appelant + // (le démontage de l'ancienne page survient APRÈS le setup de la nouvelle). + let activeOff = null; + function listen() { + if (activeOff) activeOff(); + const handler = (data) => ingest(data); + socket.on('/PLAYER/UPDATE', handler); + const off = () => { + socket.off('/PLAYER/UPDATE', handler); + if (activeOff === off) activeOff = null; + }; + activeOff = off; + return off; + } + + // Demande l'état complet (une seule requête en vol, une relance si redemandé entre-temps) + let requestInFlight = false; + let requestAgain = false; + async function requestState() { + if (requestInFlight) { + requestAgain = true; + return; + } + requestInFlight = true; + const revisionAtRequest = revision.value; + const response = await IORequestAsync('/PLAYER/STATE', undefined, 8000); + requestInFlight = false; + if (response === false) { + // Pas de player : hors-ligne, sauf si un état plus récent est arrivé entre-temps + if (revision.value === revisionAtRequest) setOffline(); + } else if (response) { + ingest(response); + } + if (requestAgain) { + requestAgain = false; + requestState(); + } + } + + // === GETTERS === + const hasState = computed(() => state.value !== null); + const status = computed(() => state.value?.status ?? 'offline'); + const online = computed(() => status.value !== 'offline'); + const current = computed(() => state.value?.current ?? null); + const next = computed(() => state.value?.next ?? []); + const nextCount = computed(() => state.value?.nextCount ?? 0); + const previous = computed(() => state.value?.previous ?? []); + const loop = computed(() => state.value?.loop ?? false); + const shuffle = computed(() => state.value?.shuffle ?? false); + const durationMs = computed(() => state.value?.durationMs ?? null); + const seekable = computed(() => state.value?.seekable ?? false); + const pendingPause = computed(() => state.value?.pendingPause ?? false); + const loading = computed(() => state.value?.loading ?? null); + const lastError = computed(() => state.value?.lastError ?? null); + const connection = computed(() => state.value?.connection ?? { status: null, channelId: null, channelName: null }); + const autoLeaveAt = computed(() => state.value?.autoLeaveAt ?? null); + + // Position affichée (ms) : min(durée, ancre + rate * temps écoulé) + const positionMs = computed(() => { + const a = anchor.value; + if (!a) return 0; + const elapsed = a.rate === 1 ? Math.max(0, clock.value - a.t0) : 0; + let ms = a.ms + elapsed; + if (durationMs.value) ms = Math.min(ms, durationMs.value); + return Math.max(0, ms); + }); + + // Texte « Chargement… (WARP/direct, essai n) » + const loadingLabel = computed(() => { + if (status.value !== 'loading') return null; + const l = loading.value; + const details = []; + if (l?.route === 'warp') details.push('WARP'); + else if (l?.route === 'direct') details.push('direct'); + if (l?.attempt) details.push('essai ' + l.attempt); + return 'Chargement…' + (details.length ? ' (' + details.join(', ') + ')' : ''); + }); + + // Heure serveur estimée (pour comparer since / at / autoLeaveAt) + function serverNow() { + return Date.now() + clockOffset.value; + } + + return { + playerState: state, + revision, + clockOffset, + hasState, + status, + online, + current, + next, + nextCount, + previous, + loop, + shuffle, + durationMs, + seekable, + pendingPause, + loading, + lastError, + connection, + autoLeaveAt, + positionMs, + loadingLabel, + ingest, + setOffline, + reset, + listen, + requestState, + serverNow + }; +}); diff --git a/src/utils/AdminRequest.js b/src/utils/AdminRequest.js new file mode 100644 index 0000000..63ee028 --- /dev/null +++ b/src/utils/AdminRequest.js @@ -0,0 +1,58 @@ +/** + * [Subsonics Chopin - Frontend] + * File: AdminRequest.js + * Description: Requêtes de l'interface d'administration (ack Socket.IO + timeout) + * Enveloppe serveur : { ok: true, data } | { ok: false, error: CODE, message? } + */ + +import { socket } from '@/socket'; + +// Libellés français des codes d'erreur renvoyés par AdminRequest (backend/src/server/Admin.js) +export const ADMIN_ERRORS = { + FORBIDDEN: "Accès refusé", + INVALID_PARAMS: "Paramètres invalides", + NOT_FOUND: "Élément introuvable", + SELF_ACTION: "Action impossible sur votre propre compte", + TARGET_PROTECTED: "Cible protégée", + UNAVAILABLE: "Indisponible", + INTERNAL: "Erreur interne du serveur", + TIMEOUT: "Délai dépassé", + DISCONNECTED: "Non connecté au serveur" +}; + +/** + * Envoie une requête admin et attend l'ack du serveur. + * Résout avec `data`, rejette avec une Error portant un `code` (FORBIDDEN, TIMEOUT...). + */ +export function adminRequest(event, data = {}, timeout = 15000) { + // Hors ligne : échec immédiat au lieu d'attendre tout le délai (socket.io mettrait l'envoi en tampon) + if (socket.disconnected) { + return Promise.reject(Object.assign(new Error("Connexion au serveur perdue"), { code: "DISCONNECTED" })); + } + return new Promise((resolve, reject) => socket.timeout(timeout).emit(event, data, (err, res) => { + if (err) return reject(Object.assign(new Error("Délai dépassé"), { code: "TIMEOUT" })); + if (res?.ok) return resolve(res.data); + // Un `false` brut (ancien format) est traité comme une erreur interne + reject(Object.assign(new Error(res?.message || res?.error || "Erreur"), { code: res?.error || "INTERNAL" })); + })); +} + +/** + * Écoute une poussée du serveur ("/ADMIN/.../UPDATE"). + * Renvoie la fonction de désinscription (à appeler au démontage). + */ +export function adminListen(event, callback) { + const handler = (data) => callback(data); + socket.on(event, handler); + return () => socket.off(event, handler); +} + +/** + * Texte lisible d'une erreur admin, avec son code : "Accès refusé : ... (FORBIDDEN)" + */ +export function describeError(error) { + const code = error?.code || "INTERNAL"; + const label = ADMIN_ERRORS[code] || "Erreur"; + const message = error?.message && error.message !== code && error.message !== label ? error.message : ""; + return `${label}${message ? " : " + message : ""} (${code})`; +} diff --git a/src/utils/IORequest.js b/src/utils/IORequest.js index 6036e51..910f1ff 100644 --- a/src/utils/IORequest.js +++ b/src/utils/IORequest.js @@ -11,12 +11,54 @@ export function IORequest(event, callback, data) { } } +// Écoute un événement poussé par le serveur. +// Renvoie une fonction de désinscription qui ne retire QUE ce handler (les appels existants peuvent l'ignorer). export function IOListener(event, callback) { console.log(`IOListener: Listen for event ${event}`); - socket.on(event, (data) => { + const handler = (data) => { if (callback && typeof callback === 'function') { console.log(`IOListener: Received data for event ${event}:`, data); callback(data); } + }; + socket.on(event, handler); + return () => socket.off(event, handler); +} + +// Requête / réponse au format historique (réponse émise par IOAnswer sur le même nom d'événement), sous forme de Promise. +// Ne rejette jamais : résout la réponse, ou null si rien n'est arrivé avant le délai +// (handler côté serveur qui ne répond pas, listeners retirés par une navigation...). +export function IORequestAsync(event, data, timeout = 10000) { + return new Promise((resolve) => { + const handler = (response) => { + clearTimeout(timer); + console.log(`IORequestAsync: Received response for event ${event}:`, response); + resolve(response); + }; + const timer = setTimeout(() => { + socket.off(event, handler); + console.warn(`IORequestAsync: Aucune réponse pour ${event} après ${timeout} ms`); + resolve(null); + }, timeout); + socket.once(event, handler); + console.log(`IORequestAsync: Emitting event ${event} with data:`, data); + socket.emit(event, data); }); -} \ No newline at end of file +} + +// Requête / réponse par acknowledgement socket.io : chaque réponse est associée à SA requête. +// Utilisable pour les handlers serveur qui répondent via l'ack (callback(value, ack)). +// Rejette avec { code: "TIMEOUT" } si le serveur ne répond pas avant le délai. +export function IORequestAck(event, data, timeout = 10000) { + return new Promise((resolve, reject) => { + console.log(`IORequestAck: Emitting event ${event} with data:`, data); + socket.timeout(timeout).emit(event, data, (err, response) => { + if (err) { + reject(Object.assign(new Error(`Délai dépassé pour ${event}`), { code: "TIMEOUT" })); + return; + } + console.log(`IORequestAck: Received response for event ${event}:`, response); + resolve(response); + }); + }); +} diff --git a/src/utils/SocketEnvironment.vue b/src/utils/SocketEnvironment.vue index 28f7f74..3066019 100644 --- a/src/utils/SocketEnvironment.vue +++ b/src/utils/SocketEnvironment.vue @@ -4,13 +4,14 @@ r \ No newline at end of file + \ No newline at end of file diff --git a/src/utils/TimeConverter.js b/src/utils/TimeConverter.js index 951174a..11851f9 100644 --- a/src/utils/TimeConverter.js +++ b/src/utils/TimeConverter.js @@ -1,79 +1,113 @@ +// Conversion de durées : aucune fonction ne renvoie "NaN". +// Même format que backend/src/utils/TimeConverter.js. + +/** + * Convertit une valeur en nombre fini. Les chaînes ne sont acceptées que si elles sont strictement numériques + * ("116.088163" oui, "117290116.088163116..." non). + * @returns {number} Le nombre, ou NaN si la valeur est invalide + */ +export function toFiniteNumber(value) { + if (typeof value === "number") return Number.isFinite(value) ? value : NaN + if (typeof value === "string" && /^\s*-?\d+(\.\d+)?\s*$/.test(value)) { + const number = Number(value) + return Number.isFinite(number) ? number : NaN + } + return NaN +} + +/** + * Nombre de secondes entier et >= 0 (0 si la valeur est invalide ou négative) + */ +function toSeconds(duration) { + const value = toFiniteNumber(duration) + if (!Number.isFinite(value) || value < 0) return 0 + return Math.floor(value) +} + +function pad(number) { + return String(number).padStart(2, "0") +} + +/** + * @param {number} duration Durée en secondes + * @returns {string} "3m05s" ou "1h02m03s" ("0m00s" si la durée est invalide) + */ export function getReadableDuration(duration) { - var max = "" - duration *= 1000; + const total = toSeconds(duration) + const hours = Math.floor(total / 3600) + const minutes = Math.floor((total % 3600) / 60) + const seconds = total % 60 - const maxhours = Math.floor(duration / 3600000); - - var maxmin = Math.trunc(duration / 60000) - (Math.floor(duration / 60000 / 60) * 60); - var maxsec = Math.floor(duration / 1000) - (Math.floor(duration / 1000 / 60) * 60); - - - if (maxsec < 10) { - maxsec = `0${maxsec}`; + if (hours > 0) { + return `${hours}h${pad(minutes)}m${pad(seconds)}s` } - - - if(maxhours != 0) { - - if (maxmin < 10) { - maxmin = `0${maxmin}`; - } - - - max = maxhours + "h" + maxmin + "m" + maxsec - } else { - max = maxmin + "m" + maxsec + "s" - - } - - return max + return `${minutes}m${pad(seconds)}s` } // Get hh:mm:ss format +/** + * @param {number} duration Durée en secondes + * @returns {string} "3:05" ou "1:02:03" ; "-:--" si la durée est inconnue (NaN, undefined, chaîne), "0:00" si négative + */ export function getVideoDuration(duration) { - var max = "" - duration *= 1000; + const value = toFiniteNumber(duration) + if (!Number.isFinite(value)) return "-:--" - const maxhours = Math.floor(duration / 3600000); + const total = toSeconds(value) + const hours = Math.floor(total / 3600) + const minutes = Math.floor((total % 3600) / 60) + const seconds = total % 60 - var maxmin = Math.trunc(duration / 60000) - (Math.floor(duration / 60000 / 60) * 60); - var maxsec = Math.floor(duration / 1000) - (Math.floor(duration / 1000 / 60) * 60); - - - if (maxsec < 10) { - maxsec = `0${maxsec}`; + if (hours > 0) { + return `${hours}:${pad(minutes)}:${pad(seconds)}` } - - - if(maxhours != 0) { - - if (maxmin < 10) { - maxmin = `0${maxmin}`; - } - - - max = maxhours + ":" + maxmin + ":" + maxsec - } else { - max = maxmin + ":" + maxsec - - } - - return max - + return `${minutes}:${pad(seconds)}` } +/** + * @param {string} duration Durée au format hh:mm:ss, mm:ss ou ss + * @returns {number} Secondes (0 si le format est invalide) + */ export function getSecondsDuration(duration) { - // Duration is in format hh:mm:ss and can be just m:ss or mm:ss - var durationArray = duration.split(":"); - var seconds = 0; - if(durationArray.length == 3) { - seconds = parseInt(durationArray[0]) * 3600 + parseInt(durationArray[1]) * 60 + parseInt(durationArray[2]); - } else if(durationArray.length == 2) { - seconds = parseInt(durationArray[0]) * 60 + parseInt(durationArray[1]); - } else { - seconds = parseInt(durationArray[0]); + if (typeof duration === "number") return toSeconds(duration) + if (typeof duration !== "string" || duration.trim() === "") return 0 + + const parts = duration.trim().split(":").map(part => parseInt(part, 10)) + if (parts.length > 3 || parts.some(part => !Number.isFinite(part) || part < 0)) return 0 + + // On lit de droite à gauche : secondes, minutes, heures + let seconds = 0 + let multiplier = 1 + for (let i = parts.length - 1; i >= 0; i--) { + seconds += parts[i] * multiplier + multiplier *= 60 } - return seconds; + return seconds } +/** + * Durée cumulée pour les statistiques (peut dépasser plusieurs jours) + * @param {number} duration Durée en secondes + * @returns {string} "2j 08h34m50s", sinon même format que getReadableDuration + */ +export function formatStatDuration(duration) { + const total = toSeconds(duration) + const days = Math.floor(total / 86400) + if (days === 0) return getReadableDuration(total) + + const rest = total % 86400 + const hours = Math.floor(rest / 3600) + const minutes = Math.floor((rest % 3600) / 60) + const seconds = rest % 60 + return `${days}j ${pad(hours)}h${pad(minutes)}m${pad(seconds)}s` +} + +/** + * Compteur lisible ("1 398") ; 0 si la valeur est invalide ou négative + */ +export function formatStatCount(value) { + const number = toFiniteNumber(value) + const safe = Number.isFinite(number) && number >= 0 ? number : 0 + return safe.toLocaleString("fr-FR") +} diff --git a/src/views/Admin.vue b/src/views/Admin.vue new file mode 100644 index 0000000..c3476b7 --- /dev/null +++ b/src/views/Admin.vue @@ -0,0 +1,10 @@ + + diff --git a/src/views/OAuthCallback.vue b/src/views/OAuthCallback.vue new file mode 100644 index 0000000..61f0bc9 --- /dev/null +++ b/src/views/OAuthCallback.vue @@ -0,0 +1,15 @@ + +