feat: initial project
This commit is contained in:
commit
eaf01cfc59
38 files changed
+5807
No files matched your search
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
data
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -0,0 +1,40 @@
|
||||
name: containerize
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- created
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: masutayunikon/fankarr
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=dev-,enable=${{ github.ref == 'refs/heads/dev' }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -0,0 +1,16 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
name: release-please
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: googleapis/release-please-action@v4
|
||||
with:
|
||||
token: ${{ secrets.FINED_TOKEN }}
|
||||
release-type: simple
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
FROM node:20-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
# ── Dépendances prod ───────────────────────────────────────────
|
||||
FROM base AS prod-deps
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
|
||||
|
||||
# ── Build ──────────────────────────────────────────────────────
|
||||
FROM base AS build
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN pnpm run build
|
||||
|
||||
# ── Production ─────────────────────────────────────────────────
|
||||
FROM base
|
||||
COPY --from=prod-deps /app/node_modules /app/node_modules
|
||||
COPY --from=build /app/dist/server /app/dist/server
|
||||
COPY --from=build /app/dist/client /app/public
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 3001
|
||||
ENV NODE_ENV=production
|
||||
CMD ["node", "dist/server/index.js"]
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
Whitespace-only changes.
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Serif+Display&display=swap" rel="stylesheet">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>fankarr</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "fankarr",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"vite\" \"tsx watch server/index.ts\"",
|
||||
"dev:server": "tsx watch server/index.ts",
|
||||
"dev:client": "vite",
|
||||
"build": "vite build --outDir dist/client && tsc -p tsconfig.server.json",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pinia": "^3.0.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vue-tsc": "^3.1.5"
|
||||
}
|
||||
}
|
||||
Generated
+2716
File diff suppressed because it is too large.
Load diff
+114
@@ -0,0 +1,114 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import bcrypt from 'bcrypt'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
const DATA_PATH = path.join(process.cwd(), 'data', 'auth.json')
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'fankarr-secret-change-me'
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
interface AuthData {
|
||||
username: string
|
||||
passwordHash: string
|
||||
}
|
||||
|
||||
// ── Helpers fichier ───────────────────────────────────────────
|
||||
function readAuth(): AuthData | null {
|
||||
try {
|
||||
if (!fs.existsSync(DATA_PATH)) return null
|
||||
return JSON.parse(fs.readFileSync(DATA_PATH, 'utf-8'))
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function writeAuth(data: AuthData): void {
|
||||
fs.mkdirSync(path.dirname(DATA_PATH), { recursive: true })
|
||||
fs.writeFileSync(DATA_PATH, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/auth/status
|
||||
export function authStatus(req: Request, res: Response): void {
|
||||
const existing = readAuth()
|
||||
const token = req.cookies?.fankarr_token
|
||||
|
||||
let loggedIn = false
|
||||
if (token) {
|
||||
try {
|
||||
jwt.verify(token, JWT_SECRET)
|
||||
loggedIn = true
|
||||
} catch {}
|
||||
}
|
||||
|
||||
res.json({
|
||||
setup: !!existing,
|
||||
loggedIn
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/auth/setup
|
||||
export function authSetup(req: Request, res: Response): void {
|
||||
if (readAuth()) {
|
||||
res.status(400).json({ error: 'Un compte existe déjà' })
|
||||
return
|
||||
}
|
||||
|
||||
const { username, password } = req.body
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username et password requis' })
|
||||
return
|
||||
}
|
||||
|
||||
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
|
||||
writeAuth({ username, passwordHash })
|
||||
|
||||
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '30d' })
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
||||
res.json({ success: true })
|
||||
}
|
||||
|
||||
// POST /api/auth/login
|
||||
export function authLogin(req: Request, res: Response): void {
|
||||
const auth = readAuth()
|
||||
if (!auth) {
|
||||
res.status(400).json({ error: 'Aucun compte configuré' })
|
||||
return
|
||||
}
|
||||
|
||||
const { username, password } = req.body
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username et password requis' })
|
||||
return
|
||||
}
|
||||
|
||||
if (username !== auth.username || !bcrypt.compareSync(password, auth.passwordHash)) {
|
||||
res.status(401).json({ error: 'Identifiants incorrects' })
|
||||
return
|
||||
}
|
||||
|
||||
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '30d' })
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
||||
res.json({ success: true })
|
||||
}
|
||||
|
||||
// POST /api/auth/logout
|
||||
export function authLogout(_req: Request, res: Response): void {
|
||||
res.clearCookie('fankarr_token')
|
||||
res.json({ success: true })
|
||||
}
|
||||
|
||||
// Middleware protection des routes
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const token = req.cookies?.fankarr_token
|
||||
if (!token) {
|
||||
res.status(401).json({ error: 'Non authentifié' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
jwt.verify(token, JWT_SECRET)
|
||||
next()
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Token invalide' })
|
||||
}
|
||||
}
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
import express from 'express'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { authStatus, authSetup, authLogin, authLogout, requireAuth } from './auth.js'
|
||||
import { readSettings, writeSettings } from './settings.js'
|
||||
import {
|
||||
registerDriver, getDriver, getAvailableClients,
|
||||
listClients, addClient, removeClient, getClient,
|
||||
sanitizeClient, dispatchDownload, dispatchList
|
||||
} from './torrent-clients'
|
||||
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
||||
import { organizeTorrent, autoOrganizeAll } from './organize.js'
|
||||
|
||||
registerDriver(qbittorrentDriver)
|
||||
|
||||
const app = express()
|
||||
const PORT = 3001
|
||||
const FANKAI_API = 'https://metadata.fankai.fr'
|
||||
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
|
||||
const GITHUB_RAW_URL = process.env.GITHUB_RAW_URL
|
||||
?? 'https://raw.githubusercontent.com/CHANGE_ME/fankarr-scraper/main/data/torrent_final.json'
|
||||
|
||||
app.use(express.json())
|
||||
app.use(cookieParser())
|
||||
|
||||
// ── Static frontend (prod) ─────────────────────────────────────
|
||||
const PUBLIC_PATH = path.join(process.cwd(), 'public')
|
||||
if (fs.existsSync(PUBLIC_PATH)) {
|
||||
app.use(express.static(PUBLIC_PATH))
|
||||
}
|
||||
|
||||
// ── Auth ───────────────────────────────────────────────────────
|
||||
app.get('/api/auth/status', authStatus)
|
||||
app.post('/api/auth/setup', authSetup)
|
||||
app.post('/api/auth/login', authLogin)
|
||||
app.post('/api/auth/logout', authLogout)
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────
|
||||
app.get('/api/settings', requireAuth, (_req, res) => {
|
||||
res.json(readSettings())
|
||||
})
|
||||
app.post('/api/settings', requireAuth, (req, res) => {
|
||||
const { mediaPath, completePath, organizeMode, category } = req.body
|
||||
res.json(writeSettings({ mediaPath, completePath, organizeMode, category }))
|
||||
})
|
||||
|
||||
// ── Torrent clients ────────────────────────────────────────────
|
||||
app.get('/api/torrent-clients/available', requireAuth, (_req, res) => {
|
||||
res.json(getAvailableClients())
|
||||
})
|
||||
app.get('/api/torrent-clients', requireAuth, (_req, res) => {
|
||||
res.json(listClients().map(c => sanitizeClient(c)))
|
||||
})
|
||||
app.post('/api/torrent-clients', requireAuth, (req, res) => {
|
||||
const { name, type, config } = req.body
|
||||
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
|
||||
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||
res.json(sanitizeClient(addClient(name, type, config)))
|
||||
})
|
||||
app.delete('/api/torrent-clients/:uuid', requireAuth, (req, res) => {
|
||||
const ok = removeClient(String(req.params.uuid))
|
||||
if (!ok) res.status(404).json({ error: 'Client introuvable' })
|
||||
else res.json({ ok: true })
|
||||
})
|
||||
app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => {
|
||||
const { type, config } = req.body
|
||||
if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return }
|
||||
const driver = getDriver(type)
|
||||
if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||
res.json(await driver.test(config))
|
||||
})
|
||||
app.post('/api/torrent-clients/:uuid/test', requireAuth, async (req, res) => {
|
||||
const client = getClient(String(req.params.uuid))
|
||||
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
|
||||
res.json(await driver.test(client.config))
|
||||
})
|
||||
app.get('/api/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res) => {
|
||||
const client = getClient(String(req.params.uuid))
|
||||
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
|
||||
res.json(await driver.healthcheck(client.config))
|
||||
})
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
function readTorrents(): any[] {
|
||||
try {
|
||||
if (!fs.existsSync(TORRENTS_PATH)) return []
|
||||
return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
|
||||
} catch { return [] }
|
||||
}
|
||||
async function fankaiGet(endpoint: string): Promise<any> {
|
||||
const res = await fetch(`${FANKAI_API}${endpoint}`)
|
||||
if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
|
||||
return res.json()
|
||||
}
|
||||
function indexBySerie(torrents: any[]): Map<number, any[]> {
|
||||
const map = new Map<number, any[]>()
|
||||
for (const t of torrents) {
|
||||
if (!t.serie_id) continue
|
||||
if (!map.has(t.serie_id)) map.set(t.serie_id, [])
|
||||
map.get(t.serie_id)!.push(t)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ── Series ─────────────────────────────────────────────────────
|
||||
app.get('/api/series', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const [apiData, torrents] = await Promise.all([fankaiGet('/series'), Promise.resolve(readTorrents())])
|
||||
const byId = indexBySerie(torrents)
|
||||
const seriesList = Array.isArray(apiData) ? apiData : (apiData.series ?? [])
|
||||
res.json({ series: seriesList.map((serie: any) => ({
|
||||
...serie,
|
||||
torrent_count: byId.get(serie.id)?.length ?? 0,
|
||||
has_torrents : byId.has(serie.id)
|
||||
}))})
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const [serie, seasonsData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`)])
|
||||
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
|
||||
const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => {
|
||||
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
|
||||
return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) }
|
||||
}))
|
||||
|
||||
const torrents = readTorrents().filter(t => t.serie_id === id)
|
||||
const allSeasonIds = new Set<number>(seasonsWithEpisodes.filter((s: any) => s.season_number !== 0).map((s: any) => s.id))
|
||||
const availableEpisodeIds = new Set<number>()
|
||||
const episodeTorrentMap: Record<number, any> = {}
|
||||
const seasonTorrentMap : Record<number, any> = {}
|
||||
const integraleTorrents: any[] = []
|
||||
const packEpisodesTorrents: any[] = []
|
||||
|
||||
for (const t of torrents) {
|
||||
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: t.type, raw: t.raw }
|
||||
for (const ep of t.resolved_episodes ?? []) {
|
||||
availableEpisodeIds.add(ep.episode_id)
|
||||
if (t.type === 'episode') episodeTorrentMap[ep.episode_id] = ref
|
||||
}
|
||||
if (t.type === 'pack_saison') {
|
||||
const rs: any[] = t.resolved_seasons ?? []
|
||||
if (rs.length > 1) {
|
||||
packEpisodesTorrents.push(t)
|
||||
} else {
|
||||
const sids = rs.length > 0 ? rs.map((r: any) => r.season_id) : [t.season_id]
|
||||
for (const sid of sids) { if (sid) seasonTorrentMap[sid] = ref }
|
||||
}
|
||||
} else if (t.type === 'pack_integrale') {
|
||||
integraleTorrents.push(t)
|
||||
} else if (t.type === 'pack_episodes') {
|
||||
packEpisodesTorrents.push(t)
|
||||
}
|
||||
}
|
||||
|
||||
let promotedIntegrale: any = null
|
||||
if (integraleTorrents.length === 0 && allSeasonIds.size > 0) {
|
||||
for (const t of torrents.filter(t => t.type === 'pack_saison')) {
|
||||
const rs: any[] = t.resolved_seasons ?? []
|
||||
const tIds = new Set<number>(rs.length > 0 ? rs.map((r: any) => r.season_id) : t.season_id ? [t.season_id] : [])
|
||||
if ([...allSeasonIds].every(sid => tIds.has(sid))) { promotedIntegrale = t; break }
|
||||
}
|
||||
}
|
||||
|
||||
if (integraleTorrents.length > 0 || promotedIntegrale) {
|
||||
for (const key of Object.keys(seasonTorrentMap)) delete seasonTorrentMap[Number(key)]
|
||||
if (promotedIntegrale) {
|
||||
const idx = packEpisodesTorrents.indexOf(promotedIntegrale)
|
||||
if (idx !== -1) packEpisodesTorrents.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function packEpisodesLabel(t: any): string {
|
||||
const eps: any[] = t.resolved_episodes ?? []
|
||||
if (!eps.length) return t.raw
|
||||
const bySeason = new Map<number, number[]>()
|
||||
for (const ep of eps) {
|
||||
const sn = ep.season_number ?? 0
|
||||
if (!bySeason.has(sn)) bySeason.set(sn, [])
|
||||
bySeason.get(sn)!.push(ep.episode_number)
|
||||
}
|
||||
return [...bySeason.entries()].sort(([a], [b]) => a - b).map(([sn, nums]) => {
|
||||
const s = nums.sort((a, b) => a - b)
|
||||
const label = sn === 0 ? 'S0' : `S${sn}`
|
||||
return s[0] === s[s.length-1] ? `${label} Ep ${s[0]}` : `${label} Eps ${s[0]}-${s[s.length-1]}`
|
||||
}).join(' · ')
|
||||
}
|
||||
|
||||
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => ({
|
||||
...season,
|
||||
torrent: seasonTorrentMap[season.id] ?? null,
|
||||
episodes: season.episodes.map((ep: any) => ({
|
||||
...ep,
|
||||
available: availableEpisodeIds.has(ep.id),
|
||||
torrent : episodeTorrentMap[ep.id] ?? null
|
||||
}))
|
||||
}))
|
||||
|
||||
const heroBundles: any[] = [
|
||||
...integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw })),
|
||||
...(promotedIntegrale ? [{ label: 'Intégrale', torrent_url: promotedIntegrale.torrent_url, magnet: promotedIntegrale.magnet, raw: promotedIntegrale.raw }] : []),
|
||||
...packEpisodesTorrents.map(t => ({ label: packEpisodesLabel(t), torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw }))
|
||||
]
|
||||
|
||||
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: heroBundles })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Download ───────────────────────────────────────────────────
|
||||
app.post('/api/download', requireAuth, async (req, res) => {
|
||||
const url = req.body.torrent_url ?? req.body.magnet
|
||||
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
|
||||
const results = await dispatchDownload(url)
|
||||
res.status(results.every(r => r.ok) ? 200 : 207).json({ results })
|
||||
})
|
||||
|
||||
// ── Downloads — liste torrents Fankai ──────────────────────────
|
||||
app.get('/api/downloads', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const torrents = await dispatchList(category ?? 'fankai')
|
||||
res.json(torrents)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Organize ───────────────────────────────────────────────────
|
||||
app.post('/api/organize', requireAuth, async (req, res) => {
|
||||
const { hash, name, save_path } = req.body
|
||||
if (!hash || !name || !save_path) {
|
||||
res.status(400).json({ error: 'hash, name et save_path requis' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await organizeTorrent(hash, name, save_path)
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Torrents status ────────────────────────────────────────────
|
||||
app.get('/api/torrents/status', requireAuth, (_req, res) => {
|
||||
try {
|
||||
if (!fs.existsSync(TORRENTS_PATH)) { res.json({ exists: false, count: 0, empty: true }); return }
|
||||
const raw = fs.readFileSync(TORRENTS_PATH, 'utf-8').trim()
|
||||
if (!raw || raw === '[]') { res.json({ exists: true, count: 0, empty: true }); return }
|
||||
const data = JSON.parse(raw)
|
||||
const count = Array.isArray(data) ? data.length : 0
|
||||
res.json({ exists: true, count, empty: count === 0 })
|
||||
} catch { res.json({ exists: false, count: 0, empty: true }) }
|
||||
})
|
||||
|
||||
// ── Update torrent_final.json ──────────────────────────────────
|
||||
app.post('/api/update', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const response = await fetch(GITHUB_RAW_URL)
|
||||
if (!response.ok) throw new Error(`GitHub a répondu ${response.status}`)
|
||||
const data = await response.json() as unknown[]
|
||||
if (!Array.isArray(data)) throw new Error('Format invalide')
|
||||
fs.mkdirSync(path.dirname(TORRENTS_PATH), { recursive: true })
|
||||
fs.writeFileSync(TORRENTS_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
res.json({ ok: true, count: data.length })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── SPA fallback (prod) — doit être après toutes les routes API ─
|
||||
if (fs.existsSync(PUBLIC_PATH)) {
|
||||
app.get('*path', (_req, res) => {
|
||||
res.sendFile(path.join(PUBLIC_PATH, 'index.html'))
|
||||
})
|
||||
}
|
||||
|
||||
// ── Start ──────────────────────────────────────────────────────
|
||||
app.listen(PORT, () => {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
|
||||
const count = Array.isArray(data) ? data.length : 0
|
||||
if (count === 0) console.warn('[fankarr] ⚠ torrent_final.json est vide')
|
||||
else console.log(`[fankarr] ${count} torrents chargés`)
|
||||
} catch { console.warn('[fankarr] ⚠ torrent_final.json introuvable ou illisible') }
|
||||
console.log(`[fankarr] Serveur sur http://localhost:${PORT}`)
|
||||
|
||||
// Auto-organise toutes les 30s
|
||||
const autoOrganize = async () => {
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
await autoOrganizeAll(() => dispatchList(category ?? 'fankai'))
|
||||
} catch (err) {
|
||||
console.error('[organize] Erreur auto-organise:', err)
|
||||
}
|
||||
}
|
||||
// Premier run 30s après démarrage, puis toutes les 30s
|
||||
setTimeout(() => {
|
||||
autoOrganize()
|
||||
setInterval(autoOrganize, 30_000)
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Organizer
|
||||
* =========
|
||||
* Déplace ou hardlinke les fichiers téléchargés vers la médiathèque Jellyfin.
|
||||
* Incrémental : skip les fichiers déjà organisés.
|
||||
*
|
||||
* Structure dest :
|
||||
* mediaPath / serie_title / Saison 001 / filename.mkv
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { readSettings } from './settings.js'
|
||||
|
||||
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
|
||||
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
|
||||
|
||||
// ─── Organized log ────────────────────────────────────────────
|
||||
|
||||
function loadOrganized(): Record<string, Record<string, string>> {
|
||||
try {
|
||||
if (!fs.existsSync(ORGANIZED_PATH)) return {}
|
||||
return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||
} catch { return {} }
|
||||
}
|
||||
|
||||
function saveOrganized(data: Record<string, Record<string, string>>) {
|
||||
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
function markOrganized(hash: string, filename: string) {
|
||||
const data = loadOrganized()
|
||||
if (!data[hash]) data[hash] = {}
|
||||
data[hash][filename] = new Date().toISOString()
|
||||
saveOrganized(data)
|
||||
}
|
||||
|
||||
function isOrganized(hash: string, filename: string): boolean {
|
||||
const data = loadOrganized()
|
||||
return !!data[hash]?.[filename]
|
||||
}
|
||||
|
||||
// ─── Torrent lookup ───────────────────────────────────────────
|
||||
|
||||
function readTorrents(): any[] {
|
||||
try {
|
||||
if (!fs.existsSync(TORRENTS_PATH)) return []
|
||||
return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
function findTorrentByName(name: string): any | null {
|
||||
const torrents = readTorrents()
|
||||
return torrents.find(t => t.raw === name) ?? null
|
||||
}
|
||||
|
||||
// ─── Filesystem ops ───────────────────────────────────────────
|
||||
|
||||
function ensureDir(dir: string) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
function seasonFolder(n: number): string {
|
||||
return `Saison ${String(n).padStart(3, '0')}`
|
||||
}
|
||||
|
||||
function tryHardlink(src: string, dest: string): boolean {
|
||||
try {
|
||||
fs.linkSync(src, dest)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function copyFile(src: string, dest: string) {
|
||||
fs.copyFileSync(src, dest)
|
||||
}
|
||||
|
||||
// ─── Résultat d'organisation ──────────────────────────────────
|
||||
|
||||
export interface OrganizeResult {
|
||||
total : number
|
||||
skipped : number
|
||||
done : number
|
||||
errors : { file: string; error: string }[]
|
||||
}
|
||||
|
||||
// ─── Organise un torrent ──────────────────────────────────────
|
||||
|
||||
export async function organizeTorrent(
|
||||
hash : string,
|
||||
name : string, // nom du dossier torrent dans completePath
|
||||
savePath: string // save_path retourné par qBittorrent
|
||||
): Promise<OrganizeResult> {
|
||||
const settings = readSettings()
|
||||
const { mediaPath, completePath, organizeMode } = settings
|
||||
|
||||
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
|
||||
|
||||
// Trouver le torrent dans torrent_final.json
|
||||
const torrent = findTorrentByName(name)
|
||||
if (!torrent) {
|
||||
throw new Error(`Torrent introuvable dans torrent_final.json : "${name}"`)
|
||||
}
|
||||
|
||||
const serieTitle = torrent.serie_title || torrent.show_title || name
|
||||
const torrentFiles: any[] = torrent.torrent_files ?? []
|
||||
|
||||
if (torrentFiles.length === 0) {
|
||||
throw new Error(`Aucun fichier trouvé dans le torrent "${name}"`)
|
||||
}
|
||||
|
||||
// Résoudre season_number par episode_id
|
||||
const epSeasonMap = new Map<number, number>()
|
||||
for (const ep of torrent.resolved_episodes ?? []) {
|
||||
if (ep.filename) {
|
||||
epSeasonMap.set(ep.episode_number, ep.season_number)
|
||||
}
|
||||
}
|
||||
// Map filename → season_number
|
||||
const filenameSeasonMap = new Map<string, number>()
|
||||
for (const ep of torrent.resolved_episodes ?? []) {
|
||||
if (ep.filename && ep.season_number !== undefined) {
|
||||
filenameSeasonMap.set(ep.filename, ep.season_number)
|
||||
}
|
||||
}
|
||||
|
||||
result.total = torrentFiles.length
|
||||
|
||||
for (const tf of torrentFiles) {
|
||||
const filename = tf.filename
|
||||
const filePath: string[] = tf.path // ex: ["Saga 1 - ...", "file.mkv"]
|
||||
|
||||
// Skip si déjà organisé
|
||||
if (isOrganized(hash, filename)) {
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Construire le chemin source
|
||||
// save_path peut déjà contenir le nom du dossier ou pas selon le client
|
||||
const torrentDir = path.join(savePath, name)
|
||||
const torrentDirAlt = savePath // au cas où save_path inclut déjà le nom
|
||||
|
||||
// Chemin source = dossier torrent + sous-dossiers + fichier
|
||||
const subPath = filePath.length > 1
|
||||
? path.join(...filePath.slice(0, -1), filename)
|
||||
: filename
|
||||
|
||||
let src = path.join(torrentDir, subPath)
|
||||
if (!fs.existsSync(src)) {
|
||||
// Fallback: save_path sans le nom du torrent
|
||||
src = path.join(torrentDirAlt, subPath)
|
||||
}
|
||||
if (!fs.existsSync(src)) {
|
||||
// Fallback: fichier directement dans save_path/name
|
||||
src = path.join(torrentDir, filename)
|
||||
}
|
||||
if (!fs.existsSync(src)) {
|
||||
result.errors.push({ file: filename, error: `Source introuvable : ${src}` })
|
||||
continue
|
||||
}
|
||||
|
||||
// Déterminer la saison
|
||||
let seasonNum = filenameSeasonMap.get(filename)
|
||||
if (seasonNum === undefined) {
|
||||
// Fallback : season_number = 1
|
||||
seasonNum = 1
|
||||
}
|
||||
|
||||
// Construire le chemin destination
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
|
||||
const dest = path.join(destDir, filename)
|
||||
|
||||
// Skip si dest existe déjà
|
||||
if (fs.existsSync(dest)) {
|
||||
markOrganized(hash, filename)
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
ensureDir(destDir)
|
||||
|
||||
if (organizeMode === 'hardlink') {
|
||||
const ok = tryHardlink(src, dest)
|
||||
if (!ok) {
|
||||
// Hardlink impossible (filesystems différents) → copy
|
||||
console.warn(`[organize] hardlink échoué pour ${filename}, fallback copy`)
|
||||
copyFile(src, dest)
|
||||
}
|
||||
} else {
|
||||
// move = copy + delete source
|
||||
copyFile(src, dest)
|
||||
fs.unlinkSync(src)
|
||||
}
|
||||
|
||||
markOrganized(hash, filename)
|
||||
result.done++
|
||||
console.log(`[organize] ${organizeMode} ${filename} → ${dest}`)
|
||||
} catch (err) {
|
||||
result.errors.push({
|
||||
file : filename,
|
||||
error: err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Auto-organise polling ────────────────────────────────────
|
||||
// Appelé depuis index.ts au démarrage, toutes les 30s
|
||||
// Détecte les torrents "seeding" non encore organisés
|
||||
|
||||
export async function autoOrganizeAll(
|
||||
listFn: () => Promise<any[]>
|
||||
): Promise<void> {
|
||||
let torrents: any[]
|
||||
try {
|
||||
torrents = await listFn()
|
||||
} catch { return }
|
||||
|
||||
const organized = loadOrganized()
|
||||
|
||||
for (const t of torrents) {
|
||||
if (t.state !== 'seeding') continue
|
||||
|
||||
// Vérifier si tous les fichiers sont déjà organisés
|
||||
const torrent = findTorrentByName(t.name)
|
||||
if (!torrent) continue
|
||||
|
||||
const files: any[] = torrent.torrent_files ?? []
|
||||
if (files.length === 0) continue
|
||||
|
||||
const allDone = files.every(f => organized[t.hash]?.[f.filename])
|
||||
if (allDone) continue
|
||||
|
||||
console.log(`[organize] Auto-organisation : ${t.name}`)
|
||||
try {
|
||||
const result = await organizeTorrent(t.hash, t.name, t.save_path)
|
||||
console.log(`[organize] ${t.name} → ${result.done} fichiers, ${result.skipped} skippés, ${result.errors.length} erreurs`)
|
||||
} catch (err) {
|
||||
console.error(`[organize] Erreur pour ${t.name}:`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const DATA_PATH = path.join(process.cwd(), 'data', 'settings.json')
|
||||
|
||||
export interface Settings {
|
||||
mediaPath : string // dossier Jellyfin destination ex: /media/Kai
|
||||
completePath : string // dossier qB complétion ex: /downloads/complete
|
||||
organizeMode : 'hardlink' | 'move'
|
||||
category : string // catégorie qBittorrent
|
||||
}
|
||||
|
||||
const defaults: Settings = {
|
||||
mediaPath : '/media/Kai',
|
||||
completePath : '/downloads/complete',
|
||||
organizeMode : 'hardlink',
|
||||
category : 'fankai',
|
||||
}
|
||||
|
||||
export function readSettings(): Settings {
|
||||
try {
|
||||
if (!fs.existsSync(DATA_PATH)) return { ...defaults }
|
||||
return { ...defaults, ...JSON.parse(fs.readFileSync(DATA_PATH, 'utf-8')) }
|
||||
} catch { return { ...defaults } }
|
||||
}
|
||||
|
||||
export function writeSettings(settings: Partial<Settings>): Settings {
|
||||
const current = readSettings()
|
||||
const updated = { ...current, ...settings }
|
||||
fs.mkdirSync(path.dirname(DATA_PATH), { recursive: true })
|
||||
fs.writeFileSync(DATA_PATH, JSON.stringify(updated, null, 2))
|
||||
return updated
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Torrent Client Registry
|
||||
* =======================
|
||||
* Interface commune + registry de tous les clients disponibles.
|
||||
* Chaque client expose son schema de configuration (FieldDef[])
|
||||
* pour que le frontend génère le formulaire dynamiquement.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FieldDef {
|
||||
key : string
|
||||
label : string
|
||||
type : 'text' | 'password' | 'number' | 'url'
|
||||
placeholder?: string
|
||||
required : boolean
|
||||
default? : string | number
|
||||
}
|
||||
|
||||
/** Définition statique d'un type de client (ce qu'il expose au front) */
|
||||
export interface TorrentClientDefinition {
|
||||
id : string // "qbittorrent", "transmission", etc.
|
||||
label : string // "qBittorrent"
|
||||
fields : FieldDef[] // schema du formulaire
|
||||
}
|
||||
|
||||
/** Instance sauvegardée d'un client configuré par l'utilisateur */
|
||||
export interface SavedClient {
|
||||
uuid : string // identifiant unique de cette instance
|
||||
name : string // nom custom donné par l'utilisateur
|
||||
type : string // id du TorrentClientDefinition
|
||||
config: Record<string, string | number>
|
||||
}
|
||||
|
||||
/** État normalisé d'un torrent (commun à tous les clients) */
|
||||
export interface TorrentInfo {
|
||||
hash : string
|
||||
name : string
|
||||
state : 'downloading' | 'seeding' | 'paused' | 'checking' | 'error' | 'unknown'
|
||||
progress : number // 0-100
|
||||
size : number // bytes
|
||||
downloaded: number // bytes
|
||||
speed : number // bytes/s
|
||||
eta : number // secondes, -1 si inconnu
|
||||
save_path : string // dossier de complétion
|
||||
category : string
|
||||
}
|
||||
|
||||
/** Interface que chaque client doit implémenter */
|
||||
export interface TorrentClientDriver {
|
||||
definition : TorrentClientDefinition
|
||||
test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }>
|
||||
healthcheck : (config: Record<string, string | number>) => Promise<{ online: boolean; version?: string }>
|
||||
add : (config: Record<string, string | number>, url: string) => Promise<void>
|
||||
list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]>
|
||||
}
|
||||
|
||||
// ─── Registry ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const drivers = new Map<string, TorrentClientDriver>()
|
||||
|
||||
export function registerDriver(driver: TorrentClientDriver) {
|
||||
drivers.set(driver.definition.id, driver)
|
||||
}
|
||||
|
||||
export function getDriver(type: string): TorrentClientDriver | undefined {
|
||||
return drivers.get(type)
|
||||
}
|
||||
|
||||
export function getAvailableClients(): TorrentClientDefinition[] {
|
||||
return [...drivers.values()].map(d => d.definition)
|
||||
}
|
||||
|
||||
// ─── Persistence (clients enregistrés) ────────────────────────────────────────
|
||||
|
||||
const CLIENTS_PATH = path.join(process.cwd(), 'data', 'torrent_clients.json')
|
||||
|
||||
function loadClients(): SavedClient[] {
|
||||
try {
|
||||
if (!fs.existsSync(CLIENTS_PATH)) return []
|
||||
const raw = fs.readFileSync(CLIENTS_PATH, 'utf-8').trim()
|
||||
if (!raw || raw === '[]') return []
|
||||
return JSON.parse(raw)
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
function saveClients(clients: SavedClient[]) {
|
||||
fs.mkdirSync(path.dirname(CLIENTS_PATH), { recursive: true })
|
||||
fs.writeFileSync(CLIENTS_PATH, JSON.stringify(clients, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export function listClients(): SavedClient[] {
|
||||
return loadClients()
|
||||
}
|
||||
|
||||
export function addClient(name: string, type: string, config: Record<string, string | number>): SavedClient {
|
||||
const clients = loadClients()
|
||||
const client: SavedClient = { uuid: randomUUID(), name, type, config }
|
||||
clients.push(client)
|
||||
saveClients(clients)
|
||||
return client
|
||||
}
|
||||
|
||||
export function removeClient(uuid: string): boolean {
|
||||
const clients = loadClients()
|
||||
const filtered = clients.filter(c => c.uuid !== uuid)
|
||||
if (filtered.length === clients.length) return false
|
||||
saveClients(filtered)
|
||||
return true
|
||||
}
|
||||
|
||||
export function getClient(uuid: string): SavedClient | undefined {
|
||||
return loadClients().find(c => c.uuid === uuid)
|
||||
}
|
||||
|
||||
/** Masque les champs password pour l'envoi au front */
|
||||
export function sanitizeClient(client: SavedClient, definition?: TorrentClientDefinition): SavedClient {
|
||||
if (!definition) definition = getDriver(client.type)?.definition
|
||||
if (!definition) return client
|
||||
const config = { ...client.config }
|
||||
for (const field of definition.fields) {
|
||||
if (field.type === 'password' && config[field.key]) {
|
||||
config[field.key] = '••••••••'
|
||||
}
|
||||
}
|
||||
return { ...client, config }
|
||||
}
|
||||
|
||||
// ─── Dispatch download vers tous les clients ──────────────────────────────────
|
||||
|
||||
export async function dispatchDownload(url: string): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> {
|
||||
const clients = loadClients()
|
||||
const results = []
|
||||
|
||||
for (const client of clients) {
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) {
|
||||
results.push({ uuid: client.uuid, name: client.name, ok: false, error: `Driver "${client.type}" introuvable` })
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await driver.add(client.config, url)
|
||||
results.push({ uuid: client.uuid, name: client.name, ok: true })
|
||||
} catch (err) {
|
||||
results.push({
|
||||
uuid : client.uuid,
|
||||
name : client.name,
|
||||
ok : false,
|
||||
error: err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ─── Dispatch list vers tous les clients ─────────────────────────────────────
|
||||
|
||||
export async function dispatchList(category?: string): Promise<(TorrentInfo & { client_uuid: string; client_name: string })[]> {
|
||||
const clients = loadClients()
|
||||
const results: (TorrentInfo & { client_uuid: string; client_name: string })[] = []
|
||||
|
||||
for (const client of clients) {
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) continue
|
||||
try {
|
||||
const torrents = await driver.list(client.config, category)
|
||||
for (const t of torrents) {
|
||||
results.push({ ...t, client_uuid: client.uuid, client_name: client.name })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[torrent-clients] list() échoué pour ${client.name}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* qBittorrent Driver
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo } from './index.js'
|
||||
|
||||
// Mapping état qBittorrent → état normalisé
|
||||
function mapState(state: string): TorrentInfo['state'] {
|
||||
if (['downloading', 'metaDL', 'queuedDL', 'stalledDL', 'forcedDL'].includes(state)) return 'downloading'
|
||||
if (['uploading', 'queuedUP', 'stalledUP', 'forcedUP'].includes(state)) return 'seeding'
|
||||
if (['pausedDL', 'pausedUP'].includes(state)) return 'paused'
|
||||
if (['checkingDL', 'checkingUP', 'checkingResumeData'].includes(state)) return 'checking'
|
||||
if (state === 'error' || state === 'missingFiles') return 'error'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
async function qbLogin(config: Record<string, string | number>): Promise<string> {
|
||||
const form = new URLSearchParams()
|
||||
form.append('username', String(config.username ?? ''))
|
||||
form.append('password', String(config.password ?? ''))
|
||||
|
||||
const res = await fetch(`${config.url}/api/v2/auth/login`, {
|
||||
method : 'POST',
|
||||
body : form,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
if (text !== 'Ok.') throw new Error(`Login échoué : ${text}`)
|
||||
|
||||
const cookie = res.headers.get('set-cookie') ?? ''
|
||||
const sid = cookie.match(/SID=([^;]+)/)?.[1]
|
||||
if (!sid) throw new Error('Cookie SID introuvable')
|
||||
return sid
|
||||
}
|
||||
|
||||
const QB: TorrentClientDriver = {
|
||||
definition: {
|
||||
id : 'qbittorrent',
|
||||
label : 'qBittorrent',
|
||||
fields: [
|
||||
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
|
||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
||||
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
],
|
||||
},
|
||||
|
||||
async test(config) {
|
||||
try {
|
||||
await qbLogin(config)
|
||||
return { ok: true, message: 'Connexion réussie' }
|
||||
} catch (err) {
|
||||
return { ok: false, message: err instanceof Error ? err.message : 'Erreur inconnue' }
|
||||
}
|
||||
},
|
||||
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const sid = await qbLogin(config)
|
||||
const res = await fetch(`${config.url}/api/v2/app/version`, {
|
||||
headers: { Cookie: `SID=${sid}` }
|
||||
})
|
||||
if (!res.ok) return { online: false }
|
||||
return { online: true, version: (await res.text()).trim() }
|
||||
} catch {
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
|
||||
async list(config, category) {
|
||||
const sid = await qbLogin(config)
|
||||
const params = new URLSearchParams()
|
||||
if (category) params.set('category', category)
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/info?${params}`, {
|
||||
headers: { Cookie: `SID=${sid}` }
|
||||
})
|
||||
if (!res.ok) throw new Error(`qB list échoué: ${res.status}`)
|
||||
const data: any[] = await res.json()
|
||||
return data.map(t => ({
|
||||
hash : t.hash,
|
||||
name : t.name,
|
||||
state : mapState(t.state),
|
||||
progress : Math.round(t.progress * 100),
|
||||
size : t.size,
|
||||
downloaded: t.downloaded,
|
||||
speed : t.dlspeed,
|
||||
eta : t.eta ?? -1,
|
||||
save_path : t.save_path,
|
||||
category : t.category ?? '',
|
||||
} satisfies TorrentInfo))
|
||||
},
|
||||
|
||||
async add(config, url) {
|
||||
const sid = await qbLogin(config)
|
||||
const form = new FormData()
|
||||
form.append('urls', url)
|
||||
if (config.category) form.append('category', String(config.category))
|
||||
if (config.savePath) form.append('savepath', String(config.savePath))
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
||||
method : 'POST',
|
||||
body : form,
|
||||
headers: { Cookie: `SID=${sid}` },
|
||||
})
|
||||
const text = await res.text()
|
||||
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default QB
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#080c10] text-[#c8d8e8] font-mono">
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<keep-alive include="CatalogView">
|
||||
<component :is="Component" :key="route.path" />
|
||||
</keep-alive>
|
||||
</RouterView>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToastContainer from '@/components/ToastContainer.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,80 @@
|
||||
const BASE = '/api'
|
||||
|
||||
export interface Production {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
kaieur: string
|
||||
cover: string | null
|
||||
background: string | null
|
||||
banner: string | null
|
||||
status: string
|
||||
torrents: string | null
|
||||
multi: number
|
||||
link: string
|
||||
episodes_count: number
|
||||
}
|
||||
|
||||
export interface Episode {
|
||||
id: number
|
||||
serie_id: number
|
||||
season_id: number
|
||||
name: string
|
||||
number: number
|
||||
cover: string | null
|
||||
duration: string
|
||||
description: string
|
||||
episodes: string
|
||||
path_jap: string | null
|
||||
path_fr: string | null
|
||||
}
|
||||
|
||||
export interface Season {
|
||||
id: number
|
||||
serie_id: number
|
||||
name: string
|
||||
number: number
|
||||
cover: string | null
|
||||
episodes: Episode[]
|
||||
}
|
||||
|
||||
export interface ProductionDetail extends Production {
|
||||
seasons: Season[]
|
||||
episodes: Episode[]
|
||||
}
|
||||
|
||||
export interface Torrent {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
// Parse "Nom@url|Nom@url" → Torrent[]
|
||||
export function parseTorrents(raw: string | null): Torrent[] {
|
||||
if (!raw) return []
|
||||
return raw.split('|').flatMap(item => {
|
||||
const parts = item.split('@')
|
||||
if (parts.length !== 2) return []
|
||||
return [{ name: parts[0] as string, url: parts[1] as string }]
|
||||
})
|
||||
}
|
||||
|
||||
// Préfixe les images relatives
|
||||
export function imageUrl(path: string | null): string | null {
|
||||
if (!path) return null
|
||||
const encoded = encodeURIComponent(`https://api.fankai.fr${path}`)
|
||||
return `https://fankai.fr/_next/image?url=${encoded}&w=640&q=100`
|
||||
}
|
||||
|
||||
export async function fetchProductions(): Promise<Production[]> {
|
||||
const res = await fetch(`${BASE}/productions`)
|
||||
if (!res.ok) throw new Error('Erreur chargement catalogue')
|
||||
const data = await res.json()
|
||||
return data.pageProps.data
|
||||
}
|
||||
|
||||
export async function fetchProduction(id: number, name: string): Promise<ProductionDetail> {
|
||||
const res = await fetch(`${BASE}/productions/${id}?name=${encodeURIComponent(name)}`)
|
||||
if (!res.ok) throw new Error('Erreur chargement production')
|
||||
const data = await res.json()
|
||||
return data.pageProps.data
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="bg-[#0d1219] flex gap-4 p-3 hover:bg-[#121920] transition-colors">
|
||||
<div class="w-24 flex-shrink-0">
|
||||
<img
|
||||
v-if="ep.cover"
|
||||
:src="imageUrl(ep.cover)"
|
||||
:alt="ep.name"
|
||||
class="w-full aspect-video object-cover"
|
||||
/>
|
||||
<div v-else class="w-full aspect-video bg-[#121920]"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-[10px] text-[#e8513a]">FILM {{ ep.number }}</span>
|
||||
<span class="text-sm font-bold text-white truncate">{{ ep.name }}</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-[#5a7a94] mb-1">
|
||||
Épisodes {{ ep.episodes }} · {{ ep.duration }}
|
||||
</div>
|
||||
<p class="text-xs text-[#5a7a94] leading-relaxed line-clamp-2">{{ ep.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<a
|
||||
v-if="ep.path_fr"
|
||||
:href="`https://fankai.fr/productions/${productionId}?episode=${ep.number}`"
|
||||
target="_blank"
|
||||
title="Streamable sur fankai.fr"
|
||||
class="text-[10px] text-[#3ae87a] border border-[#3ae87a] px-2 py-1 tracking-widest hover:bg-[#3ae87a]/10 transition-colors"
|
||||
@click.stop
|
||||
>
|
||||
▶ STREAM
|
||||
</a>
|
||||
<div
|
||||
v-else
|
||||
class="text-[10px] text-[#1e2d3d] border border-[#1e2d3d] px-2 py-1 tracking-widest"
|
||||
>
|
||||
BIENTÔT
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { imageUrl, type Episode } from '@/api/fankai'
|
||||
|
||||
defineProps<{
|
||||
ep: Episode
|
||||
productionId: number
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2">
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
@click="remove(t.id)"
|
||||
:class="{
|
||||
'border-[#3ae87a] text-[#3ae87a]': t.type === 'success',
|
||||
'border-[#e8513a] text-[#e8513a]': t.type === 'error',
|
||||
'border-[#3a8fe8] text-[#3a8fe8]': t.type === 'info',
|
||||
}"
|
||||
class="bg-[#0d1219] border px-4 py-3 text-xs tracking-wider cursor-pointer flex items-center gap-3 min-w-64"
|
||||
>
|
||||
<span v-if="t.type === 'success'">✓</span>
|
||||
<span v-else-if="t.type === 'error'">✕</span>
|
||||
<span v-else>·</span>
|
||||
{{ t.message }}
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from '@/composables/useToast'
|
||||
const { toasts, remove } = useToast()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active { transition: all 0.2s ease; }
|
||||
.toast-leave-active { transition: all 0.2s ease; }
|
||||
.toast-enter-from { opacity: 0; transform: translateX(20px); }
|
||||
.toast-leave-to { opacity: 0; transform: translateX(20px); }
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
type: 'success' | 'error' | 'info'
|
||||
}
|
||||
|
||||
const toasts = ref<Toast[]>([])
|
||||
let nextId = 0
|
||||
|
||||
export function useToast() {
|
||||
function add(message: string, type: Toast['type'] = 'info', duration = 3000) {
|
||||
const id = nextId++
|
||||
toasts.value.push({ id, message, type })
|
||||
setTimeout(() => remove(id), duration)
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
const idx = toasts.value.findIndex(t => t.id === id)
|
||||
if (idx !== -1) toasts.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
return { toasts, add, remove }
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from '@/App.vue'
|
||||
import '@/assets/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/auth',
|
||||
component: () => import('@/views/AuthView.vue'),
|
||||
meta: { public: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'catalog',
|
||||
component: () => import('@/views/CatalogView.vue')
|
||||
},
|
||||
{
|
||||
path: '/series/:id',
|
||||
component: () => import('@/views/SerieView.vue')
|
||||
},
|
||||
{
|
||||
path: '/downloads',
|
||||
component: () => import('@/views/DownloadsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: () => import('@/views/SettingsView.vue')
|
||||
}
|
||||
],
|
||||
scrollBehavior(to, _from, savedPosition) {
|
||||
if (savedPosition) return savedPosition
|
||||
return { top: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
if (auth.loading) {
|
||||
await auth.checkStatus()
|
||||
}
|
||||
|
||||
if (!to.meta.public && !auth.loggedIn) {
|
||||
return '/auth'
|
||||
}
|
||||
|
||||
if (to.path === '/auth' && auth.loggedIn) {
|
||||
return '/'
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,57 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const loggedIn = ref(false)
|
||||
const setup = ref(false)
|
||||
const loading = ref(true)
|
||||
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/status')
|
||||
const data = await res.json()
|
||||
setup.value = data.setup
|
||||
loggedIn.value = data.loggedIn
|
||||
} catch {
|
||||
loggedIn.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function login(username: string, password: string): Promise<string | null> {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
loggedIn.value = true
|
||||
return null
|
||||
}
|
||||
return data.error
|
||||
}
|
||||
|
||||
async function setupAccount(username: string, password: string): Promise<string | null> {
|
||||
const res = await fetch('/api/auth/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
loggedIn.value = true
|
||||
setup.value = true
|
||||
return null
|
||||
}
|
||||
return data.error
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
loggedIn.value = false
|
||||
}
|
||||
|
||||
return { loggedIn, setup, loading, checkStatus, login, setupAccount, logout }
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
fetchProductions,
|
||||
fetchProduction,
|
||||
type Production,
|
||||
type ProductionDetail
|
||||
} from '@/api/fankai'
|
||||
|
||||
export const useCatalogStore = defineStore('catalog', () => {
|
||||
const productions = ref<Production[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function loadProductions() {
|
||||
if (productions.value.length > 0) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
productions.value = await fetchProductions()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProduction(id: number, name: string): Promise<ProductionDetail> {
|
||||
return await fetchProduction(id, name)
|
||||
}
|
||||
|
||||
return { productions, loading, error, loadProductions, loadProduction }
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface TorrentRef {
|
||||
torrent_raw: string
|
||||
torrent_url: string | null
|
||||
magnet: string | null
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface Episode {
|
||||
id: number
|
||||
episode_number: number
|
||||
display_episode: number | null
|
||||
title: string
|
||||
plot: string | null
|
||||
aired: string | null
|
||||
duration: number | null
|
||||
thumb_image: string | null
|
||||
has_thumbnail: boolean
|
||||
season_id: number
|
||||
available: boolean
|
||||
torrent: TorrentRef | null // non-null seulement si type=episode
|
||||
}
|
||||
|
||||
export interface Season {
|
||||
id: number
|
||||
season_number: number
|
||||
title: string
|
||||
plot: string | null
|
||||
poster_image: string | null
|
||||
episode_count: number
|
||||
premiered: string | null
|
||||
episodes: Episode[]
|
||||
torrent: TorrentRef | null
|
||||
}
|
||||
|
||||
export interface Serie {
|
||||
id: number
|
||||
title: string
|
||||
sort_title: string
|
||||
plot: string | null
|
||||
poster_image: string | null
|
||||
fanart_image: string | null
|
||||
year: number | null
|
||||
status: string | null
|
||||
torrent_count: number
|
||||
has_torrents: boolean
|
||||
}
|
||||
|
||||
export interface SerieDetail {
|
||||
serie: Serie
|
||||
seasons: Season[]
|
||||
torrents_integrale: { raw: string; torrent_url: string | null; magnet: string | null }[]
|
||||
}
|
||||
|
||||
export const useSeriesStore = defineStore('series', () => {
|
||||
const series = ref<Serie[]>([])
|
||||
const currentSerie = ref<SerieDetail | null>(null)
|
||||
const loadingSeries = ref(false)
|
||||
const loadingDetail = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchSeries() {
|
||||
loadingSeries.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await fetch('/api/series')
|
||||
if (!res.ok) throw new Error(`Erreur ${res.status}`)
|
||||
const data = await res.json()
|
||||
series.value = data.series
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
} finally {
|
||||
loadingSeries.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSerieDetail(id: number) {
|
||||
loadingDetail.value = true
|
||||
error.value = null
|
||||
currentSerie.value = null
|
||||
try {
|
||||
const res = await fetch(`/api/series/${id}`)
|
||||
if (!res.ok) throw new Error(`Erreur ${res.status}`)
|
||||
currentSerie.value = await res.json()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
} finally {
|
||||
loadingDetail.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function download(torrent_url: string | null, magnet: string | null): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const res = await fetch('/api/download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ torrent_url, magnet })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) return { success: true }
|
||||
return { success: false, error: data.error }
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Erreur inconnue' }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
series, currentSerie,
|
||||
loadingSeries, loadingDetail, error,
|
||||
fetchSeries, fetchSerieDetail, download
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#080c10] flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-baseline gap-1 mb-8 justify-center">
|
||||
<span class="font-sans text-3xl font-black text-white tracking-tight">Fank</span>
|
||||
<span class="font-sans text-3xl font-black text-[#e8513a] tracking-tight">Arr</span>
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] p-8">
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">
|
||||
{{ isSetup ? 'PREMIÈRE CONNEXION' : 'CONNEXION' }}
|
||||
</div>
|
||||
<h1 class="text-lg font-black text-white font-sans mb-6">
|
||||
{{ isSetup ? 'Créer un compte' : 'Bon retour' }}
|
||||
</h1>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">IDENTIFIANT</label>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">MOT DE PASSE</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isSetup">
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">CONFIRMER</label>
|
||||
<input
|
||||
v-model="confirm"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="text-[#e8513a] text-xs">{{ error }}</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<button
|
||||
@click="submit"
|
||||
:disabled="submitting"
|
||||
class="w-full bg-[#e8513a] text-white py-2.5 text-xs tracking-widest font-bold hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer mt-2"
|
||||
>
|
||||
{{ submitting ? '...' : isSetup ? 'CRÉER LE COMPTE' : 'SE CONNECTER' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const confirm = ref('')
|
||||
const error = ref<string | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
const isSetup = computed(() => !auth.setup)
|
||||
|
||||
async function submit() {
|
||||
error.value = null
|
||||
|
||||
if (!username.value || !password.value) {
|
||||
error.value = 'Tous les champs sont requis'
|
||||
return
|
||||
}
|
||||
|
||||
if (isSetup.value && password.value !== confirm.value) {
|
||||
error.value = 'Les mots de passe ne correspondent pas'
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
|
||||
const err = isSetup.value
|
||||
? await auth.setupAccount(username.value, password.value)
|
||||
: await auth.login(username.value, password.value)
|
||||
|
||||
submitting.value = false
|
||||
|
||||
if (err) {
|
||||
error.value = err
|
||||
return
|
||||
}
|
||||
|
||||
await router.push('/')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="sticky top-0 z-50 bg-zinc-950/90 backdrop-blur border-b border-white/5">
|
||||
<div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||
<span class="text-2xl font-serif tracking-tight">
|
||||
<span class="text-zinc-100">Fan</span><span class="text-orange-500">karr</span>
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<router-link to="/downloads"
|
||||
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition relative">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>
|
||||
</svg>
|
||||
<!-- Badge si téléchargements actifs -->
|
||||
<span v-if="activeDownloads > 0"
|
||||
class="absolute top-1 right-1 w-2 h-2 bg-[#e8513a] rounded-full"/>
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/settings"
|
||||
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</router-link>
|
||||
<button
|
||||
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + Filters -->
|
||||
<div class="max-w-7xl mx-auto px-6 pb-3 flex flex-wrap items-center gap-3">
|
||||
<div class="relative flex-1 min-w-48 max-w-sm">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-600 pointer-events-none" width="15" height="15" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Rechercher…"
|
||||
class="w-full bg-white/5 border border-white/[0.08] rounded-lg pl-9 pr-4 py-2 text-sm placeholder-zinc-600 outline-none focus:border-orange-500/50 transition"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-for="f in filters"
|
||||
:key="f.value"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
|
||||
:class="activeFilter === f.value
|
||||
? 'bg-orange-500 border-orange-500 text-white'
|
||||
: 'bg-transparent border-white/[0.08] text-zinc-500 hover:text-zinc-100 hover:border-white/20'"
|
||||
@click="activeFilter = f.value"
|
||||
>{{ f.label }}</button>
|
||||
</div>
|
||||
<span class="text-xs text-zinc-600 ml-auto">{{ filtered.length }} série{{ filtered.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="max-w-7xl mx-auto px-6 py-8">
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.loadingSeries" class="flex flex-col items-center justify-center gap-4 min-h-96 text-zinc-600">
|
||||
<div class="w-8 h-8 border-2 border-white/10 border-t-orange-500 rounded-full animate-spin" />
|
||||
<p class="text-sm">Chargement du catalogue…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-4 min-h-96 text-orange-400">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>
|
||||
</svg>
|
||||
<p class="text-sm">{{ store.error }}</p>
|
||||
<button class="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm hover:bg-orange-600 transition" @click="store.fetchSeries()">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="filtered.length === 0" class="flex flex-col items-center justify-center gap-3 min-h-96 text-zinc-600">
|
||||
<p class="text-sm">Aucune série trouvée</p>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div v-else class="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-5">
|
||||
<router-link
|
||||
v-for="serie in filtered"
|
||||
:key="serie.id"
|
||||
:to="`/series/${serie.id}`"
|
||||
class="group flex flex-col gap-2 transition-transform duration-200 hover:-translate-y-1"
|
||||
:class="{ 'opacity-40': !serie.has_torrents }"
|
||||
>
|
||||
<!-- Poster -->
|
||||
<div class="relative aspect-[2/3] rounded-xl overflow-hidden bg-zinc-900 border border-white/5">
|
||||
<img
|
||||
v-if="serie.poster_image"
|
||||
:src="serie.poster_image"
|
||||
:alt="serie.title"
|
||||
loading="lazy"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="2" y="2" width="20" height="20" rx="2"/>
|
||||
<path d="m9 9 6 6M15 9l-6 6"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Badge torrents dispo -->
|
||||
<div v-if="serie.has_torrents" class="absolute top-2 right-2 bg-orange-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full leading-none">
|
||||
{{ serie.torrent_count }}
|
||||
</div>
|
||||
|
||||
<!-- Overlay hover -->
|
||||
<div class="absolute inset-0 bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<span class="text-xs text-white border border-white/40 px-3 py-1.5 rounded-md">Voir</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div>
|
||||
<p class="text-xs font-medium truncate leading-snug">{{ serie.title }}</p>
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<span v-if="serie.year" class="text-[11px] text-zinc-600">{{ serie.year }}</span>
|
||||
<span
|
||||
v-if="serie.status"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded"
|
||||
:class="serie.status.toLowerCase() === 'continuing'
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-white/5 text-zinc-600'"
|
||||
>{{ serie.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
defineOptions({ name: 'CatalogView' })
|
||||
|
||||
const store = useSeriesStore()
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const search = ref('')
|
||||
const activeFilter = ref('all')
|
||||
|
||||
const filters = [
|
||||
{ label: 'Toutes', value: 'all' },
|
||||
{ label: 'Disponibles', value: 'available' },
|
||||
{ label: 'Sans torrent', value: 'unavailable' },
|
||||
]
|
||||
|
||||
const filtered = computed(() => {
|
||||
let list = store.series
|
||||
if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents)
|
||||
if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents)
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase()
|
||||
list = list.filter(s => s.title.toLowerCase().includes(q))
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
router.push('/auth')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (store.series.length === 0) store.fetchSeries()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<button @click="$router.push('/')"
|
||||
class="text-[#5a7a94] text-xs tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer">
|
||||
← CATALOGUE
|
||||
</button>
|
||||
<h1 class="text-xl font-black text-white font-sans">Téléchargements</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Indicateur de polling -->
|
||||
<div class="flex items-center gap-2 text-[10px] text-[#5a7a94] tracking-widest">
|
||||
<div class="w-1.5 h-1.5 rounded-full" :class="polling ? 'bg-green-500 animate-pulse' : 'bg-[#1e2d3d]'"/>
|
||||
{{ polling ? 'EN DIRECT' : 'PAUSE' }}
|
||||
</div>
|
||||
<button @click="togglePolling"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
|
||||
{{ polling ? 'PAUSE' : 'REPRENDRE' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Résumé -->
|
||||
<div class="grid grid-cols-4 gap-3 mb-6">
|
||||
<div v-for="stat in stats" :key="stat.label" class="bg-[#0d1219] border border-[#1e2d3d] p-4">
|
||||
<div class="text-[10px] text-[#5a7a94] tracking-widest mb-1">{{ stat.label }}</div>
|
||||
<div class="text-lg font-bold text-white">{{ stat.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pas de clients -->
|
||||
<div v-if="noClients" class="flex flex-col items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
|
||||
<p class="text-sm">Aucun client torrent configuré</p>
|
||||
<router-link to="/settings"
|
||||
class="text-[10px] tracking-widest text-[#e8513a] hover:opacity-75 transition-opacity">
|
||||
CONFIGURER →
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="loading && torrents.length === 0" class="flex items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
|
||||
<div class="w-5 h-5 border-2 border-white/10 border-t-[#e8513a] rounded-full animate-spin"/>
|
||||
<span class="text-sm">Chargement...</span>
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else-if="!loading && torrents.length === 0" class="flex flex-col items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
|
||||
<p class="text-sm">Aucun téléchargement en cours</p>
|
||||
<p class="text-[11px]">Les torrents lancés depuis Fankarr apparaîtront ici</p>
|
||||
</div>
|
||||
|
||||
<!-- Liste torrents -->
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div v-for="t in torrents" :key="t.hash"
|
||||
class="bg-[#0d1219] border border-[#1e2d3d] p-4">
|
||||
|
||||
<!-- Ligne principale -->
|
||||
<div class="flex items-start justify-between gap-4 mb-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-white font-medium truncate">{{ t.name }}</p>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<!-- Badge état -->
|
||||
<span class="text-[10px] tracking-widest px-2 py-0.5 border"
|
||||
:class="stateBadge(t.state).class">
|
||||
{{ stateBadge(t.state).label }}
|
||||
</span>
|
||||
<!-- Client -->
|
||||
<span class="text-[10px] text-[#5a7a94]">{{ t.client_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Infos droite -->
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-sm font-bold text-white">{{ t.progress }}%</p>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-0.5">
|
||||
{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre de progression -->
|
||||
<div class="h-1 bg-[#1e2d3d] rounded-full overflow-hidden mb-2">
|
||||
<div class="h-full rounded-full transition-all duration-500"
|
||||
:class="t.state === 'seeding' ? 'bg-green-500' :
|
||||
t.state === 'error' ? 'bg-red-500' :
|
||||
t.state === 'paused' ? 'bg-[#5a7a94]' : 'bg-[#e8513a]'"
|
||||
:style="{ width: `${t.progress}%` }"/>
|
||||
</div>
|
||||
|
||||
<!-- Infos bas -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 text-[10px] text-[#5a7a94]">
|
||||
<span v-if="t.state === 'downloading'">
|
||||
↓ {{ formatSpeed(t.speed) }}
|
||||
</span>
|
||||
<span v-if="t.state === 'downloading' && t.eta > 0">
|
||||
ETA {{ formatEta(t.eta) }}
|
||||
</span>
|
||||
<span v-if="t.state === 'seeding'" class="text-green-500">
|
||||
✓ Complété
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Bouton organiser si complété -->
|
||||
<button v-if="t.state === 'seeding'"
|
||||
@click="organize(t)"
|
||||
:disabled="organizing[t.hash]"
|
||||
class="text-[10px] tracking-widest px-3 py-1 border transition-colors cursor-pointer"
|
||||
:class="organized[t.hash]
|
||||
? 'border-green-500/40 text-green-500'
|
||||
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#e8513a] hover:text-[#e8513a]'">
|
||||
{{ organized[t.hash] ? 'ORGANISÉ ✓' : organizing[t.hash] ? '...' : 'ORGANISER' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const torrents = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const polling = ref(true)
|
||||
const noClients = ref(false)
|
||||
const organizing = ref<Record<string, boolean>>({})
|
||||
const organized = ref<Record<string, boolean>>({})
|
||||
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// ── Stats ──────────────────────────────────────────────────────
|
||||
const stats = computed(() => [
|
||||
{ label: 'EN COURS', value: torrents.value.filter(t => t.state === 'downloading').length },
|
||||
{ label: 'COMPLÉTÉS', value: torrents.value.filter(t => t.state === 'seeding').length },
|
||||
{ label: 'EN PAUSE', value: torrents.value.filter(t => t.state === 'paused').length },
|
||||
{ label: 'ERREURS', value: torrents.value.filter(t => t.state === 'error').length },
|
||||
])
|
||||
|
||||
// ── Fetch ──────────────────────────────────────────────────────
|
||||
async function fetchTorrents() {
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
if (res.status === 503) { noClients.value = true; return }
|
||||
if (!res.ok) return
|
||||
torrents.value = await res.json()
|
||||
noClients.value = false
|
||||
} catch {
|
||||
// silencieux en polling
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
fetchTorrents()
|
||||
pollInterval = setInterval(fetchTorrents, 3000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval) { clearInterval(pollInterval); pollInterval = null }
|
||||
}
|
||||
|
||||
function togglePolling() {
|
||||
polling.value = !polling.value
|
||||
if (polling.value) startPolling()
|
||||
else stopPolling()
|
||||
}
|
||||
|
||||
onMounted(startPolling)
|
||||
onUnmounted(stopPolling)
|
||||
|
||||
// ── Organiser ──────────────────────────────────────────────────
|
||||
async function organize(torrent: any) {
|
||||
organizing.value[torrent.hash] = true
|
||||
try {
|
||||
const res = await fetch('/api/organize', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name })
|
||||
})
|
||||
if (res.ok) {
|
||||
organized.value[torrent.hash] = true
|
||||
toast(`${torrent.name} organisé ✓`, 'success')
|
||||
} else {
|
||||
const { error } = await res.json()
|
||||
toast(error ?? 'Erreur lors de l\'organisation', 'error')
|
||||
}
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
organizing.value[torrent.hash] = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers affichage ──────────────────────────────────────────
|
||||
function stateBadge(state: string) {
|
||||
const map: Record<string, { label: string; class: string }> = {
|
||||
downloading: { label: 'TÉLÉCHARGEMENT', class: 'border-[#e8513a]/40 text-[#e8513a]' },
|
||||
seeding : { label: 'COMPLÉTÉ', class: 'border-green-500/40 text-green-500' },
|
||||
paused : { label: 'EN PAUSE', class: 'border-[#5a7a94]/40 text-[#5a7a94]' },
|
||||
checking : { label: 'VÉRIFICATION', class: 'border-yellow-500/40 text-yellow-500' },
|
||||
error : { label: 'ERREUR', class: 'border-red-500/40 text-red-500' },
|
||||
unknown : { label: 'INCONNU', class: 'border-[#1e2d3d] text-[#5a7a94]' },
|
||||
}
|
||||
return map[state] ?? map.unknown
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
function formatSpeed(bps: number): string {
|
||||
return `${formatSize(bps)}/s`
|
||||
}
|
||||
|
||||
function formatEta(seconds: number): string {
|
||||
if (seconds < 0 || seconds > 86400 * 7) return '∞'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = seconds % 60
|
||||
if (h > 0) return `${h}h${m.toString().padStart(2, '0')}m`
|
||||
if (m > 0) return `${m}m${s.toString().padStart(2, '0')}s`
|
||||
return `${s}s`
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="max-w-5xl mx-auto px-8 py-8">
|
||||
<!-- Back -->
|
||||
<button
|
||||
@click="$router.push('/')"
|
||||
class="text-[#5a7a94] text-xs tracking-widest hover:text-[#e8513a] transition-colors mb-8 cursor-pointer"
|
||||
>
|
||||
← CATALOGUE
|
||||
</button>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="text-[#5a7a94] text-sm tracking-widest py-20 text-center">
|
||||
CHARGEMENT...
|
||||
</div>
|
||||
|
||||
<template v-else-if="production">
|
||||
<!-- Hero -->
|
||||
<div class="flex gap-8 mb-10">
|
||||
<!-- Poster -->
|
||||
<div class="w-40 flex-shrink-0">
|
||||
<img
|
||||
v-if="production.cover"
|
||||
:src="imageUrl(production.cover)"
|
||||
:alt="production.name"
|
||||
class="w-full object-cover"
|
||||
/>
|
||||
<div v-else class="w-full aspect-[2/3] bg-[#0d1219] flex items-center justify-center text-5xl">🎌</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1">
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-2">FANKAI</div>
|
||||
<h1 class="text-3xl font-black text-white font-sans mb-4">{{ production.name }}</h1>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<span class="text-[10px] border border-[#1e2d3d] text-[#5a7a94] px-2 py-1 tracking-wider">
|
||||
{{ production.status.toUpperCase() }}
|
||||
</span>
|
||||
<span class="text-[10px] border border-[#3a8fe8] text-[#3a8fe8] bg-[#3a8fe8]/10 px-2 py-1 tracking-wider">
|
||||
{{ production.episodes_count }} FILMS
|
||||
</span>
|
||||
<span class="text-[10px] border border-[#1e2d3d] text-[#5a7a94] px-2 py-1 tracking-wider">
|
||||
PAR {{ production.kaieur.toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-[#5a7a94] leading-relaxed mb-6">{{ production.description }}</p>
|
||||
|
||||
<!-- Torrents globaux -->
|
||||
<div v-if="torrents.length > 0">
|
||||
<div class="text-[10px] text-[#5a7a94] tracking-widest mb-2">TORRENTS</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<a
|
||||
v-for="t in torrents"
|
||||
:key="t.url"
|
||||
:href="t.url"
|
||||
target="_blank"
|
||||
class="flex items-center justify-between bg-[#0d1219] border border-[#1e2d3d] hover:border-[#e8513a] px-4 py-2.5 transition-colors group"
|
||||
>
|
||||
<span class="text-sm text-white">{{ t.name }}</span>
|
||||
<span class="text-[10px] text-[#e8513a] tracking-widest group-hover:text-white transition-colors">
|
||||
NYAA ↗
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lien wiki -->
|
||||
<a
|
||||
v-if="production.link"
|
||||
:href="production.link"
|
||||
target="_blank"
|
||||
class="inline-block mt-4 text-[10px] text-[#5a7a94] hover:text-[#3a8fe8] tracking-widest transition-colors"
|
||||
>
|
||||
GUIDE DES ÉPISODES ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Saisons -->
|
||||
<!-- Saisons -->
|
||||
<div v-if="production.seasons.length > 0" class="space-y-6">
|
||||
<div v-for="season in production.seasons" :key="season.id">
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<div class="text-[10px] text-[#5a7a94] tracking-widest">SAISON {{ season.number }}</div>
|
||||
<div class="text-sm font-bold text-white">{{ season.name }}</div>
|
||||
<div class="flex-1 h-px bg-[#1e2d3d]"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-px bg-[#1e2d3d]">
|
||||
<div v-for="ep in season.episodes" :key="ep.id">
|
||||
<EpisodeRow :ep="ep" :production-id="production.id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Episodes sans saison -->
|
||||
<div v-else-if="production.episodes.length > 0">
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<div class="text-sm font-bold text-white">ÉPISODES</div>
|
||||
<div class="flex-1 h-px bg-[#1e2d3d]"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-px bg-[#1e2d3d]">
|
||||
<div v-for="ep in production.episodes" :key="ep.id">
|
||||
<EpisodeRow :ep="ep" :production-id="production.id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useCatalogStore } from '@/stores/catalog'
|
||||
import { imageUrl, parseTorrents, type ProductionDetail } from '@/api/fankai'
|
||||
import EpisodeRow from "@/components/EpisodeRow.vue";
|
||||
|
||||
const route = useRoute()
|
||||
const store = useCatalogStore()
|
||||
|
||||
const production = ref<ProductionDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const torrents = computed(() => parseTorrents(production.value?.torrents ?? null))
|
||||
|
||||
onMounted(async () => {
|
||||
const id = Number(route.params.id)
|
||||
const name = route.query.name as string ?? ''
|
||||
production.value = await store.loadProduction(id, name)
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
|
||||
<!-- Back -->
|
||||
<div class="max-w-7xl mx-auto px-6 pt-6">
|
||||
<router-link to="/" class="inline-flex items-center gap-2 text-sm text-zinc-500 hover:text-zinc-100 transition">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 5l-7 7 7 7"/>
|
||||
</svg>
|
||||
Catalogue
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.loadingDetail" class="flex flex-col items-center justify-center gap-4 min-h-96 text-zinc-600">
|
||||
<div class="w-8 h-8 border-2 border-white/10 border-t-orange-500 rounded-full animate-spin" />
|
||||
<p class="text-sm">Chargement…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-4 min-h-96 text-orange-400">
|
||||
<p class="text-sm">{{ store.error }}</p>
|
||||
<button class="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm" @click="load">Réessayer</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="data">
|
||||
<!-- Hero -->
|
||||
<div class="relative">
|
||||
<div v-if="data.serie.fanart_image" class="absolute inset-0 h-72 overflow-hidden pointer-events-none">
|
||||
<img :src="data.serie.fanart_image" class="w-full h-full object-cover opacity-20" alt="fanart image"/>
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-950" />
|
||||
</div>
|
||||
|
||||
<div class="relative max-w-7xl mx-auto px-6 pt-8 pb-10 flex gap-8">
|
||||
<!-- Poster -->
|
||||
<div class="shrink-0 w-36 rounded-xl overflow-hidden border border-white/10 shadow-2xl hidden sm:block">
|
||||
<img v-if="data.serie.poster_image" :src="data.serie.poster_image" class="w-full h-full object-cover" alt="poster image"/>
|
||||
<div v-else class="w-full aspect-[2/3] bg-zinc-900 flex items-center justify-center text-zinc-700">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="2" y="2" width="20" height="20" rx="2"/><path d="m9 9 6 6M15 9l-6 6"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex flex-col gap-3 justify-end min-w-0">
|
||||
<h1 class="text-3xl font-bold tracking-tight">{{ data.serie.title }}</h1>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span v-if="data.serie.year" class="text-sm text-zinc-400">{{ data.serie.year }}</span>
|
||||
<span
|
||||
v-if="data.serie.status"
|
||||
class="text-xs px-2 py-0.5 rounded-full font-medium border"
|
||||
:class="data.serie.status.toLowerCase() === 'continuing'
|
||||
? 'bg-green-500/15 text-green-400 border-green-500/20'
|
||||
: 'bg-white/5 text-zinc-500 border-white/10'"
|
||||
>{{ data.serie.status }}</span>
|
||||
</div>
|
||||
<p v-if="data.serie.plot" class="text-sm text-zinc-400 leading-relaxed max-w-2xl line-clamp-3">
|
||||
{{ data.serie.plot }}
|
||||
</p>
|
||||
|
||||
<!-- Boutons hero : intégrale, pack_saison promu, pack_episodes -->
|
||||
<div class="flex flex-wrap gap-2 mt-1">
|
||||
<button
|
||||
v-for="(t, i) in data.torrents_integrale"
|
||||
:key="i"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition"
|
||||
:class="t.label === 'Intégrale'
|
||||
? 'bg-orange-500 hover:bg-orange-600 text-white'
|
||||
: 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border border-orange-500/20'"
|
||||
:disabled="downloading === `integrale-${i}`"
|
||||
@click="download(`integrale-${i}`, t.torrent_url, t.magnet)"
|
||||
>
|
||||
<DownloadIcon :spinning="downloading === `integrale-${i}`" />
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seasons -->
|
||||
<div class="max-w-7xl mx-auto px-6 pb-16 flex flex-col gap-6">
|
||||
<div
|
||||
v-for="season in data.seasons"
|
||||
:key="season.id"
|
||||
class="bg-zinc-900/50 border border-white/5 rounded-2xl overflow-hidden"
|
||||
>
|
||||
<!-- Season header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-white/5">
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="text-zinc-500 hover:text-zinc-100 transition" @click="toggleSeason(season.id)">
|
||||
<svg
|
||||
width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
||||
class="transition-transform duration-200"
|
||||
:class="collapsedSeasons.has(season.id) ? '' : 'rotate-180'"
|
||||
>
|
||||
<path d="M18 15l-6-6-6 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="font-semibold text-sm">
|
||||
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
|
||||
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-zinc-500 font-normal ml-1">
|
||||
— {{ season.title }}
|
||||
</span>
|
||||
</h2>
|
||||
<p class="text-xs text-zinc-600 mt-0.5">
|
||||
{{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
|
||||
· {{ seasonAvailableCount(season) }}/{{ season.episodes.length }} dispo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton pack_saison sur le header -->
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
|
||||
:class="season.torrent
|
||||
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20'
|
||||
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed'"
|
||||
:disabled="!season.torrent || downloading === `season-${season.id}`"
|
||||
@click="season.torrent && download(`season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
|
||||
>
|
||||
<DownloadIcon :spinning="downloading === `season-${season.id}`" :size="12" />
|
||||
Saison entière
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Episodes -->
|
||||
<div v-if="!collapsedSeasons.has(season.id)" class="divide-y divide-white/[0.04]">
|
||||
<div
|
||||
v-for="ep in season.episodes"
|
||||
:key="ep.id"
|
||||
class="flex items-center gap-4 px-5 py-3 hover:bg-white/[0.02] transition"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="shrink-0 w-24 aspect-video rounded-lg overflow-hidden bg-zinc-800 hidden sm:block">
|
||||
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" alt="thumb image"/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700 text-xs font-mono">
|
||||
{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-zinc-600 shrink-0 font-mono">
|
||||
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2,'0')}` }}
|
||||
</span>
|
||||
<span class="text-sm font-medium truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-0.5">
|
||||
<span v-if="ep.aired" class="text-xs text-zinc-600">{{ formatDate(ep.aired) }}</span>
|
||||
<span v-if="ep.duration" class="text-xs text-zinc-600">{{ ep.duration }} min</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton épisode (type episode uniquement) -->
|
||||
<div class="shrink-0">
|
||||
<!-- Badge dispo/indispo basé sur available (episode résolu dans un torrent) -->
|
||||
<span
|
||||
class="inline-block text-[10px] px-2 py-0.5 rounded-full border mr-2"
|
||||
:class="ep.available
|
||||
? 'bg-green-500/10 text-green-400 border-green-500/20'
|
||||
: 'bg-white/5 text-zinc-600 border-white/8'"
|
||||
>
|
||||
{{ ep.available ? 'dispo' : 'indispo' }}
|
||||
</span>
|
||||
|
||||
<!-- Bouton download — visible seulement si type=episode, sinon grisé -->
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
|
||||
:class="ep.torrent
|
||||
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20'
|
||||
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed'"
|
||||
:disabled="!ep.torrent || downloading === `ep-${ep.id}`"
|
||||
@click="ep.torrent && download(`ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)"
|
||||
>
|
||||
<DownloadIcon :spinning="downloading === `ep-${ep.id}`" :size="12" />
|
||||
Ep.
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Toast -->
|
||||
<Transition name="toast">
|
||||
<div
|
||||
v-if="toast"
|
||||
class="fixed bottom-6 right-6 flex items-center gap-3 px-4 py-3 rounded-xl shadow-2xl text-sm font-medium z-50"
|
||||
:class="toast.type === 'success'
|
||||
? 'bg-zinc-800 border border-green-500/30 text-green-400'
|
||||
: 'bg-zinc-800 border border-red-500/30 text-red-400'"
|
||||
>
|
||||
<svg v-if="toast.type === 'success'" width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M20 6 9 17l-5-5"/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>
|
||||
</svg>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, defineComponent, h } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import type { Season } from '@/stores/series'
|
||||
|
||||
// ── Composant icône download ──────────────────────────────────
|
||||
const DownloadIcon = defineComponent({
|
||||
props: { spinning: Boolean, size: { type: Number, default: 14 } },
|
||||
setup(props) {
|
||||
return () => props.spinning
|
||||
? h('div', { class: `w-${props.size === 12 ? '3' : '3.5'} h-${props.size === 12 ? '3' : '3.5'} border border-current/30 border-t-current rounded-full animate-spin` })
|
||||
: h('svg', { width: props.size, height: props.size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2' }, [
|
||||
h('path', { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3' })
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
// ── Setup ─────────────────────────────────────────────────────
|
||||
const route = useRoute()
|
||||
const store = useSeriesStore()
|
||||
|
||||
const collapsedSeasons = ref<Set<number>>(new Set())
|
||||
const downloading = ref<string | null>(null)
|
||||
const toast = ref<{ type: 'success' | 'error'; message: string } | null>(null)
|
||||
|
||||
const data = computed(() => store.currentSerie)
|
||||
|
||||
function seasonAvailableCount(season: Season) {
|
||||
return season.episodes.filter(e => e.available).length
|
||||
}
|
||||
|
||||
function toggleSeason(id: number) {
|
||||
if (collapsedSeasons.value.has(id)) collapsedSeasons.value.delete(id)
|
||||
else collapsedSeasons.value.add(id)
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
if (!d) return ''
|
||||
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function showToast(type: 'success' | 'error', message: string) {
|
||||
toast.value = { type, message }
|
||||
setTimeout(() => { toast.value = null }, 3500)
|
||||
}
|
||||
|
||||
async function download(key: string, torrent_url: string | null, magnet: string | null) {
|
||||
downloading.value = key
|
||||
const result = await store.download(torrent_url, magnet)
|
||||
downloading.value = null
|
||||
if (result.success) showToast('success', 'Ajouté à qBittorrent !')
|
||||
else showToast('error', result.error ?? 'Erreur inconnue')
|
||||
}
|
||||
|
||||
function load() {
|
||||
store.fetchSerieDetail(Number(route.params.id))
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active, .toast-leave-active { transition: all 0.25s ease; }
|
||||
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(6px); }
|
||||
</style>
|
||||
@@ -0,0 +1,401 @@
|
||||
<template>
|
||||
<div class="max-w-2xl mx-auto px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4 mb-8">
|
||||
<button
|
||||
@click="$router.push('/')"
|
||||
class="text-[#5a7a94] text-xs tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer">
|
||||
← CATALOGUE
|
||||
</button>
|
||||
<h1 class="text-xl font-black text-white font-sans">Paramètres</h1>
|
||||
</div>
|
||||
|
||||
<!-- Bannière données manquantes -->
|
||||
<div v-if="torrentsStatus.empty" class="border border-[#e8513a]/40 bg-[#e8513a]/5 p-4 mb-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">DONNÉES MANQUANTES</div>
|
||||
<p class="text-sm text-[#a0b4c4]">
|
||||
{{ torrentsStatus.exists ? 'Le fichier de torrents est vide.' : 'Aucun fichier de torrents trouvé.' }}
|
||||
Téléchargez les données pour utiliser Fankarr.
|
||||
</p>
|
||||
</div>
|
||||
<button @click="update" :disabled="updating"
|
||||
class="bg-[#e8513a] text-white px-5 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer whitespace-nowrap">
|
||||
{{ updating ? 'TÉLÉCHARGEMENT...' : 'TÉLÉCHARGER' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Données torrents -->
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">DONNÉES</div>
|
||||
<h2 class="text-sm font-bold text-white mb-1">Torrents Fankai</h2>
|
||||
<p class="text-[11px] text-[#5a7a94] mb-5">
|
||||
{{ torrentsStatus.empty ? 'Aucune donnée chargée' : `${torrentsStatus.count} torrents chargés` }}
|
||||
</p>
|
||||
<button @click="update" :disabled="updating"
|
||||
class="bg-[#e8513a] text-white px-6 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer">
|
||||
{{ updating ? 'MISE À JOUR...' : 'METTRE À JOUR' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Clients torrent -->
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">TÉLÉCHARGEMENT</div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-sm font-bold text-white">Clients torrent</h2>
|
||||
<button @click="openAddModal"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
|
||||
+ AJOUTER
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="clients.length === 0" class="text-[11px] text-[#5a7a94]">
|
||||
Aucun client configuré
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div v-for="client in clients" :key="client.uuid"
|
||||
class="flex items-center justify-between border border-[#1e2d3d] px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full"
|
||||
:class="healthStatus[client.uuid] === true ? 'bg-green-500' :
|
||||
healthStatus[client.uuid] === false ? 'bg-red-500' : 'bg-[#1e2d3d]'"/>
|
||||
<div>
|
||||
<p class="text-sm text-white font-medium">{{ client.name }}</p>
|
||||
<p class="text-[10px] text-[#5a7a94] tracking-widest mt-0.5">{{ clientLabel(client.type) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="testClient(client.uuid)"
|
||||
class="text-[10px] text-[#5a7a94] tracking-widest hover:text-[#3a8fe8] transition-colors cursor-pointer px-2 py-1">
|
||||
TESTER
|
||||
</button>
|
||||
<button @click="deleteClient(client.uuid)"
|
||||
class="text-[10px] text-[#5a7a94] tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer px-2 py-1">
|
||||
SUPPRIMER
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Médias & Organisation -->
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">MÉDIAS</div>
|
||||
<h2 class="text-sm font-bold text-white mb-6">Organisation</h2>
|
||||
|
||||
<div class="flex flex-col gap-5">
|
||||
|
||||
<!-- Dossier complétion -->
|
||||
<div>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">DOSSIER COMPLÉTION</label>
|
||||
<input v-model="form.completePath"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
|
||||
placeholder="/downloads/complete"/>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier de complétion de qBittorrent (monté dans Docker)</p>
|
||||
</div>
|
||||
|
||||
<!-- Dossier Jellyfin -->
|
||||
<div>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">DOSSIER JELLYFIN</label>
|
||||
<input v-model="form.mediaPath"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
|
||||
placeholder="/media/Kai"/>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier racine de la bibliothèque Fankai dans Jellyfin</p>
|
||||
</div>
|
||||
|
||||
<!-- Mode organisation -->
|
||||
<div>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">MODE</label>
|
||||
<div class="flex gap-2">
|
||||
<button @click="form.organizeMode = 'hardlink'"
|
||||
class="px-5 py-2 text-xs tracking-widest border transition-colors cursor-pointer"
|
||||
:class="form.organizeMode === 'hardlink'
|
||||
? 'border-[#e8513a] text-[#e8513a] bg-[#e8513a]/5'
|
||||
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
|
||||
HARDLINK
|
||||
</button>
|
||||
<button @click="form.organizeMode = 'move'"
|
||||
class="px-5 py-2 text-xs tracking-widest border transition-colors cursor-pointer"
|
||||
:class="form.organizeMode === 'move'
|
||||
? 'border-[#e8513a] text-[#e8513a] bg-[#e8513a]/5'
|
||||
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
|
||||
MOVE
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">
|
||||
<span class="text-white">Hardlink recommandé</span> — le fichier reste dans qBittorrent pour le ratio
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-1">
|
||||
<button type="button" @click="save" :disabled="saving"
|
||||
class="bg-[#e8513a] text-white px-6 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer">
|
||||
{{ saving ? '...' : 'SAUVEGARDER' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compte -->
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6">
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">COMPTE</div>
|
||||
<h2 class="text-sm font-bold text-white mb-6">Session</h2>
|
||||
<button type="button" @click="logout"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-6 py-2.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
|
||||
SE DÉCONNECTER
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal ajout client -->
|
||||
<div v-if="modal.open" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4">
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] w-full max-w-md p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-sm font-bold text-white">Ajouter un client</h3>
|
||||
<button @click="closeModal" class="text-[#5a7a94] hover:text-white transition-colors cursor-pointer">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">NOM</label>
|
||||
<input v-model="modal.name" placeholder="Mon qBittorrent"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">TYPE</label>
|
||||
<select v-model="modal.type" @change="onTypeChange"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors cursor-pointer">
|
||||
<option value="">Choisir un type...</option>
|
||||
<option v-for="def in availableClients" :key="def.id" :value="def.id">{{ def.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="currentDefinition" class="flex flex-col gap-3 mb-6">
|
||||
<div v-for="field in currentDefinition.fields" :key="field.key">
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">{{ field.label.toUpperCase() }}</label>
|
||||
<input
|
||||
v-model="modal.config[field.key]"
|
||||
:type="field.type === 'password' ? 'password' : 'text'"
|
||||
:placeholder="field.placeholder ?? ''"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button @click="saveClient" :disabled="modal.saving || !modal.tested"
|
||||
class="bg-[#e8513a] text-white px-6 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer">
|
||||
{{ modal.saving ? '...' : 'ENREGISTRER' }}
|
||||
</button>
|
||||
<button @click="testNewClient" :disabled="modal.testing || !modal.type"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-6 py-2.5 text-xs tracking-widest hover:border-[#3a8fe8] hover:text-[#3a8fe8] transition-colors disabled:opacity-50 cursor-pointer">
|
||||
{{ modal.testing ? '...' : modal.tested ? 'TESTÉ ✓' : 'TESTER' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!modal.tested && modal.type" class="text-[10px] text-[#5a7a94] mt-3">
|
||||
Testez la connexion avant d'enregistrer
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const { add: toast } = useToast()
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────
|
||||
const form = ref({
|
||||
mediaPath : '/media/Kai',
|
||||
completePath: '/downloads/complete',
|
||||
organizeMode: 'hardlink' as 'hardlink' | 'move',
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const updating = ref(false)
|
||||
const torrentsStatus = ref({ exists: false, count: 0, empty: true })
|
||||
|
||||
const clients = ref<any[]>([])
|
||||
const availableClients = ref<any[]>([])
|
||||
const healthStatus = ref<Record<string, boolean | null>>({})
|
||||
|
||||
const modal = ref({
|
||||
open : false,
|
||||
name : '',
|
||||
type : '',
|
||||
config : {} as Record<string, string>,
|
||||
testing: false,
|
||||
saving : false,
|
||||
tested : false,
|
||||
})
|
||||
|
||||
// ── Computed ───────────────────────────────────────────────────
|
||||
const currentDefinition = computed(() =>
|
||||
availableClients.value.find(d => d.id === modal.value.type) ?? null
|
||||
)
|
||||
|
||||
function clientLabel(type: string): string {
|
||||
return availableClients.value.find(d => d.id === type)?.label ?? type
|
||||
}
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
const [settingsRes, statusRes, clientsRes, availRes] = await Promise.all([
|
||||
fetch('/api/settings', { credentials: 'include' }),
|
||||
fetch('/api/torrents/status', { credentials: 'include' }),
|
||||
fetch('/api/torrent-clients', { credentials: 'include' }),
|
||||
fetch('/api/torrent-clients/available', { credentials: 'include' }),
|
||||
])
|
||||
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
|
||||
if (statusRes.ok) torrentsStatus.value = await statusRes.json()
|
||||
if (clientsRes.ok) clients.value = await clientsRes.json()
|
||||
if (availRes.ok) availableClients.value = await availRes.json()
|
||||
|
||||
for (const c of clients.value) refreshHealth(c.uuid)
|
||||
})
|
||||
|
||||
// ── Healthcheck ────────────────────────────────────────────────
|
||||
async function refreshHealth(uuid: string) {
|
||||
healthStatus.value[uuid] = null
|
||||
try {
|
||||
const res = await fetch(`/api/torrent-clients/${uuid}/healthcheck`, { credentials: 'include' })
|
||||
healthStatus.value[uuid] = res.ok ? (await res.json()).online : false
|
||||
} catch {
|
||||
healthStatus.value[uuid] = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test / delete clients ──────────────────────────────────────
|
||||
async function testClient(uuid: string) {
|
||||
const res = await fetch(`/api/torrent-clients/${uuid}/test`, { method: 'POST', credentials: 'include' })
|
||||
const { ok, message } = await res.json()
|
||||
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
|
||||
healthStatus.value[uuid] = ok
|
||||
}
|
||||
|
||||
async function deleteClient(uuid: string) {
|
||||
const res = await fetch(`/api/torrent-clients/${uuid}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
clients.value = clients.value.filter(c => c.uuid !== uuid)
|
||||
delete healthStatus.value[uuid]
|
||||
toast('Client supprimé', 'success')
|
||||
} else {
|
||||
toast('Erreur lors de la suppression', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modal ──────────────────────────────────────────────────────
|
||||
function openAddModal() {
|
||||
modal.value = { open: true, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.value.open = false
|
||||
}
|
||||
|
||||
function onTypeChange() {
|
||||
modal.value.config = {}
|
||||
modal.value.tested = false
|
||||
const def = currentDefinition.value
|
||||
if (def) {
|
||||
for (const field of def.fields) {
|
||||
if (field.default !== undefined) modal.value.config[field.key] = String(field.default)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function testNewClient() {
|
||||
modal.value.testing = true
|
||||
modal.value.tested = false
|
||||
try {
|
||||
const res = await fetch('/api/torrent-clients/test-config', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ type: modal.value.type, config: modal.value.config })
|
||||
})
|
||||
const { ok, message } = await res.json()
|
||||
modal.value.tested = ok
|
||||
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
modal.value.testing = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveClient() {
|
||||
if (!modal.value.tested) return
|
||||
modal.value.saving = true
|
||||
try {
|
||||
const res = await fetch('/api/torrent-clients', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name : modal.value.name || clientLabel(modal.value.type),
|
||||
type : modal.value.type,
|
||||
config: modal.value.config
|
||||
})
|
||||
})
|
||||
if (res.ok) {
|
||||
const client = await res.json()
|
||||
clients.value.push(client)
|
||||
refreshHealth(client.uuid)
|
||||
closeModal()
|
||||
toast('Client enregistré ✓', 'success')
|
||||
} else {
|
||||
const { error } = await res.json()
|
||||
toast(error ?? "Erreur lors de l'enregistrement", 'error')
|
||||
}
|
||||
} finally {
|
||||
modal.value.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Données torrents ───────────────────────────────────────────
|
||||
async function update() {
|
||||
updating.value = true
|
||||
try {
|
||||
const res = await fetch('/api/update', { method: 'POST', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const { count } = await res.json()
|
||||
torrentsStatus.value = { exists: true, count, empty: count === 0 }
|
||||
toast(`${count} torrents téléchargés ✓`, 'success')
|
||||
} else {
|
||||
const { error } = await res.json()
|
||||
toast(error ?? 'Erreur lors de la mise à jour', 'error')
|
||||
}
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
updating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sauvegarder settings ───────────────────────────────────────
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(form.value)
|
||||
})
|
||||
if (res.ok) toast('Configuration sauvegardée', 'success')
|
||||
else toast('Erreur lors de la sauvegarde', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
await router.push('/auth')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src", "server"]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target" : "ES2022",
|
||||
"module" : "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir" : "./dist/server",
|
||||
"rootDir" : "./server",
|
||||
"strict" : true,
|
||||
"esModuleInterop" : true,
|
||||
"skipLibCheck" : true,
|
||||
"sourceMap" : false
|
||||
},
|
||||
"include": ["server/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001'
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user