diff --git a/server/auth.ts b/server/auth.ts index 8f18e07..6017e9a 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -8,9 +8,11 @@ import { DATA_DIR } from './config.js' import { logger } from './logger.js' import { findByUsername, findById, findByApiToken, - createUser, changePassword, regenerateApiToken, safeUser, hasUsers, + createUser, changePassword, regenerateApiToken, safeUser, hasUsers, markTourSeen, type User, } from './users.js' +import { readSettings } from './settings.js' +import { startOnboarding } from './onboarding.js' // ── Typage Express étendu ───────────────────────────────────── declare global { @@ -50,7 +52,11 @@ export function authStatus(req: Request, res: Response): void { const payload = jwt.verify(token, JWT_SECRET) as any const user = findById(payload.id) if (!user) { res.json({ setup, loggedIn: false }); return } - res.json({ setup, loggedIn: true, role: user.role, username: user.username, userId: user.id }) + res.json({ + setup, loggedIn: true, role: user.role, username: user.username, userId: user.id, + onboardingDone: !!readSettings().onboardingCompletedAt, + tourSeen : !!user.tourSeenAt, + }) } catch { res.json({ setup, loggedIn: false }) } @@ -67,9 +73,13 @@ export function authSetup(req: Request, res: Response): void { if (!username || !password) { res.status(400).json({ error: 'Username et password requis' }); return } + if (password.length < 6) { + res.status(400).json({ error: 'Le mot de passe doit faire au moins 6 caractères' }); return + } try { const user = createUser(username, password, 'admin') + startOnboarding() logger.info('auth', `Premier compte admin créé pour "${username}"`) setCookieAndRespond(res, user) } catch (err) { @@ -143,6 +153,15 @@ export function authRegenerateToken(req: Request, res: Response): void { } } +// POST /api/auth/tour-seen +export function authTourSeen(req: Request, res: Response): void { + try { + res.json({ tourSeenAt: markTourSeen(req.user!.id) }) + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' }) + } +} + // POST /api/auth/register (via lien d'invitation) export function authRegister(req: Request, res: Response): void { const { username, password, inviteCode } = req.body diff --git a/server/index.ts b/server/index.ts index f15be25..3eebe13 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3,8 +3,9 @@ import cookieParser from 'cookie-parser' import http from 'http' import fs from 'fs' import path from 'path' -import { authStatus, authSetup, authLogin, authLogout, authMe, authChangePassword, authRegenerateToken, requireAuth, requireAdmin } from './auth.js' +import { authStatus, authSetup, authLogin, authLogout, authMe, authChangePassword, authRegenerateToken, authTourSeen, requireAuth, requireAdmin } from './auth.js' import { migrateIfNeeded } from './users.js' +import { migrateOnboarding } from './onboarding.js' import { readSettings } from './settings.js' import { registerDriver, dispatchList, dispatchRemove } from './torrent-clients/index.js' import qbittorrentDriver from './torrent-clients/qbittorrent.js' @@ -71,6 +72,7 @@ if (fs.existsSync(PUBLIC_PATH)) { // ── Migration mono-user → multi-user ────────────────────────── migrateIfNeeded() +migrateOnboarding() // ── Auth ─────────────────────────────────────────────────────── app.get ('/api/auth/status', authStatus) @@ -80,6 +82,7 @@ app.post('/api/auth/logout', authLogout) app.get ('/api/auth/me', requireAuth, authMe) app.post('/api/auth/change-password', requireAuth, authChangePassword) app.post('/api/auth/regenerate-token', requireAuth, authRegenerateToken) +app.post('/api/auth/tour-seen', requireAuth, authTourSeen) // ── API publique v1 (auth par token) ────────────────────────── app.use('/api', publicApiRouter) diff --git a/server/onboarding.ts b/server/onboarding.ts new file mode 100644 index 0000000..7b016fa --- /dev/null +++ b/server/onboarding.ts @@ -0,0 +1,55 @@ +import { readSettings, writeSettings } from './settings.js' +import { markAllToursSeen } from './users.js' +import { listClients } from './torrent-clients/index.js' +import { logger } from './logger.js' + +export const ONBOARDING_STEPS = ['welcome', 'paths', 'client', 'import', 'media-server', 'catalog', 'recap'] as const +export type OnboardingStep = typeof ONBOARDING_STEPS[number] + +export interface OnboardingState { + step : OnboardingStep | null + completedAt: string | null +} + +export function isOnboardingStep(value: unknown): value is OnboardingStep { + return typeof value === 'string' && (ONBOARDING_STEPS as readonly string[]).includes(value) +} + +export function readOnboarding(): OnboardingState { + const { onboardingStep, onboardingCompletedAt } = readSettings() + return { step: isOnboardingStep(onboardingStep) ? onboardingStep : null, completedAt: onboardingCompletedAt } +} + +// Installation antérieure à l'assistant : déjà configurée → rien n'est imposé +export function migrateOnboarding(): boolean { + const settings = readSettings() + if (settings.onboardingCompletedAt || settings.onboardingStep) return false + if (!settings.mediaPath && listClients().length === 0) return false + + const now = new Date().toISOString() + writeSettings({ onboardingCompletedAt: now }) + markAllToursSeen(now) + logger.info('onboarding', 'Installation existante détectée — assistant marqué comme terminé') + return true +} + +export function startOnboarding(): void { + if (readSettings().onboardingCompletedAt) return + writeSettings({ onboardingStep: 'welcome' }) +} + +export function advanceOnboarding(step?: OnboardingStep, complete = false): OnboardingState { + const current = readOnboarding() + const patch: { onboardingStep?: string; onboardingCompletedAt?: string } = {} + + if (step && (!current.step || ONBOARDING_STEPS.indexOf(step) > ONBOARDING_STEPS.indexOf(current.step))) { + patch.onboardingStep = step + } + if (complete) { + patch.onboardingStep = 'recap' + patch.onboardingCompletedAt = new Date().toISOString() + logger.info('onboarding', 'Assistant de configuration terminé') + } + if (Object.keys(patch).length > 0) writeSettings(patch) + return readOnboarding() +} diff --git a/server/organize.ts b/server/organize.ts index 3a1daad..89e20c1 100644 --- a/server/organize.ts +++ b/server/organize.ts @@ -268,7 +268,7 @@ export async function scanMediaPath( } // Désimport automatique des fichiers manquants sur le disque - const { autoUnimportMissing } = readSettings() as any + const { autoUnimportMissing } = readSettings() let autoRemoved = 0 if (autoUnimportMissing && !mediaEmpty) { for (const [hash, episodes] of Object.entries(organized)) { @@ -300,11 +300,15 @@ export async function autoOrganizeAll( seriesData : any[], onResult ?: (r: { hash: string; name: string; serieId?: number | null; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void ): Promise { - const { autoImport } = readSettings() + const { autoImport, mediaPath } = readSettings() if (!autoImport) { logger.debug('organize', 'Import automatique désactivé — skip') return } + if (!mediaPath?.trim()) { + logger.debug('organize', 'Médiathèque non configurée — skip') + return + } if (_queued > 0) { logger.debug('organize', 'Import déjà en cours — skip') diff --git a/server/routes/settings.ts b/server/routes/settings.ts index 506e683..719977c 100644 --- a/server/routes/settings.ts +++ b/server/routes/settings.ts @@ -2,9 +2,22 @@ import { Router } from 'express' import { requireAuth } from '../auth.js' import { readSettings, writeSettings } from '../settings.js' import { logger } from '../logger.js' +import { readOnboarding, advanceOnboarding, isOnboardingStep } from '../onboarding.js' const router = Router() +router.get('/settings/onboarding', requireAuth, (_req, res) => { + res.json(readOnboarding()) +}) + +router.post('/settings/onboarding', requireAuth, (req, res) => { + const { step, complete } = req.body ?? {} + if (step !== undefined && !isOnboardingStep(step)) { + res.status(400).json({ error: 'Étape inconnue' }); return + } + res.json(advanceOnboarding(step, complete === true)) +}) + router.get('/settings', requireAuth, (_req, res) => { res.json(readSettings()) }) diff --git a/server/routes/system.ts b/server/routes/system.ts index 4ec1fe1..39d0c72 100644 --- a/server/routes/system.ts +++ b/server/routes/system.ts @@ -5,7 +5,7 @@ import { requireAuth } from '../auth.js' import { logger, readLogs, clearLogs, logsFileSize } from '../logger.js' import { DATA_DIR, BASE_DIR } from '../config.js' import { readSettings } from '../settings.js' -import { systemInfo } from '../system.js' +import { systemInfo, checkPaths } from '../system.js' import { scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from '../organize.js' import { readAvailable, cacheClear } from '../lib/github-cache.js' import { loadCatalog } from '../lib/serie-helpers.js' @@ -36,6 +36,14 @@ router.get('/system', requireAuth, (_req, res) => { res.json({ isDocker: fs.existsSync('/.dockerenv') }) }) +router.post('/system/check-paths', requireAuth, (req, res) => { + const { mediaPath, completePath } = req.body ?? {} + if (typeof mediaPath !== 'string' || (completePath !== undefined && typeof completePath !== 'string')) { + res.status(400).json({ error: 'mediaPath requis' }); return + } + res.json(checkPaths(mediaPath, completePath)) +}) + router.get('/version', (_req, res) => { try { const versionPath = path.join(BASE_DIR, 'version.txt') diff --git a/server/settings.ts b/server/settings.ts index 2ce37d7..0522051 100644 --- a/server/settings.ts +++ b/server/settings.ts @@ -17,6 +17,9 @@ export interface Settings { jellyfinUrl : string // URL du serveur Jellyfin (ex: http://jellyfin:8096) jellyfinAdminToken : string // Token admin Jellyfin pour la sync englishDirectory : boolean // Si true, les dossiers de séries sont en anglais (ex: "Season 1" au lieu de "Saison 1") + autoUnimportMissing : boolean + onboardingStep : string | null // étape la plus avancée atteinte dans l'assistant + onboardingCompletedAt: string | null } const defaults: Settings = { @@ -32,6 +35,9 @@ const defaults: Settings = { jellyfinUrl : '', jellyfinAdminToken : '', englishDirectory : false, + autoUnimportMissing: false, + onboardingStep : null, + onboardingCompletedAt: null, } export function readSettings(): Settings { diff --git a/server/system.ts b/server/system.ts index ade7a68..f92ee14 100644 --- a/server/system.ts +++ b/server/system.ts @@ -1,5 +1,7 @@ import fs from 'fs' import os from 'os' +import path from 'path' +import { randomBytes } from 'crypto' import type { Request, Response } from 'express' // GET /api/system/info @@ -12,4 +14,140 @@ export function systemInfo(_req: Request, res: Response): void { isTermux, defaultPath: isTermux ? '/sdcard/' : '/', }) -} \ No newline at end of file +} + +// ── Vérification des dossiers (assistant de configuration) ──── + +export interface PathCheck { + input : string + resolved : string + exists : boolean + isDirectory: boolean + writable : boolean + error? : string +} + +export interface HardlinkCheck { + tested : boolean + ok : boolean + code? : string + message: string +} + +export type PathRelation = 'distinct' | 'same' | 'nested' + +function tempName(prefix: string): string { + return `.fankarr-${prefix}-${process.pid}-${randomBytes(4).toString('hex')}` +} + +function removeQuietly(file: string): void { + try { fs.rmSync(file, { force: true, maxRetries: 3, retryDelay: 100 }) } catch {} +} + +export function checkDirectory(input: string, { allowRoot = true } = {}): PathCheck { + const raw = (input ?? '').trim() + const result: PathCheck = { input: raw, resolved: '', exists: false, isDirectory: false, writable: false } + + if (!raw) return { ...result, error: 'Chemin vide' } + if (/^[A-Za-z]:$/.test(raw)) return { ...result, error: 'Chemin incomplet : ajoutez une barre après la lettre du lecteur' } + if (!path.isAbsolute(raw)) return { ...result, error: 'Le chemin doit être absolu' } + + result.resolved = path.resolve(raw) + if (!allowRoot && path.parse(result.resolved).root === result.resolved) { + return { ...result, error: 'La racine du disque ne peut pas servir de médiathèque' } + } + + let stat: fs.Stats + try { stat = fs.statSync(result.resolved) } + catch { return { ...result, error: 'Dossier introuvable' } } + + result.exists = true + if (!stat.isDirectory()) return { ...result, error: "Ce chemin n'est pas un dossier" } + result.isDirectory = true + + // Test d'écriture réel + const probe = path.join(result.resolved, tempName('write')) + try { + fs.writeFileSync(probe, '') + result.writable = true + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + result.error = `Écriture impossible${code ? ` (${code})` : ''}` + } finally { + removeQuietly(probe) + } + return result +} + +export function pathRelation(a: string, b: string): PathRelation { + const norm = (p: string) => { + const r = path.resolve(p).replace(/[\\/]+$/, '') + return process.platform === 'win32' ? r.toLowerCase() : r + } + const na = norm(a), nb = norm(b) + if (na === nb) return 'same' + if (na.startsWith(nb + path.sep) || nb.startsWith(na + path.sep)) return 'nested' + return 'distinct' +} + +function hardlinkMessage(code: string | undefined, err: unknown): string { + switch (code) { + case 'EXDEV': + return 'Volumes différents : hardlink impossible. Passez en mode Copier ou, sous Docker, montez un seul volume parent contenant téléchargements et médiathèque.' + case 'EPERM': + case 'ENOTSUP': + case 'ENOSYS': + return 'Ce système de fichiers ne gère pas les hardlinks (FAT/exFAT, partage réseau…).' + case 'EACCES': + return 'Permissions insuffisantes pour créer un hardlink.' + default: + return `Hardlink impossible : ${err instanceof Error ? err.message : String(err)}` + } +} + +export function testHardlink(srcDir: string, destDir: string): HardlinkCheck { + const name = tempName('linktest') + const src = path.join(srcDir, name) + const dest = path.join(destDir, `${name}.link`) + try { + fs.writeFileSync(src, '') + fs.linkSync(src, dest) + return { tested: true, ok: true, message: 'Hardlink possible entre les deux dossiers' } + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + return { tested: true, ok: false, code, message: hardlinkMessage(code, err) } + } finally { + removeQuietly(dest) + removeQuietly(src) + } +} + +export function checkPaths(mediaPath: string, completePath?: string) { + const media = checkDirectory(mediaPath, { allowRoot: false }) + const hasComplete = !!completePath?.trim() + const complete = hasComplete ? checkDirectory(completePath!) : null + + const valid = (c: PathCheck | null) => !!c && c.writable && !c.error + const relation = hasComplete && media.resolved && complete?.resolved + ? pathRelation(media.resolved, complete.resolved) + : null + + let hardlink: HardlinkCheck + if (!hasComplete) { + hardlink = { tested: false, ok: false, message: 'Renseignez le dossier de téléchargements pour tester le hardlink' } + } else if (!valid(media) || !valid(complete)) { + hardlink = { tested: false, ok: false, message: 'Test impossible tant que les dossiers ne sont pas valides' } + } else if (relation === 'same') { + hardlink = { tested: false, ok: false, message: 'Les deux dossiers sont identiques' } + } else { + hardlink = testHardlink(complete!.resolved, media.resolved) + } + + return { + ok: valid(media) && (!hasComplete || valid(complete)) && relation !== 'same', + mediaPath : media, + completePath: complete, + relation, + hardlink, + } +} diff --git a/server/users.ts b/server/users.ts index 051f2d2..8823315 100644 --- a/server/users.ts +++ b/server/users.ts @@ -19,6 +19,7 @@ export interface User { role : UserRole apiToken : string createdAt : string + tourSeenAt? : string | null } const USERS_PATH = path.join(DATA_DIR, 'users.json') @@ -92,6 +93,7 @@ export function createUser(username: string, password: string, role: UserRole = role, apiToken : crypto.randomBytes(32).toString('hex'), createdAt : new Date().toISOString(), + tourSeenAt : null, } writeUsers([...users, user]) logger.info('users', `Utilisateur "${username}" créé (rôle: ${role})`) @@ -142,6 +144,24 @@ export function changePassword(id: string, currentPassword: string, newPassword: logger.info('users', `Mot de passe changé pour "${user.username}"`) } +export function markTourSeen(id: string): string { + const users = readUsers() + const user = users.find(u => u.id === id) + if (!user) throw new Error('Utilisateur introuvable') + user.tourSeenAt = user.tourSeenAt ?? new Date().toISOString() + writeUsers(users) + return user.tourSeenAt +} + +export function markAllToursSeen(at: string): number { + const users = readUsers() + const pending = users.filter(u => !u.tourSeenAt) + if (pending.length === 0) return 0 + for (const u of pending) u.tourSeenAt = at + writeUsers(users) + return pending.length +} + export function regenerateApiToken(id: string): string { const users = readUsers() const user = users.find(u => u.id === id) diff --git a/src/components/FankarrLogo.vue b/src/components/FankarrLogo.vue new file mode 100644 index 0000000..a56d96b --- /dev/null +++ b/src/components/FankarrLogo.vue @@ -0,0 +1,6 @@ + diff --git a/src/components/ToastContainer.vue b/src/components/ToastContainer.vue index 9741fc7..4e5d85b 100644 --- a/src/components/ToastContainer.vue +++ b/src/components/ToastContainer.vue @@ -1,6 +1,6 @@ diff --git a/src/views/settings/AdvancedView.vue b/src/views/settings/AdvancedView.vue index 81a3ab4..ff6c825 100644 --- a/src/views/settings/AdvancedView.vue +++ b/src/views/settings/AdvancedView.vue @@ -16,12 +16,28 @@
Paramètre sauvegardé.
+
+
+

Assistant de configuration

+

+ Reprend pas à pas les dossiers, le client torrent, Jellyfin/Plex et le catalogue. La configuration actuelle est conservée. +

+
+
+ + Relancer l'assistant +
+
+ \ No newline at end of file + diff --git a/src/views/settings/JellyfinView.vue b/src/views/settings/JellyfinView.vue index 8da204c..d2621a2 100644 --- a/src/views/settings/JellyfinView.vue +++ b/src/views/settings/JellyfinView.vue @@ -11,34 +11,7 @@

-
-
- - -

URL accessible depuis FanKarr (réseau local ou Docker)

-
-
- - -

- Dans Jellyfin : Tableau de bord → Clés API → Créer une clé -

-
- -
- - - - {{ testResult.ok ? `Connecté — Jellyfin ${testResult.version}` : testResult.error }} - - Enregistré. -
-
+ @@ -102,15 +75,10 @@ diff --git a/src/views/settings/ProfileView.vue b/src/views/settings/ProfileView.vue index 782c2b3..c6ec824 100644 --- a/src/views/settings/ProfileView.vue +++ b/src/views/settings/ProfileView.vue @@ -57,7 +57,7 @@ -
+

Token API personnel