Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dd282e6ca | ||
|
|
6150b4aea3 | ||
|
|
c41f8c76cf | ||
|
|
f593288b73 | ||
|
|
a4528aeb0c | ||
|
|
0453ce44fb | ||
|
|
1a6b08757b | ||
|
|
8c2d61de06 | ||
|
|
87fd964374 | ||
|
|
271fc14386 | ||
|
|
176dfed0af | ||
|
|
34057638ca | ||
|
|
bb29f02000 | ||
|
|
392f736925 | ||
|
|
73b401f3e7 | ||
|
|
f7c21f5fd1 | ||
|
|
6078aac714 | ||
|
|
c61bd05f75 | ||
|
|
89f225b980 | ||
|
|
66b0313ae9 | ||
|
|
e02650d1cc | ||
|
|
45bf17c48f | ||
|
|
854839b16a | ||
|
|
08c4122d5e | ||
|
|
ef23a7119e | ||
|
|
21bcb2ba23 | ||
|
|
c94ff7da3f | ||
|
|
71961eb998 | ||
|
|
9b4dc4f782 | ||
|
|
bb6d094383 | ||
|
|
ea7ed597de | ||
|
|
ba736a6fe1 | ||
|
|
ceb18cef3c | ||
|
|
327cd66dcc | ||
|
|
31779d8f7e |
No files matched your search
@@ -48,12 +48,11 @@ jobs:
|
||||
bun-target: bun-windows-x64
|
||||
binary: binaries/fankarr.exe
|
||||
archive: fankarr-windows-x64.zip
|
||||
# Baseline pour les CPUs Windows anciens (sans AVX2, avant ~2013)
|
||||
- target: windows-x64-baseline
|
||||
os: windows-latest
|
||||
- target: windows-x64-legacy
|
||||
os: ubuntu-latest
|
||||
bun-target: bun-windows-x64-baseline
|
||||
binary: binaries/fankarr.exe
|
||||
archive: fankarr-windows-x64-baseline.zip
|
||||
archive: fankarr-windows-x64-legacy.zip
|
||||
- target: linux-x64
|
||||
os: ubuntu-latest
|
||||
bun-target: bun-linux-x64
|
||||
@@ -63,7 +62,7 @@ jobs:
|
||||
os: ubuntu-latest
|
||||
bun-target: bun-linux-arm64
|
||||
binary: binaries/fankarr
|
||||
archive: fankarr-linux-arm64-termux.zip
|
||||
archive: fankarr-linux-arm64.zip
|
||||
- target: macos
|
||||
os: macos-latest
|
||||
bun-target: bun-darwin-arm64
|
||||
@@ -96,13 +95,13 @@ jobs:
|
||||
path: public/
|
||||
|
||||
- name: Build binary (Unix)
|
||||
if: matrix.target != 'windows-x64' && matrix.target != 'windows-x64-baseline'
|
||||
if: matrix.target != 'windows-x64'
|
||||
run: |
|
||||
mkdir -p binaries
|
||||
bun build server/index.ts --compile --target ${{ matrix.bun-target }} --outfile ${{ matrix.binary }}
|
||||
|
||||
- name: Build binary (Windows)
|
||||
if: matrix.target == 'windows-x64' || matrix.target == 'windows-x64-baseline'
|
||||
if: matrix.target == 'windows-x64'
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path binaries
|
||||
bun build server/index.ts --compile --target ${{ matrix.bun-target }} --outfile ${{ matrix.binary }}
|
||||
@@ -112,15 +111,14 @@ jobs:
|
||||
run: bun build server/organize-worker.ts --target node --outfile organize-worker.js
|
||||
|
||||
- name: Package archive (Windows)
|
||||
if: matrix.target == 'windows-x64' || matrix.target == 'windows-x64-baseline'
|
||||
run: Compress-Archive -Path ${{ matrix.binary }}, organize-worker.js, public -DestinationPath ${{ matrix.archive }}
|
||||
if: matrix.target == 'windows-x64' || matrix.target == 'windows-x64-legacy'
|
||||
run: Compress-Archive -Path ${{ matrix.binary }}, organize-worker.js, public, version.txt -DestinationPath ${{ matrix.archive }}
|
||||
shell: pwsh
|
||||
|
||||
- name: Package archive (Unix)
|
||||
if: matrix.target != 'windows-x64' && matrix.target != 'windows-x64-baseline'
|
||||
if: matrix.target != 'windows-x64' && matrix.target != 'windows-x64-legacy'
|
||||
run: |
|
||||
chmod +x ${{ matrix.binary }}
|
||||
zip -r ${{ matrix.archive }} ${{ matrix.binary }} organize-worker.js public/
|
||||
zip -r ${{ matrix.archive }} ${{ matrix.binary }} organize-worker.js public/ version.txt
|
||||
|
||||
- name: Upload archive to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
@@ -9,8 +9,28 @@ name: release-please
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
steps:
|
||||
- uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
token: ${{ secrets.FINED_TOKEN }}
|
||||
release-type: simple
|
||||
release-type: simple
|
||||
|
||||
# Quand une release est créée, on écrit version.txt et on le committe
|
||||
- uses: actions/checkout@v4
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
with:
|
||||
token: ${{ secrets.FINED_TOKEN }}
|
||||
|
||||
- name: Write version.txt
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
run: |
|
||||
echo "${{ steps.release.outputs.tag_name }}" > version.txt
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add version.txt
|
||||
git commit -m "chore: update version.txt to ${{ steps.release.outputs.tag_name }}"
|
||||
git push
|
||||
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## [3.7.1](https://github.com/Masutayunikon/FanKarr/compare/v3.7.0...v3.7.1) (2026-04-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ajout de la copy dans le worker ([a7d4488](https://github.com/Masutayunikon/FanKarr/commit/a7d44886ddb4c5ca4e12bfa5dbb15e115257d9c8))
|
||||
|
||||
## [3.7.0](https://github.com/Masutayunikon/FanKarr/compare/v3.6.1...v3.7.0) (2026-04-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* activation de l'import manuel ([9712644](https://github.com/Masutayunikon/FanKarr/commit/971264446c45401f5aaa3b3acae0fab6cae15264))
|
||||
* ajout d'une "notification" dans la navbar si une nouvelle versions est disponible ([9712644](https://github.com/Masutayunikon/FanKarr/commit/971264446c45401f5aaa3b3acae0fab6cae15264))
|
||||
* ajout d'une hashmap pour matcher les titres sur synologie et ajouter les hash de torrent manuellement ([3b8c448](https://github.com/Masutayunikon/FanKarr/commit/3b8c448ea0b0917e40d493ad4bf0e81cd35dff5a))
|
||||
* ajout de l'option supprimé le torrent si l'option déplacer est activé ([3e83dc0](https://github.com/Masutayunikon/FanKarr/commit/3e83dc0d79107e46cac14f73dd3ef04794ad824d))
|
||||
* ajout de la suppression des torrents depuis l'interface ([3e83dc0](https://github.com/Masutayunikon/FanKarr/commit/3e83dc0d79107e46cac14f73dd3ef04794ad824d))
|
||||
* ajout du bouton copie ([3e83dc0](https://github.com/Masutayunikon/FanKarr/commit/3e83dc0d79107e46cac14f73dd3ef04794ad824d))
|
||||
* ajout du ratio, upload speed, etc dans les activités ([ee34a53](https://github.com/Masutayunikon/FanKarr/commit/ee34a5350086dcd7e783f9c14ca60e09c60eb032))
|
||||
* edit d'episodes importer dans le menu d'importation ([3017cac](https://github.com/Masutayunikon/FanKarr/commit/3017cac67a5d3f5ccb9f153f233a446b0afaaec8))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ajout de l'option de suppresion dans l'interface settings.ts ([a70e65f](https://github.com/Masutayunikon/FanKarr/commit/a70e65f0e4323369d1fd7401665fb3033352a286))
|
||||
* ajout de logs lors de non match ([0c6c802](https://github.com/Masutayunikon/FanKarr/commit/0c6c802cbfdf297df40aa5944eacf16f58489bd1))
|
||||
* ajout des series non disponible pour pouvoir les add manuellement ([9e0664a](https://github.com/Masutayunikon/FanKarr/commit/9e0664aa49d6f99be8ca7e7bbd7c8e7cec0f9e7c))
|
||||
* changer le noms de build arm64 et windows legacy pour une meilleur comprehension ([47d81e6](https://github.com/Masutayunikon/FanKarr/commit/47d81e692d866beedf80a6c46317dfe79213547d))
|
||||
* formatage ([82a61eb](https://github.com/Masutayunikon/FanKarr/commit/82a61ebe74b8196891a50c1380ffaa89178c1fd1))
|
||||
* formatage ([f6b384e](https://github.com/Masutayunikon/FanKarr/commit/f6b384ec49bf018e23eb0a0b15917aee4578d66e))
|
||||
* l'import a le meme comportement que radarr/sonarr en ouvrant l'import dans le dossier de la serie attendu directement ([1485efe](https://github.com/Masutayunikon/FanKarr/commit/1485efe053d3281521978bb2d29a6c9ce219c2fd))
|
||||
* les series sans torrent et non ajoutées manuellement sont bien grisé ([2bdafd8](https://github.com/Masutayunikon/FanKarr/commit/2bdafd8e377d1a957903f228c61c1f3ab3a45955))
|
||||
* les series sans torrent et non ajoutées manuellement sont bien grisé ([d854247](https://github.com/Masutayunikon/FanKarr/commit/d854247b7fe6d5e01b0803ff7d415aba431c4f0d))
|
||||
* les series sans torrents sont bien organiser si importé manuellement ([6ed77c1](https://github.com/Masutayunikon/FanKarr/commit/6ed77c1196c45e57dbd989cc049f42403f522821))
|
||||
* les series sans torrents sont bien organiser si importé manuellement ([bc10889](https://github.com/Masutayunikon/FanKarr/commit/bc1088903053b7b98cabbe24c9cdcb8ac4c489b2))
|
||||
* log ([ef4b364](https://github.com/Masutayunikon/FanKarr/commit/ef4b3643c2852500f048ee330b92d60fed4b5ba5))
|
||||
* organisation utilise l'extension du fichier au lieu de tester si ça existe ([2b7bf48](https://github.com/Masutayunikon/FanKarr/commit/2b7bf4808e4a11d450403f477ea3031add8a2ebc))
|
||||
* settings ([60a548f](https://github.com/Masutayunikon/FanKarr/commit/60a548f6bd658fe9986d94c094cad0fbed103882))
|
||||
* type ([f4305b5](https://github.com/Masutayunikon/FanKarr/commit/f4305b537f2ab0a1370445eae9e9150a34e44e86))
|
||||
* type ([bdfe74b](https://github.com/Masutayunikon/FanKarr/commit/bdfe74bb8132b6bff0b14ccb135bb3b90f3441b6))
|
||||
* vue des series importer ([6786cb5](https://github.com/Masutayunikon/FanKarr/commit/6786cb50f4c50b95f1dcd48d15742b1ae6602ed5))
|
||||
* vue des series importer ([50efd74](https://github.com/Masutayunikon/FanKarr/commit/50efd74a5b918c4ff61e7d4feac2643693db1861))
|
||||
|
||||
## [3.6.1](https://github.com/Masutayunikon/FanKarr/compare/v3.6.0...v3.6.1) (2026-04-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* windows baseline ([0469031](https://github.com/Masutayunikon/FanKarr/commit/0469031ff05c8794b20f73bc7c3ac417ab473e5d))
|
||||
|
||||
## [3.6.0](https://github.com/Masutayunikon/FanKarr/compare/v3.5.0...v3.6.0) (2026-04-10)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* gitlab-map.ts
|
||||
* =============
|
||||
* Map des titres API → titres GitLab pour le téléchargement des NFO.
|
||||
* Partagé entre organize-worker.ts et index.ts.
|
||||
*/
|
||||
|
||||
export const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
|
||||
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
|
||||
'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū',
|
||||
'Hikaru No Go Henshū' : 'Hikaru no Go Henshū',
|
||||
'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï',
|
||||
'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū',
|
||||
'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū',
|
||||
'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū',
|
||||
'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū',
|
||||
'Tower Of God Henshū' : 'Tower of God Henshū',
|
||||
}
|
||||
|
||||
export function getGitlabTitle(title: string): string {
|
||||
return SERIE_TITLE_GITLAB_MAP[title] ?? title
|
||||
}
|
||||
+153
-41
@@ -8,7 +8,7 @@ import { readSettings, writeSettings } from './settings.js'
|
||||
import {
|
||||
registerDriver, getDriver, getAvailableClients,
|
||||
listClients, addClient, removeClient, getClient,
|
||||
sanitizeClient, dispatchDownload, dispatchList, updateClient
|
||||
sanitizeClient, dispatchDownload, dispatchList, updateClient, dispatchRemove
|
||||
} from './torrent-clients/index.js'
|
||||
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
||||
import transmissionDriver from './torrent-clients/transmission.js'
|
||||
@@ -19,6 +19,7 @@ import { organizeTorrent, autoOrganizeAll, scanMediaPath, workerRunning } from '
|
||||
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
||||
import { DATA_DIR, BASE_DIR } from './config.js'
|
||||
import { systemInfo } from './system.js'
|
||||
import { getGitlabTitle } from './gitlab-map.js'
|
||||
|
||||
registerDriver(qbittorrentDriver)
|
||||
registerDriver(transmissionDriver)
|
||||
@@ -53,15 +54,16 @@ app.get('/api/settings', requireAuth, (_req, res) => {
|
||||
res.json(readSettings())
|
||||
})
|
||||
app.post('/api/settings', requireAuth, (req, res) => {
|
||||
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode } = req.body
|
||||
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode, deleteTorrentOnMove } = req.body
|
||||
const patch: Record<string, any> = {}
|
||||
if (mediaPath !== undefined) patch.mediaPath = mediaPath
|
||||
if (completePath !== undefined) patch.completePath = completePath
|
||||
if (organizeMode !== undefined) patch.organizeMode = organizeMode
|
||||
if (category !== undefined) patch.category = category
|
||||
if (nfoSupport !== undefined) patch.nfoSupport = nfoSupport
|
||||
if (autoImport !== undefined) patch.autoImport = autoImport
|
||||
if (devMode !== undefined) patch.devMode = devMode
|
||||
if (mediaPath !== undefined) patch.mediaPath = mediaPath
|
||||
if (completePath !== undefined) patch.completePath = completePath
|
||||
if (organizeMode !== undefined) patch.organizeMode = organizeMode
|
||||
if (category !== undefined) patch.category = category
|
||||
if (nfoSupport !== undefined) patch.nfoSupport = nfoSupport
|
||||
if (autoImport !== undefined) patch.autoImport = autoImport
|
||||
if (devMode !== undefined) patch.devMode = devMode
|
||||
if (deleteTorrentOnMove !== undefined) patch.deleteTorrentOnMove = deleteTorrentOnMove
|
||||
const updated = writeSettings(patch)
|
||||
logger.info('api', 'Paramètres mis à jour')
|
||||
res.json(updated)
|
||||
@@ -170,6 +172,11 @@ async function readAvailable(): Promise<number[]> {
|
||||
catch { return [] }
|
||||
}
|
||||
|
||||
async function readInfohashMap(): Promise<Record<string, string>> {
|
||||
try { return await githubGet('infohash_map.json') as Record<string, string> }
|
||||
catch { return {} }
|
||||
}
|
||||
|
||||
async function readSerieData(serieId: number): Promise<any | null> {
|
||||
try { return await githubGet(`series/${serieId}.json`) }
|
||||
catch { return null }
|
||||
@@ -245,15 +252,20 @@ async function loadEnrichedSeriesData(): Promise<any[]> {
|
||||
function computeSerieDownloadState(serieData: any | null, organized: Record<string, Record<string, any>>, activeTorrents: Set<string>): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||
if (!serieData) return 'none'
|
||||
const allTorrents = extractTorrentsFromSerieData(serieData)
|
||||
if (allTorrents.length === 0) return 'none'
|
||||
|
||||
if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
|
||||
|
||||
// Collecter tous les épisodes de la série
|
||||
const allEpisodeIds = new Set<number>()
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if ((ep.torrents ?? []).length > 0 || (ep.paths ?? []).length > 0) allEpisodeIds.add(ep.id)
|
||||
allEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
|
||||
// Vérifier via les torrents
|
||||
for (const t of allTorrents) {
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (!hash) continue
|
||||
@@ -264,6 +276,15 @@ function computeSerieDownloadState(serieData: any | null, organized: Record<stri
|
||||
if (isOrganized) organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier aussi les imports manuels (clé "manual" dans organized.json)
|
||||
const manualOrg = organized['manual'] ?? {}
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (manualOrg[String(ep.id)]) organizedEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (allEpisodeIds.size === 0 || organizedEpisodeIds.size === 0) return 'none'
|
||||
if (organizedEpisodeIds.size >= allEpisodeIds.size) return 'complete'
|
||||
return 'partial'
|
||||
@@ -277,13 +298,13 @@ app.get('/api/series', requireAuth, async (_req, res) => {
|
||||
let organized: Record<string, Record<string, any>> = {}
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
const activeTorrents = new Set<string>()
|
||||
try { const { category } = readSettings(); const active = await dispatchList(category ?? 'fankai'); for (const t of active) { if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {}
|
||||
try { const { category } = readSettings(); const infohashMap = await readInfohashMap(); const active = await dispatchList(category ?? 'fankai', infohashMap); for (const t of active) { if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {}
|
||||
const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
|
||||
const serieDataMap = new Map<number, any>()
|
||||
await Promise.all(seriesRaw.filter((s: any) => availableSet.has(s.id)).map(async (s: any) => { const sd = await readSerieData(s.id); if (sd) serieDataMap.set(s.id, sd) }))
|
||||
res.json({ series: seriesRaw.map((serie: any) => {
|
||||
const hasTorrents = availableSet.has(serie.id)
|
||||
const serieData = serieDataMap.get(serie.id) ?? null
|
||||
const hasTorrents = serieData ? extractTorrentsFromSerieData(serieData).length > 0 : false
|
||||
return { ...serie, torrent_count: serieData ? extractTorrentsFromSerieData(serieData).length : 0, has_torrents: hasTorrents, download_state: computeSerieDownloadState(serieData, organized, activeTorrents) }
|
||||
})})
|
||||
} catch (err) {
|
||||
@@ -310,6 +331,16 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
const seasonTorrentMapBySn : Record<number, any> = {}
|
||||
const integraleTorrents : any[] = []
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
|
||||
// Vérifier les imports manuels (clé "manual") pour tous les épisodes de la série
|
||||
const manualOrg = organized['manual'] ?? {}
|
||||
if (serieData) {
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (manualOrg[String(ep.id)]) organizedEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (serieData) {
|
||||
for (const t of (serieData.torrents ?? [])) {
|
||||
integraleTorrents.push({ ...t, raw: t.title })
|
||||
@@ -370,7 +401,15 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
})
|
||||
const total = eps.filter((e: any) => e.available).length
|
||||
const orgCount = eps.filter((e: any) => e.organized).length
|
||||
return { ...season, torrent: seasonTorrentMapBySn[season.season_number] ?? null, organized_state: orgCount === 0 ? 'none' : orgCount >= total && total > 0 ? 'complete' : 'partial', organized_count: orgCount, episodes: eps }
|
||||
const epTotal = eps.length // total réel incluant les imports manuels
|
||||
const orgState = orgCount === 0
|
||||
? 'none'
|
||||
: orgCount >= epTotal
|
||||
? 'complete'
|
||||
: total > 0 && orgCount >= total
|
||||
? 'complete'
|
||||
: 'partial'
|
||||
return { ...season, torrent: seasonTorrentMapBySn[season.season_number] ?? null, organized_state: orgState, organized_count: orgCount, episodes: eps }
|
||||
})
|
||||
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw })) })
|
||||
} catch (err) {
|
||||
@@ -388,11 +427,25 @@ app.post('/api/download', requireAuth, async (req, res) => {
|
||||
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
|
||||
})
|
||||
|
||||
app.delete('/api/torrent/:hash', requireAuth, async (req, res) => {
|
||||
const hash = req.params.hash as string
|
||||
const deleteFiles = req.query.deleteFiles === 'true'
|
||||
if (!hash) { res.status(400).json({ error: 'hash requis' }); return }
|
||||
try {
|
||||
const result = await dispatchRemove(hash, deleteFiles)
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
logger.error('api', `Suppression torrent échouée : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Downloads list ─────────────────────────────────────────────
|
||||
app.get('/api/downloads', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const torrents = await dispatchList(category ?? 'fankai')
|
||||
const infohashMap = await readInfohashMap()
|
||||
const torrents = await dispatchList(category ?? 'fankai', infohashMap)
|
||||
let organized: Record<string, Record<string, any>> = {}
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
const enriched = await Promise.all(torrents.map(async (t: any) => {
|
||||
@@ -474,6 +527,18 @@ app.get('/api/system', requireAuth, (_req, res) => {
|
||||
res.json({ isDocker: fs.existsSync('/.dockerenv') })
|
||||
})
|
||||
|
||||
app.get('/api/version', (_req, res) => {
|
||||
try {
|
||||
const versionPath = path.join(BASE_DIR, 'version.txt')
|
||||
const version = fs.existsSync(versionPath)
|
||||
? fs.readFileSync(versionPath, 'utf-8').trim()
|
||||
: 'dev'
|
||||
res.json({ version })
|
||||
} catch {
|
||||
res.json({ version: 'dev' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/browse', requireAuth, (req, res) => {
|
||||
const dirPath = (req.query.path as string) || '/'
|
||||
try {
|
||||
@@ -490,6 +555,11 @@ app.get('/api/browse-files', requireAuth, (req, res) => {
|
||||
const dirPath = (req.query.path as string) || '/'
|
||||
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.m4v', '.mov', '.wmv'])
|
||||
try {
|
||||
// Créer le dossier s'il n'existe pas (cas import manuel série non encore importée)
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
logger.info('api', `Dossier créé : ${dirPath}`)
|
||||
}
|
||||
const files: { name: string; path: string; size: number }[] = []
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[]
|
||||
@@ -585,22 +655,40 @@ app.post('/api/manual-import', requireAuth, async (req, res) => {
|
||||
const resolvedName = nfoSupport
|
||||
? (ep.nfo_filename ?? ep.original_filename ?? srcFilename)
|
||||
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? srcFilename)
|
||||
const destFilename = resolvedName.endsWith('.nfo') ? resolvedName.slice(0, -4) + srcExt : resolvedName
|
||||
// FIX : swap l'extension avec celle du fichier source (.nfo/.mkv → .mp4 si besoin)
|
||||
const resolvedExt = path.extname(resolvedName)
|
||||
const destFilename = resolvedExt && resolvedExt !== srcExt
|
||||
? resolvedName.slice(0, -resolvedExt.length) + srcExt
|
||||
: resolvedName
|
||||
const seasonFolder = season.season_number === 0 ? 'Specials' : `Saison ${season.season_number}`
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder)
|
||||
const destPath = path.join(destDir, destFilename)
|
||||
try {
|
||||
if (!fs.existsSync(file_path)) throw new Error('Fichier source introuvable')
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
if (!fs.existsSync(destPath)) {
|
||||
if (organizeMode === 'hardlink') {
|
||||
try { fs.linkSync(file_path, destPath) }
|
||||
catch { await fs.promises.copyFile(file_path, destPath) }
|
||||
} else {
|
||||
await fs.promises.copyFile(file_path, destPath)
|
||||
await fs.promises.unlink(file_path)
|
||||
|
||||
if (file_path !== destPath) {
|
||||
// Si le fichier est déjà dans mediaPath/[série]/ → rename sur place
|
||||
const serieRootPath = path.join(mediaPath, serieTitle)
|
||||
const isInSeriePath = file_path.startsWith(serieRootPath + path.sep) || file_path.startsWith(serieRootPath + '/')
|
||||
|
||||
if (isInSeriePath && !fs.existsSync(destPath)) {
|
||||
fs.renameSync(file_path, destPath)
|
||||
logger.info('api', `Import manuel (rename) : "${srcFilename}" → "${destFilename}"`)
|
||||
} else if (!isInSeriePath && !fs.existsSync(destPath)) {
|
||||
if (organizeMode === 'hardlink') {
|
||||
try { fs.linkSync(file_path, destPath) }
|
||||
catch { await fs.promises.copyFile(file_path, destPath) }
|
||||
} else if (organizeMode === 'move') {
|
||||
await fs.promises.copyFile(file_path, destPath)
|
||||
await fs.promises.unlink(file_path)
|
||||
} else {
|
||||
await fs.promises.copyFile(file_path, destPath)
|
||||
}
|
||||
logger.info('api', `Import manuel : "${srcFilename}" → "${destPath}"`)
|
||||
}
|
||||
}
|
||||
|
||||
const torrentHash = String(hash || '').toLowerCase() || 'manual'
|
||||
if (!organized[torrentHash]) organized[torrentHash] = {}
|
||||
organized[torrentHash][String(episode_id)] = {
|
||||
@@ -609,7 +697,6 @@ app.post('/api/manual-import', requireAuth, async (req, res) => {
|
||||
src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath,
|
||||
}
|
||||
done.push(episode_id)
|
||||
logger.info('api', `Import manuel : "${srcFilename}" → "${destPath}"`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
errors.push({ file: srcFilename, error: msg })
|
||||
@@ -617,6 +704,34 @@ app.post('/api/manual-import', requireAuth, async (req, res) => {
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(organized, null, 2), 'utf-8')
|
||||
|
||||
// Télécharger les NFO en arrière-plan si activé
|
||||
if (nfoSupport && done.length > 0) {
|
||||
const gitlabTitle = getGitlabTitle(serieTitle)
|
||||
;(async () => {
|
||||
try {
|
||||
let files: any[] = [], page = 1
|
||||
while (true) {
|
||||
const batch = await fetch(`${GITLAB_API_NFO}/tree?path=${encodeURIComponent('pack/' + gitlabTitle)}&recursive=true&per_page=100&page=${page}&ref=main`, { headers: { 'User-Agent': 'fankarr' } }).then(r => r.ok ? r.json() : [])
|
||||
if (!Array.isArray(batch) || batch.length === 0) break
|
||||
files.push(...batch); if (batch.length < 100) break; page++
|
||||
}
|
||||
for (const entry of files.filter((f: any) => f.type === 'blob')) {
|
||||
const rel = entry.path.replace(`pack/${gitlabTitle}/`, '')
|
||||
const dest = path.join(mediaPath, serieTitle, rel)
|
||||
if (fs.existsSync(dest)) continue
|
||||
const raw = await fetch(`${GITLAB_RAW_NFO}/${encodeURIComponent(gitlabTitle)}/${rel.split('/').map(encodeURIComponent).join('/')}`, { headers: { 'User-Agent': 'fankarr' } })
|
||||
if (!raw.ok) continue
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
||||
fs.writeFileSync(dest, Buffer.from(await raw.arrayBuffer()))
|
||||
}
|
||||
logger.info('api', `NFO téléchargés pour "${gitlabTitle}" (import manuel)`)
|
||||
} catch (err) {
|
||||
logger.warn('api', `Échec téléchargement NFO pour "${gitlabTitle}" : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
res.json({ ok: true, done: done.length, errors })
|
||||
})
|
||||
|
||||
@@ -686,20 +801,6 @@ const NFO_COMMITS_PATH = path.join(DATA_DIR, 'nfo_commits.json')
|
||||
const GITLAB_API_NFO = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
|
||||
const GITLAB_RAW_NFO = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
|
||||
|
||||
const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
|
||||
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
|
||||
'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū',
|
||||
'Hikaru No Go Henshū' : 'Hikaru no Go Henshū',
|
||||
'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï',
|
||||
'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū',
|
||||
'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū',
|
||||
'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū',
|
||||
'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū',
|
||||
'Tower Of God Henshū' : 'Tower of God Henshū',
|
||||
}
|
||||
|
||||
function getGitlabTitle(title: string): string { return SERIE_TITLE_GITLAB_MAP[title] ?? title }
|
||||
|
||||
function loadNfoCommits(): Record<string, string> {
|
||||
try { if (!fs.existsSync(NFO_COMMITS_PATH)) return {}; return JSON.parse(fs.readFileSync(NFO_COMMITS_PATH, 'utf-8')) }
|
||||
catch { return {} }
|
||||
@@ -828,14 +929,25 @@ server.listen(PORT, async () => {
|
||||
const autoOrganize = async () => {
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const infohashMap = await readInfohashMap()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
await autoOrganizeAll(
|
||||
() => dispatchList(category ?? 'fankai'),
|
||||
() => dispatchList(category ?? 'fankai', infohashMap),
|
||||
seriesData,
|
||||
(result) => {
|
||||
async (result) => {
|
||||
if (result.done > 0 || result.errors > 0) {
|
||||
pushNotif({ ...result, at: new Date().toISOString() })
|
||||
}
|
||||
// Supprimer le torrent après move si option activée
|
||||
const { organizeMode, deleteTorrentOnMove } = readSettings()
|
||||
if (organizeMode === 'move' && deleteTorrentOnMove && result.done > 0) {
|
||||
try {
|
||||
await dispatchRemove(result.hash, false)
|
||||
logger.info('api', `Torrent "${result.name}" supprimé après move`)
|
||||
} catch (err) {
|
||||
logger.warn('api', `Impossible de supprimer le torrent "${result.name}" : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import fsp from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { parentPort } from 'worker_threads'
|
||||
import { DATA_DIR } from './config.js'
|
||||
import { getGitlabTitle } from './gitlab-map.js'
|
||||
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
|
||||
@@ -170,23 +171,6 @@ function isInExcludedFolder(filePath: string[]): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── Map titres API → GitLab ──────────────────────────────────
|
||||
const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
|
||||
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
|
||||
'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū',
|
||||
'Hikaru No Go Henshū' : 'Hikaru no Go Henshū',
|
||||
'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï',
|
||||
'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū',
|
||||
'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū',
|
||||
'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū',
|
||||
'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū',
|
||||
'Tower Of God Henshū' : 'Tower of God Henshū',
|
||||
}
|
||||
|
||||
function getGitlabSerieTitle(serieTitle: string): string {
|
||||
return SERIE_TITLE_GITLAB_MAP[serieTitle] ?? serieTitle
|
||||
}
|
||||
|
||||
// ─── GitLab NFO downloader ────────────────────────────────────
|
||||
const GITLAB_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
|
||||
const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
|
||||
@@ -206,7 +190,7 @@ async function fetchBinary(url: string): Promise<Buffer> {
|
||||
}
|
||||
|
||||
async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> {
|
||||
const gitlabTitle = getGitlabSerieTitle(serieTitle)
|
||||
const gitlabTitle = getGitlabTitle(serieTitle)
|
||||
const folderPath = `pack/${gitlabTitle}`
|
||||
log(`Téléchargement NFO/images depuis GitLab pour "${gitlabTitle}"`)
|
||||
|
||||
@@ -346,7 +330,9 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
if (organizeMode === 'hardlink') {
|
||||
if (organizeMode === 'copy') {
|
||||
await fsp.copyFile(src, dest)
|
||||
} else if (organizeMode === 'hardlink') {
|
||||
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
|
||||
} else {
|
||||
await fsp.copyFile(src, dest)
|
||||
@@ -414,7 +400,9 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
|
||||
try {
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
if (organizeMode === 'hardlink') {
|
||||
if (organizeMode === 'copy') {
|
||||
await fsp.copyFile(src, dest)
|
||||
} else if (organizeMode === 'hardlink') {
|
||||
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
|
||||
markOrganized(hash, episode_id, {
|
||||
at: new Date().toISOString(), season: season_number,
|
||||
|
||||
+59
-38
@@ -64,7 +64,8 @@ export async function scanMediaPath(
|
||||
|
||||
logger.info('organize', `Scan de la médiathèque : ${mediaPath}`)
|
||||
|
||||
// Map : nom_sur_disque → { hash, srcFilename, episodeId, season, episode }
|
||||
// Map : nom_sans_extension → { hash, srcFilename, episodeId, season, episode }
|
||||
// On indexe par nom sans extension pour matcher quelle que soit l'extension sur le disque
|
||||
const filenameIndex = new Map<string, {
|
||||
hash : string
|
||||
srcFilename : string
|
||||
@@ -77,43 +78,38 @@ export async function scanMediaPath(
|
||||
for (const sd of seriesData) {
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const p of ep.paths ?? []) {
|
||||
if (typeof p === 'string') continue
|
||||
const hash = p.infohash?.toLowerCase()
|
||||
if (!hash || !p.path) continue
|
||||
|
||||
const srcFilename = p.path.replace(/\\/g, '/').split('/').pop()
|
||||
if (!srcFilename) continue
|
||||
|
||||
const srcExt = path.extname(srcFilename)
|
||||
const entry = {
|
||||
// Helper : indexer par nom sans extension
|
||||
const idx = (filename: string, hash: string, destFilename?: string) => {
|
||||
const base = filename.replace(/\.[^.]+$/, '')
|
||||
if (!base) return
|
||||
filenameIndex.set(base, {
|
||||
hash,
|
||||
srcFilename,
|
||||
srcFilename : filename,
|
||||
episodeId : ep.id,
|
||||
season : season.season_number,
|
||||
episode : ep.episode_number,
|
||||
destFilename: srcFilename,
|
||||
}
|
||||
destFilename: destFilename ?? filename,
|
||||
})
|
||||
}
|
||||
|
||||
// 1. Nom brut source
|
||||
filenameIndex.set(srcFilename, entry)
|
||||
// 1. Noms depuis les paths (avec vrai infohash)
|
||||
for (const p of ep.paths ?? []) {
|
||||
if (typeof p !== 'object' || !p.infohash || !p.path) continue
|
||||
const hash = p.infohash.toLowerCase()
|
||||
const srcFilename = p.path.replace(/\\/g, '/').split('/').pop()
|
||||
if (!srcFilename) continue
|
||||
idx(srcFilename, hash)
|
||||
if (ep.nfo_filename) idx(ep.nfo_filename, hash, ep.nfo_filename)
|
||||
}
|
||||
|
||||
// 2. nfo_filename avec extension swappée
|
||||
if (ep.nfo_filename) {
|
||||
const nfoRenamed = swapExt(ep.nfo_filename, srcExt)
|
||||
filenameIndex.set(nfoRenamed, { ...entry, destFilename: nfoRenamed })
|
||||
}
|
||||
|
||||
// 3. original_filename
|
||||
if (ep.original_filename) {
|
||||
filenameIndex.set(ep.original_filename, { ...entry, destFilename: ep.original_filename })
|
||||
}
|
||||
|
||||
// 4. formatted_name + .mkv
|
||||
if (ep.formatted_name?.trim()) {
|
||||
const fmtName = ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + '.mkv'
|
||||
filenameIndex.set(fmtName, { ...entry, destFilename: fmtName })
|
||||
}
|
||||
// 2. original_filename, nfo_filename et formatted_name — indexés même si paths est vide
|
||||
const fallbackHash = ep.paths?.[0]?.infohash?.toLowerCase() ?? 'manual'
|
||||
if (ep.original_filename) idx(ep.original_filename, fallbackHash, ep.original_filename)
|
||||
if (ep.nfo_filename) idx(ep.nfo_filename, fallbackHash, ep.nfo_filename)
|
||||
if (ep.formatted_name?.trim()) {
|
||||
const fmtBase = ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim()
|
||||
idx(fmtBase, fallbackHash, fmtBase)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,6 +123,7 @@ export async function scanMediaPath(
|
||||
|
||||
// Track les paires hash:episodeId présentes
|
||||
const presentFiles = new Set<string>()
|
||||
let noMatch = 0
|
||||
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[]
|
||||
@@ -136,12 +133,37 @@ export async function scanMediaPath(
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walk(full)
|
||||
} else if (entry.isFile() && entry.name.endsWith('.mkv')) {
|
||||
} else if (entry.isFile() && /\.(mkv|mp4|avi|m4v|mov|wmv)$/i.test(entry.name)) {
|
||||
result.found++
|
||||
const match = filenameIndex.get(entry.name)
|
||||
if (!match) continue
|
||||
const nameWithoutExt = entry.name.replace(/\.[^.]+$/, '')
|
||||
let match = filenameIndex.get(nameWithoutExt)
|
||||
|
||||
const { hash, srcFilename, episodeId, season, episode, destFilename } = match
|
||||
// Fallback : chercher si un candidat de l'index est un préfixe du nom du fichier
|
||||
// ex: index a "Wind Breaker.S01E01.MULTI.1080p" et le fichier est "Wind Breaker.S01E01.MULTI.1080p.x264-FANKAI"
|
||||
if (!match) {
|
||||
const lower = nameWithoutExt.toLowerCase()
|
||||
for (const [key, val] of filenameIndex.entries()) {
|
||||
if (lower.startsWith(key.toLowerCase() + '.') || lower === key.toLowerCase()) {
|
||||
match = val
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
noMatch++
|
||||
const prefix = nameWithoutExt.slice(0, 20).toLowerCase()
|
||||
const close = [...filenameIndex.keys()].filter(k => k.toLowerCase().startsWith(prefix)).slice(0, 3)
|
||||
const closeStr = close.length > 0 ? ` | candidats : ${close.map(c => `"${c}"`).join(', ')}` : ' | aucun candidat proche'
|
||||
logger.warn('organize', `Scan no match : "${nameWithoutExt}"${closeStr}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const { hash, srcFilename, episodeId, season, episode } = match
|
||||
// Le destFilename prend l'extension réelle du fichier sur le disque
|
||||
const realExt = path.extname(entry.name)
|
||||
const destBase = match.destFilename.replace(/\.[^.]+$/, '')
|
||||
const destFilename = destBase + realExt
|
||||
presentFiles.add(`${hash}:${episodeId}`)
|
||||
|
||||
if (organized[hash]?.[String(episodeId)]) continue
|
||||
@@ -180,11 +202,10 @@ export async function scanMediaPath(
|
||||
|
||||
if (result.added > 0 || removed > 0) {
|
||||
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
|
||||
logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés`)
|
||||
} else {
|
||||
logger.info('organize', `Scan terminé — ${result.found} fichiers, rien de nouveau`)
|
||||
}
|
||||
|
||||
logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
+9
-7
@@ -5,13 +5,14 @@ import { DATA_DIR } from './config.js'
|
||||
const DATA_PATH = path.join(DATA_DIR, 'settings.json')
|
||||
|
||||
export interface Settings {
|
||||
mediaPath : string
|
||||
completePath : string
|
||||
organizeMode : 'hardlink' | 'move'
|
||||
category : string
|
||||
nfoSupport : boolean
|
||||
autoImport : boolean
|
||||
devMode : boolean
|
||||
mediaPath : string
|
||||
completePath : string
|
||||
organizeMode : 'hardlink' | 'copy' | 'move' // ← ajouter 'copy'
|
||||
category : string
|
||||
nfoSupport : boolean
|
||||
autoImport : boolean
|
||||
devMode : boolean
|
||||
deleteTorrentOnMove : boolean // ← ajouter
|
||||
}
|
||||
|
||||
const defaults: Settings = {
|
||||
@@ -22,6 +23,7 @@ const defaults: Settings = {
|
||||
nfoSupport : false,
|
||||
autoImport : true,
|
||||
devMode : false,
|
||||
deleteTorrentOnMove: false,
|
||||
}
|
||||
|
||||
export function readSettings(): Settings {
|
||||
|
||||
@@ -41,7 +41,10 @@ export interface TorrentInfo {
|
||||
progress : number
|
||||
size : number
|
||||
downloaded: number
|
||||
uploaded : number
|
||||
ratio : number
|
||||
speed : number
|
||||
upspeed : number
|
||||
eta : number
|
||||
save_path : string
|
||||
category : string
|
||||
@@ -53,6 +56,7 @@ export interface TorrentClientDriver {
|
||||
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[]>
|
||||
remove : (config: Record<string, string | number>, hash: string, deleteFiles?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
// ─── Registry ─────────────────────────────────────────────────────────────────
|
||||
@@ -235,17 +239,47 @@ export async function dispatchDownload(url: string): Promise<{ uuid: string; nam
|
||||
return results
|
||||
}
|
||||
|
||||
// ─── Dispatch list ─────────────────────────────────────────────────────────────
|
||||
// ─── Dispatch remove ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function dispatchList(category?: string): Promise<(TorrentInfo & { client_uuid: string; client_name: string })[]> {
|
||||
export async function dispatchRemove(hash: string, deleteFiles = false): Promise<{ ok: boolean; error?: string }> {
|
||||
const clients = loadClients()
|
||||
let found = false
|
||||
for (const client of clients) {
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) continue
|
||||
try {
|
||||
await driver.remove(client.config, hash, deleteFiles)
|
||||
logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de "${client.name}"${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||
found = true
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.warn('torrent-clients', `Impossible de supprimer le torrent de "${client.name}" : ${msg}`)
|
||||
}
|
||||
}
|
||||
return found ? { ok: true } : { ok: false, error: 'Torrent introuvable dans les clients' }
|
||||
}
|
||||
|
||||
// Map inversée : titre normalisé → infohash (pour matcher les torrents Synology)
|
||||
function buildTitleIndex(infohashMap: Record<string, string>): Map<string, string> {
|
||||
const index = new Map<string, string>()
|
||||
for (const [hash, title] of Object.entries(infohashMap)) {
|
||||
index.set(title.toLowerCase().trim(), hash.toLowerCase())
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
export async function dispatchList(
|
||||
category? : string,
|
||||
infohashMap? : Record<string, string>,
|
||||
): Promise<(TorrentInfo & { client_uuid: string; client_name: string })[]> {
|
||||
const clients = loadClients()
|
||||
const results: (TorrentInfo & { client_uuid: string; client_name: string })[] = []
|
||||
const titleIndex = infohashMap ? buildTitleIndex(infohashMap) : null
|
||||
|
||||
for (const client of clients) {
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) continue
|
||||
|
||||
// Skip si le client est en cooldown suite à des échecs d'auth
|
||||
if (isInCooldown(client.uuid)) {
|
||||
const remaining = cooldownRemaining(client.uuid)
|
||||
logger.debug('torrent-clients', `Client "${client.name}" en cooldown — retry dans ${remaining}`)
|
||||
@@ -266,12 +300,23 @@ export async function dispatchList(category?: string): Promise<(TorrentInfo & {
|
||||
const save_path = hasRemap
|
||||
? remapSavePath(t.save_path, remotePath, localPath)
|
||||
: t.save_path
|
||||
results.push({ ...t, save_path, client_uuid: client.uuid, client_name: client.name })
|
||||
|
||||
// Pour Synology (hash = dbid_xxx), on essaie de résoudre le vrai infohash
|
||||
// en matchant le titre du torrent dans la infohashMap
|
||||
let hash = t.hash
|
||||
if (titleIndex && t.hash.startsWith('dbid_')) {
|
||||
const resolved = titleIndex.get(t.name.toLowerCase().trim())
|
||||
if (resolved) {
|
||||
logger.debug('torrent-clients', `Synology hash résolu : "${t.name}" → ${resolved.slice(0, 8)}…`)
|
||||
hash = resolved
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ ...t, hash, save_path, client_uuid: client.uuid, client_name: client.name })
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.error('torrent-clients', `Impossible de récupérer la liste depuis "${client.name}" : ${msg}`)
|
||||
// Déclencher le backoff si c'est une erreur d'auth
|
||||
const isAuthError = msg.toLowerCase().includes('login') ||
|
||||
msg.toLowerCase().includes('auth') ||
|
||||
msg.toLowerCase().includes('ban') ||
|
||||
|
||||
@@ -93,7 +93,10 @@ const QB: TorrentClientDriver = {
|
||||
progress : Math.round(t.progress * 100),
|
||||
size : t.size,
|
||||
downloaded: t.downloaded,
|
||||
uploaded : t.uploaded ?? 0,
|
||||
ratio : Math.round((t.ratio ?? 0) * 100) / 100,
|
||||
speed : t.dlspeed,
|
||||
upspeed : t.upspeed ?? 0,
|
||||
eta : t.eta ?? -1,
|
||||
save_path : t.save_path,
|
||||
category : t.category ?? '',
|
||||
@@ -115,6 +118,20 @@ const QB: TorrentClientDriver = {
|
||||
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
|
||||
logger.info('qbittorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
|
||||
},
|
||||
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
const sid = await qbLogin(config)
|
||||
const form = new FormData()
|
||||
form.append('hashes', hash)
|
||||
form.append('deleteFiles', deleteFiles ? 'true' : 'false')
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/delete`, {
|
||||
method : 'POST',
|
||||
body : form,
|
||||
headers: { Cookie: `SID=${sid}` },
|
||||
})
|
||||
if (!res.ok) throw new Error(`Suppression échouée : ${res.status}`)
|
||||
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default QB
|
||||
@@ -178,11 +178,13 @@ const RT: TorrentClientDriver = {
|
||||
'd.complete',
|
||||
'd.size_bytes',
|
||||
'd.bytes_done',
|
||||
'd.up.total', // total uploadé
|
||||
'd.ratio', // ratio x1000
|
||||
'd.down.rate',
|
||||
'd.up.rate',
|
||||
'd.left_bytes',
|
||||
'd.down.rate',
|
||||
'd.directory',
|
||||
'd.custom1', // label rTorrent
|
||||
'd.custom1',
|
||||
]
|
||||
|
||||
const rows = await d_multicall(config, 'main', methods)
|
||||
@@ -190,10 +192,11 @@ const RT: TorrentClientDriver = {
|
||||
return rows
|
||||
.map(r => {
|
||||
const [hash, name, isOpen, isActive, isChecking, isComplete,
|
||||
size, downloaded, speed, leftBytes, , directory, label] = r
|
||||
size, downloaded, uploadedTotal, ratioRaw,
|
||||
dlSpeed, ulSpeed, leftBytes, directory, label] = r
|
||||
|
||||
const progress = size > 0 ? Math.min(100, Math.round(((size - leftBytes) / size) * 100)) : 0
|
||||
const eta = speed > 0 && leftBytes > 0 ? Math.round(leftBytes / speed) : -1
|
||||
const eta = Number(dlSpeed) > 0 && Number(leftBytes) > 0 ? Math.round(Number(leftBytes) / Number(dlSpeed)) : -1
|
||||
|
||||
return {
|
||||
hash : String(hash).toLowerCase(),
|
||||
@@ -202,7 +205,10 @@ const RT: TorrentClientDriver = {
|
||||
progress,
|
||||
size : Number(size),
|
||||
downloaded: Number(downloaded),
|
||||
speed : Number(speed),
|
||||
uploaded : Number(uploadedTotal ?? 0),
|
||||
ratio : Math.round((Number(ratioRaw ?? 0) / 1000) * 100) / 100,
|
||||
speed : Number(dlSpeed),
|
||||
upspeed : Number(ulSpeed ?? 0),
|
||||
eta,
|
||||
save_path : String(directory),
|
||||
category : String(label ?? ''),
|
||||
@@ -230,6 +236,18 @@ const RT: TorrentClientDriver = {
|
||||
await rpcCall(config, method, args)
|
||||
logger.info('rtorrent', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
|
||||
},
|
||||
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
const h = hash.toUpperCase()
|
||||
if (deleteFiles) {
|
||||
// Supprimer les fichiers puis effacer
|
||||
await rpcCall(config, 'd.delete_tied', [h])
|
||||
await rpcCall(config, 'd.erase', [h])
|
||||
} else {
|
||||
await rpcCall(config, 'd.erase', [h])
|
||||
}
|
||||
logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default RT
|
||||
@@ -108,11 +108,6 @@ const DS: TorrentClientDriver = {
|
||||
const tasks: any[] = data?.tasks ?? []
|
||||
|
||||
return tasks
|
||||
.filter(t => {
|
||||
if (!category) return true
|
||||
// Synology n'a pas de labels natifs — on filtre par destination si elle contient la catégorie
|
||||
return (t.additional?.detail?.destination as string)?.includes(String(category))
|
||||
})
|
||||
.map(t => {
|
||||
const transfer = t.additional?.transfer ?? {}
|
||||
const size = t.size ?? 0
|
||||
@@ -124,10 +119,13 @@ const DS: TorrentClientDriver = {
|
||||
progress : size > 0 ? Math.min(100, Math.round((dl / size) * 100)) : 0,
|
||||
size,
|
||||
downloaded: dl,
|
||||
uploaded : transfer.size_uploaded ?? 0,
|
||||
ratio : size > 0 ? Math.round(((transfer.size_uploaded ?? 0) / size) * 100) / 100 : 0,
|
||||
speed : transfer.speed_download ?? 0,
|
||||
upspeed : transfer.speed_upload ?? 0,
|
||||
eta : -1,
|
||||
save_path : t.additional?.detail?.destination ?? '',
|
||||
category : '',
|
||||
category : category ?? '',
|
||||
} satisfies TorrentInfo
|
||||
})
|
||||
},
|
||||
@@ -153,6 +151,27 @@ const DS: TorrentClientDriver = {
|
||||
|
||||
logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
|
||||
},
|
||||
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
const sid = await dsLogin(config)
|
||||
// Synology utilise son ID interne — on cherche via la liste
|
||||
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', { additional: 'detail' }, sid)
|
||||
const tasks: any[] = data?.tasks ?? []
|
||||
// Matcher par hash si résolu, sinon par id
|
||||
const found = tasks.find(t => t.id?.toLowerCase() === hash.toLowerCase())
|
||||
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
|
||||
const params = new URLSearchParams({
|
||||
api : 'SYNO.DownloadStation.Task',
|
||||
version: '1',
|
||||
method : 'delete',
|
||||
id : found.id,
|
||||
force_complete: deleteFiles ? 'false' : 'false',
|
||||
_sid : sid,
|
||||
})
|
||||
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
logger.info('synology-ds', `Torrent ${found.id} supprimé`)
|
||||
},
|
||||
}
|
||||
|
||||
export default DS
|
||||
@@ -94,7 +94,8 @@ const TR: TorrentClientDriver = {
|
||||
async list(config, category) {
|
||||
const fields = [
|
||||
'hashString', 'name', 'status', 'percentDone', 'totalSize',
|
||||
'downloadedEver', 'rateDownload', 'eta', 'downloadDir', 'labels',
|
||||
'downloadedEver', 'uploadedEver', 'uploadRatio',
|
||||
'rateDownload', 'rateUpload', 'eta', 'downloadDir', 'labels',
|
||||
]
|
||||
const data = await trRequest(config, 'torrent-get', { fields })
|
||||
const torrents: any[] = data.arguments?.torrents ?? []
|
||||
@@ -111,7 +112,10 @@ const TR: TorrentClientDriver = {
|
||||
progress : Math.round(t.percentDone * 100),
|
||||
size : t.totalSize,
|
||||
downloaded: t.downloadedEver,
|
||||
uploaded : t.uploadedEver ?? 0,
|
||||
ratio : Math.round((t.uploadRatio ?? 0) * 100) / 100,
|
||||
speed : t.rateDownload,
|
||||
upspeed : t.rateUpload ?? 0,
|
||||
eta : t.eta ?? -1,
|
||||
save_path : t.downloadDir,
|
||||
category : t.labels?.[0] ?? '',
|
||||
@@ -132,6 +136,16 @@ const TR: TorrentClientDriver = {
|
||||
await trRequest(config, 'torrent-add', args)
|
||||
logger.info('transmission', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
|
||||
},
|
||||
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
// Transmission identifie les torrents par ID numérique — on cherche via la liste
|
||||
const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
|
||||
const torrents: any[] = data.arguments?.torrents ?? []
|
||||
const found = torrents.find(t => t.hashString?.toLowerCase() === hash.toLowerCase())
|
||||
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
|
||||
await trRequest(config, 'torrent-remove', { ids: [found.id], 'delete-local-data': deleteFiles })
|
||||
logger.info('transmission', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default TR
|
||||
@@ -62,7 +62,7 @@ function mapTorrent(t: any[]): TorrentInfo {
|
||||
// [hash, status, name, size, progress, downloaded, uploaded, ratio,
|
||||
// ul_speed, dl_speed, eta, label, peers_connected, peers_swarm,
|
||||
// seeds_connected, seeds_swarm, availability, queue_order, remaining]
|
||||
const [hash, status, name, size, progress, downloaded, , , , dlSpeed, eta, label] = t
|
||||
const [hash, status, name, size, progress, downloaded, uploaded, ratio, ulSpeed, dlSpeed, eta, label] = t
|
||||
return {
|
||||
hash : hash.toLowerCase(),
|
||||
name,
|
||||
@@ -70,9 +70,12 @@ function mapTorrent(t: any[]): TorrentInfo {
|
||||
progress : Math.min(100, Math.round(progress / 10)),
|
||||
size,
|
||||
downloaded,
|
||||
uploaded : uploaded ?? 0,
|
||||
ratio : Math.round(((ratio ?? 0) / 1000) * 100) / 100, // uTorrent ratio est x1000
|
||||
speed : dlSpeed ?? 0,
|
||||
upspeed : ulSpeed ?? 0,
|
||||
eta : eta ?? -1,
|
||||
save_path : '', // récupéré séparément via torrent properties si besoin
|
||||
save_path : '',
|
||||
category : label ?? '',
|
||||
}
|
||||
}
|
||||
@@ -191,6 +194,15 @@ const UT: TorrentClientDriver = {
|
||||
|
||||
logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
|
||||
},
|
||||
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
const s = await utGetSession(config)
|
||||
await utRequest(config, {
|
||||
action: deleteFiles ? 'removedata' : 'remove',
|
||||
hash : hash.toUpperCase(),
|
||||
}, s)
|
||||
logger.info('utorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default UT
|
||||
@@ -7,70 +7,79 @@
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-primary">Import manuel — {{ serieName }}</h3>
|
||||
<p class="text-xs text-muted mt-0.5">Associez vos fichiers existants aux épisodes du catalogue</p>
|
||||
<p class="text-xs text-muted font-mono mt-0.5 truncate">{{ serieFolder }}</p>
|
||||
</div>
|
||||
<button @click="$emit('close')" class="text-muted hover:text-primary transition-colors text-lg leading-none">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Étape 1 : Choisir un dossier -->
|
||||
<div v-if="step === 'folder'" class="flex flex-col gap-4 px-6 py-5">
|
||||
<p class="text-sm text-secondary">Sélectionnez le dossier contenant vos fichiers vidéo :</p>
|
||||
<button
|
||||
@click="pickerOpen = true"
|
||||
class="settings-input text-left font-mono truncate"
|
||||
:class="selectedFolder ? 'text-primary' : 'text-muted'"
|
||||
>
|
||||
{{ selectedFolder || 'Choisir un dossier…' }}
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button @click="scanFolder" :disabled="!selectedFolder || scanning" class="btn-primary">
|
||||
{{ scanning ? 'Scan…' : 'Scanner ce dossier' }}
|
||||
</button>
|
||||
<button @click="$emit('close')" class="btn-secondary">Annuler</button>
|
||||
</div>
|
||||
<!-- Loading -->
|
||||
<div v-if="scanning" class="flex flex-col items-center justify-center gap-3 py-16 text-muted">
|
||||
<div class="w-5 h-5 border border-border border-t-accent rounded-full animate-spin" />
|
||||
<p class="text-sm">Scan du dossier…</p>
|
||||
</div>
|
||||
|
||||
<!-- Étape 2 : Matcher les fichiers -->
|
||||
<template v-else-if="step === 'match'">
|
||||
<!-- Dossier vide / introuvable -->
|
||||
<div v-else-if="!scanning && items.length === 0 && !scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
|
||||
<p class="text-sm text-muted">Aucun fichier vidéo trouvé dans ce dossier.</p>
|
||||
<p class="text-xs text-muted">Placez vos fichiers dans <span class="font-mono text-primary">{{ serieFolder }}</span> puis réessayez.</p>
|
||||
<button @click="scan" class="btn-secondary mt-2">Rescanner</button>
|
||||
</div>
|
||||
|
||||
<!-- Erreur -->
|
||||
<div v-else-if="scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
|
||||
<p class="text-sm text-red-400">{{ scanError }}</p>
|
||||
<button @click="scan" class="btn-secondary">Réessayer</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste fichiers -->
|
||||
<template v-else-if="items.length > 0">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between px-6 py-3 border-b border-border shrink-0 gap-3">
|
||||
<p class="text-xs text-muted">
|
||||
<span class="text-primary font-medium">{{ files.length }}</span> fichier{{ files.length > 1 ? 's' : '' }} trouvé{{ files.length > 1 ? 's' : '' }}
|
||||
· <span class="text-green-400">{{ matchedCount }} matché{{ matchedCount > 1 ? 's' : '' }}</span>
|
||||
<span v-if="unmatchedCount > 0"> · <span class="text-yellow-500">{{ unmatchedCount }} non matché{{ unmatchedCount > 1 ? 's' : '' }}</span></span>
|
||||
<span class="text-primary font-medium">{{ items.length }}</span> fichier{{ items.length > 1 ? 's' : '' }}
|
||||
<span v-if="alreadyImportedCount > 0"> · <span class="text-blue-400">{{ alreadyImportedCount }} déjà importé{{ alreadyImportedCount > 1 ? 's' : '' }}</span></span>
|
||||
· <span class="text-green-400">{{ newMatchedCount }} nouveau{{ newMatchedCount > 1 ? 'x' : '' }}</span>
|
||||
<span v-if="unmatchedCount > 0"> · <span class="text-yellow-500">{{ unmatchedCount }} non associé{{ unmatchedCount > 1 ? 's' : '' }}</span></span>
|
||||
</p>
|
||||
<button @click="step = 'folder'" class="text-xs text-muted hover:text-primary transition-colors">← Changer de dossier</button>
|
||||
<button @click="scan" class="text-xs text-muted hover:text-primary transition-colors">↺ Rescanner</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste fichiers -->
|
||||
<!-- Fichiers -->
|
||||
<div class="overflow-y-auto flex-1 px-6 py-3 flex flex-col gap-2">
|
||||
<div
|
||||
v-for="(item, i) in items"
|
||||
:key="item.file.path"
|
||||
class="flex items-center gap-3 p-3 rounded-lg border transition-colors"
|
||||
:class="item.episode_id
|
||||
? 'border-green-500/20 bg-green-500/5'
|
||||
: 'border-border bg-shell'"
|
||||
:class="item.alreadyImported
|
||||
? 'border-blue-500/20 bg-blue-500/5'
|
||||
: item.episode_id
|
||||
? 'border-green-500/20 bg-green-500/5'
|
||||
: 'border-border bg-shell'"
|
||||
>
|
||||
<!-- Icône état -->
|
||||
<!-- Icône -->
|
||||
<div class="w-5 h-5 rounded-md flex items-center justify-center shrink-0"
|
||||
:class="item.episode_id ? 'bg-green-500/15 text-green-400' : 'bg-border text-muted'">
|
||||
<svg v-if="item.episode_id" width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/></svg>
|
||||
:class="item.alreadyImported ? 'bg-blue-500/15 text-blue-400' :
|
||||
item.episode_id ? 'bg-green-500/15 text-green-400' : 'bg-border text-muted'">
|
||||
<svg v-if="item.alreadyImported || item.episode_id" width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/></svg>
|
||||
<svg v-else width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2" fill="none"><line x1="6" y1="2" x2="6" y2="7"/><circle cx="6" cy="9.5" r="0.5" fill="currentColor"/></svg>
|
||||
</div>
|
||||
|
||||
<!-- Nom fichier -->
|
||||
<!-- Nom + taille -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-primary font-mono truncate">{{ item.file.name }}</p>
|
||||
<p class="text-[11px] text-muted mt-0.5">{{ formatSize(item.file.size) }}</p>
|
||||
<p class="text-[11px] text-muted mt-0.5">
|
||||
{{ formatSize(item.file.size) }}
|
||||
<span v-if="item.alreadyImported" class="text-blue-400 ml-1">— déjà importé</span>
|
||||
<span v-else-if="item.willRename" class="text-accent ml-1">— sera renommé</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Sélecteur épisode -->
|
||||
<select
|
||||
v-model="items[i].episode_id"
|
||||
class="settings-input text-xs py-1 max-w-[260px] shrink-0"
|
||||
@change="items[i].hash = getHashForEpisode(Number(items[i].episode_id))"
|
||||
@change="onEpisodeChange(i)"
|
||||
>
|
||||
<option :value="null">— Non associé —</option>
|
||||
<optgroup v-for="season in seasons" :key="season.id" :label="season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}`">
|
||||
@@ -93,23 +102,23 @@
|
||||
<div class="flex items-center justify-between px-6 py-4 border-t border-border shrink-0">
|
||||
<p v-if="importError" class="text-xs text-red-400">{{ importError }}</p>
|
||||
<p v-else class="text-xs text-muted">
|
||||
Seuls les fichiers associés à un épisode seront importés.
|
||||
Les fichiers dans ce dossier seront <span class="text-accent">renommés sur place</span>.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="$emit('close')" class="btn-secondary">Annuler</button>
|
||||
<button
|
||||
@click="doImport"
|
||||
:disabled="matchedCount === 0 || importing"
|
||||
:disabled="newMatchedCount === 0 || importing"
|
||||
class="btn-primary"
|
||||
>
|
||||
{{ importing ? 'Import…' : `Importer ${matchedCount} fichier${matchedCount > 1 ? 's' : ''}` }}
|
||||
{{ importing ? 'Import…' : `Importer ${newMatchedCount} fichier${newMatchedCount > 1 ? 's' : ''}` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- Étape 3 : Résultat -->
|
||||
<!-- Résultat -->
|
||||
<div v-else-if="step === 'done'" class="flex flex-col items-center gap-4 px-6 py-10 text-center">
|
||||
<div class="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" stroke="#22c55e" stroke-width="2.5" fill="none"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
@@ -134,29 +143,21 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder Picker -->
|
||||
<FolderPicker
|
||||
v-if="pickerOpen"
|
||||
:initial-path="selectedFolder || '/'"
|
||||
@select="onFolderSelect"
|
||||
@cancel="pickerOpen = false"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import FolderPicker from '@/components/FolderPicker.vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const props = defineProps<{
|
||||
serieId : number
|
||||
serieName: string
|
||||
seasons : any[]
|
||||
organized: Record<string, any> // episode_id → entry
|
||||
serieId : number
|
||||
serieName : string
|
||||
seasons : any[]
|
||||
organized : Record<string, any>
|
||||
initialPath: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -165,26 +166,33 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────
|
||||
const step = ref<'folder' | 'match' | 'done'>('folder')
|
||||
const pickerOpen = ref(false)
|
||||
const selectedFolder = ref('')
|
||||
const scanning = ref(false)
|
||||
const importing = ref(false)
|
||||
const importError = ref('')
|
||||
const importResult = ref({ done: 0, errors: [] as { file: string; error: string }[] })
|
||||
const step = ref<'list' | 'done'>('list')
|
||||
const scanning = ref(false)
|
||||
const importing = ref(false)
|
||||
const importError = ref('')
|
||||
const scanError = ref('')
|
||||
const importResult = ref({ done: 0, errors: [] as { file: string; error: string }[] })
|
||||
|
||||
// Dossier de la série dans la médiathèque
|
||||
const serieTitle = computed(() =>
|
||||
props.serieName.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
||||
)
|
||||
const serieFolder = computed(() => `${props.initialPath}/${serieTitle.value}`)
|
||||
|
||||
interface FileItem {
|
||||
file : { name: string; path: string; size: number }
|
||||
episode_id: number | null
|
||||
hash : string | null
|
||||
file : { name: string; path: string; size: number }
|
||||
episode_id : number | null
|
||||
hash : string | null
|
||||
alreadyImported: boolean
|
||||
willRename : boolean
|
||||
}
|
||||
|
||||
const files = ref<{ name: string; path: string; size: number }[]>([])
|
||||
const items = ref<FileItem[]>([])
|
||||
|
||||
// ─── Computed ─────────────────────────────────────────────────
|
||||
const matchedCount = computed(() => items.value.filter(i => i.episode_id !== null).length)
|
||||
const unmatchedCount = computed(() => items.value.filter(i => i.episode_id === null).length)
|
||||
// ─── Computed ──────────────────────────────────────────────────
|
||||
const alreadyImportedCount = computed(() => items.value.filter(i => i.alreadyImported).length)
|
||||
const newMatchedCount = computed(() => items.value.filter(i => i.episode_id !== null && !i.alreadyImported).length)
|
||||
const unmatchedCount = computed(() => items.value.filter(i => i.episode_id === null).length)
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────
|
||||
function isEpisodeOrganized(epId: number): boolean {
|
||||
@@ -207,36 +215,38 @@ function getHashForEpisode(episodeId: number): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
// Auto-match : on essaie de matcher par original_filename, nfo_filename, formatted_name
|
||||
function buildOrganizedByPath(): Map<string, number> {
|
||||
const index = new Map<string, number>()
|
||||
for (const [epIdStr, entry] of Object.entries(props.organized)) {
|
||||
if (entry?.dest_path) index.set(entry.dest_path, Number(epIdStr))
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function autoMatch(filename: string): { episode_id: number | null; hash: string | null } {
|
||||
const lower = filename.toLowerCase()
|
||||
const nameNoExt = filename.replace(/\.[^.]+$/, '').toLowerCase()
|
||||
|
||||
for (const season of props.seasons) {
|
||||
for (const ep of season.episodes) {
|
||||
const candidates = [
|
||||
ep.original_filename,
|
||||
ep.nfo_filename?.replace(/\.nfo$/, '.mkv'),
|
||||
ep.formatted_name ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null,
|
||||
].filter(Boolean).map((s: string) => s.toLowerCase())
|
||||
ep.nfo_filename?.replace(/\.nfo$/, ''),
|
||||
ep.formatted_name?.replace(/[<>:"/\\|?*]/g, '').trim(),
|
||||
].filter(Boolean).map((s: string) => s.toLowerCase().replace(/\.[^.]+$/, ''))
|
||||
|
||||
if (candidates.some(c => c === lower || lower.includes(c.replace(/\.mkv$/, '')))) {
|
||||
const hash = getHashForEpisode(ep.id)
|
||||
return { episode_id: ep.id, hash }
|
||||
if (candidates.some(c => c === nameNoExt || nameNoExt.startsWith(c + '.'))) {
|
||||
return { episode_id: ep.id, hash: getHashForEpisode(ep.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback : match par numéro d'épisode dans le nom de fichier
|
||||
const numMatch = filename.match(/(?:e|ep|episode|épisode)[.\s_-]?0*(\d+)/i)
|
||||
?? filename.match(/\b0*(\d{1,3})\b/)
|
||||
// Fallback numéro d'épisode
|
||||
const numMatch = filename.match(/[.\s_-]0*(\d{1,3})[.\s_-]/i)
|
||||
if (numMatch) {
|
||||
const num = parseInt(numMatch[1], 10)
|
||||
for (const season of props.seasons) {
|
||||
const ep = season.episodes.find((e: any) => e.episode_number === num)
|
||||
if (ep) {
|
||||
const hash = getHashForEpisode(ep.id)
|
||||
return { episode_id: ep.id, hash }
|
||||
}
|
||||
if (ep) return { episode_id: ep.id, hash: getHashForEpisode(ep.id) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,45 +260,59 @@ function formatSize(bytes: number): string {
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────
|
||||
function onFolderSelect(p: string) {
|
||||
selectedFolder.value = p
|
||||
pickerOpen.value = false
|
||||
function onEpisodeChange(i: number) {
|
||||
items.value[i].hash = getHashForEpisode(Number(items.value[i].episode_id))
|
||||
items.value[i].alreadyImported = false
|
||||
}
|
||||
|
||||
async function scanFolder() {
|
||||
if (!selectedFolder.value) return
|
||||
scanning.value = true
|
||||
// ─── Actions ──────────────────────────────────────────────────
|
||||
async function scan() {
|
||||
scanning.value = true
|
||||
scanError.value = ''
|
||||
items.value = []
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/browse-files?path=${encodeURIComponent(selectedFolder.value)}`, { credentials: 'include' })
|
||||
if (!res.ok) { toast('Erreur lors du scan', 'error'); return }
|
||||
const res = await fetch(`/api/browse-files?path=${encodeURIComponent(serieFolder.value)}`, { credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
scanError.value = data.error ?? 'Erreur lors du scan'
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
files.value = data.files ?? []
|
||||
const files: { name: string; path: string; size: number }[] = data.files ?? []
|
||||
|
||||
const organizedByPath = buildOrganizedByPath()
|
||||
|
||||
items.value = files.map(f => {
|
||||
// Vérifier si déjà importé via dest_path
|
||||
const existingEpId = organizedByPath.get(f.path) ?? null
|
||||
if (existingEpId) {
|
||||
return { file: f, episode_id: existingEpId, hash: getHashForEpisode(existingEpId), alreadyImported: true, willRename: false }
|
||||
}
|
||||
|
||||
items.value = files.value.map(f => {
|
||||
const match = autoMatch(f.name)
|
||||
return { file: f, ...match }
|
||||
return { file: f, ...match, alreadyImported: false, willRename: match.episode_id !== null }
|
||||
})
|
||||
|
||||
step.value = 'match'
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
scanError.value = 'Impossible de contacter le serveur'
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
importing.value = true
|
||||
importing.value = true
|
||||
importError.value = ''
|
||||
|
||||
const payload = items.value
|
||||
.filter(i => i.episode_id !== null)
|
||||
.map(i => ({
|
||||
file_path : i.file.path,
|
||||
episode_id : i.episode_id,
|
||||
hash : i.hash,
|
||||
}))
|
||||
.filter(i => i.episode_id !== null && !i.alreadyImported)
|
||||
.map(i => ({ file_path: i.file.path, episode_id: i.episode_id, hash: i.hash }))
|
||||
|
||||
if (payload.length === 0) {
|
||||
importError.value = 'Aucun nouveau fichier à importer'
|
||||
importing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/manual-import', {
|
||||
@@ -297,10 +321,8 @@ async function doImport() {
|
||||
credentials: 'include',
|
||||
body : JSON.stringify({ serie_id: props.serieId, items: payload }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) { importError.value = data.error ?? 'Erreur inconnue'; return }
|
||||
|
||||
importResult.value = { done: data.done, errors: data.errors ?? [] }
|
||||
step.value = 'done'
|
||||
} catch {
|
||||
@@ -309,4 +331,6 @@ async function doImport() {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(scan)
|
||||
</script>
|
||||
@@ -27,6 +27,32 @@
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Nouvelle version disponible -->
|
||||
<a
|
||||
v-if="updateAvailable"
|
||||
:href="latestReleaseUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="mx-2 mb-2 flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-accent/30 bg-accent/5 hover:bg-accent/10 transition-colors group"
|
||||
>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-medium text-accent">Mise à jour disponible</p>
|
||||
<p class="text-[11px] text-muted truncate">{{ latestVersion }} est disponible</p>
|
||||
</div>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
|
||||
class="shrink-0 text-muted group-hover:text-accent transition-colors">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Version actuelle -->
|
||||
<div class="px-3 pb-1 pt-0.5">
|
||||
<p class="text-[10px] text-muted/50 font-mono">{{ currentVersion }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Thème -->
|
||||
<ThemeSwitcher />
|
||||
|
||||
@@ -34,6 +60,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { LogOut } from 'lucide-vue-next'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -46,8 +73,64 @@ defineProps<{ items: NavItem[] }>()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const currentVersion = ref('dev')
|
||||
const latestVersion = ref('')
|
||||
const latestReleaseUrl = ref('https://github.com/masutayunikon/FanKarr/releases/latest')
|
||||
const updateAvailable = ref(false)
|
||||
|
||||
const GITHUB_REPO = 'masutayunikon/FanKarr'
|
||||
|
||||
function parseVersion(v: string): number[] {
|
||||
return v.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0)
|
||||
}
|
||||
|
||||
function isNewer(latest: string, current: string): boolean {
|
||||
if (current === 'dev') return false
|
||||
const l = parseVersion(latest)
|
||||
const c = parseVersion(current)
|
||||
for (let i = 0; i < Math.max(l.length, c.length); i++) {
|
||||
const lv = l[i] ?? 0
|
||||
const cv = c[i] ?? 0
|
||||
if (lv > cv) return true
|
||||
if (lv < cv) return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
try {
|
||||
// Version locale
|
||||
const vRes = await fetch('/api/version', { credentials: 'include' })
|
||||
if (vRes.ok) {
|
||||
const { version } = await vRes.json()
|
||||
currentVersion.value = version
|
||||
}
|
||||
|
||||
if (currentVersion.value === 'dev') return
|
||||
|
||||
// Dernière release GitHub
|
||||
const gRes = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
|
||||
headers: { Accept: 'application/vnd.github.v3+json' },
|
||||
})
|
||||
if (!gRes.ok) return
|
||||
const release = await gRes.json()
|
||||
latestVersion.value = release.tag_name ?? ''
|
||||
latestReleaseUrl.value = release.html_url ?? latestReleaseUrl.value
|
||||
|
||||
if (isNewer(latestVersion.value, currentVersion.value)) {
|
||||
updateAvailable.value = true
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
router.push('/auth')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkForUpdates()
|
||||
// Revérifier toutes les 6h
|
||||
setInterval(checkForUpdates, 6 * 60 * 60 * 1000)
|
||||
})
|
||||
</script>
|
||||
+242
-104
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar : recherche + tri -->
|
||||
<!-- Toolbar : recherche + tri + colonnes -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="relative flex-1 max-w-xs">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
|
||||
@@ -51,6 +51,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tri -->
|
||||
<div class="relative shrink-0" ref="sortRef">
|
||||
<button
|
||||
@click="sortOpen = !sortOpen"
|
||||
@@ -61,7 +62,6 @@
|
||||
<span class="hidden md:inline text-xs">Trier</span>
|
||||
<span v-if="activeSort !== 'none'" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="sortOpen"
|
||||
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-48 flex flex-col gap-0.5 shadow-xl"
|
||||
@@ -78,17 +78,42 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colonnes -->
|
||||
<div class="relative shrink-0" ref="colRef">
|
||||
<button
|
||||
@click="colOpen = !colOpen"
|
||||
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5"
|
||||
:class="colOpen ? 'border-accent text-accent' : ''"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<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>
|
||||
<span class="hidden md:inline text-xs">Colonnes</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="colOpen"
|
||||
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-52 flex flex-col gap-0.5 shadow-xl"
|
||||
>
|
||||
<p class="text-[10px] text-muted px-3 py-1">Colonnes affichées</p>
|
||||
<label
|
||||
v-for="col in columnOptions" :key="col.key"
|
||||
class="flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs cursor-pointer hover:bg-hover transition-colors"
|
||||
>
|
||||
<input type="checkbox" v-model="columns[col.key]" class="accent-accent" />
|
||||
{{ col.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Onglets + filtre -->
|
||||
<!-- Onglets + filtres -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex gap-1 bg-shell rounded-lg p-1 border border-border">
|
||||
<button
|
||||
@click="activeTab = 'active'"
|
||||
class="px-4 py-1.5 text-xs rounded-md transition-colors"
|
||||
:class="activeTab === 'active'
|
||||
? 'bg-active text-primary'
|
||||
: 'text-muted hover:text-primary'"
|
||||
:class="activeTab === 'active' ? 'bg-active text-primary' : 'text-muted hover:text-primary'"
|
||||
>
|
||||
En cours
|
||||
<span v-if="activeTorrents.length > 0" class="ml-1.5 text-[10px] text-accent">
|
||||
@@ -98,9 +123,7 @@
|
||||
<button
|
||||
@click="activeTab = 'done'"
|
||||
class="px-4 py-1.5 text-xs rounded-md transition-colors"
|
||||
:class="activeTab === 'done'
|
||||
? 'bg-active text-primary'
|
||||
: 'text-muted hover:text-primary'"
|
||||
:class="activeTab === 'done' ? 'bg-active text-primary' : 'text-muted hover:text-primary'"
|
||||
>
|
||||
Terminés
|
||||
<span v-if="doneTorrents.length > 0" class="ml-1.5 text-[10px] text-accent">
|
||||
@@ -109,18 +132,33 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'done'" class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted">Masquer importés</span>
|
||||
<button
|
||||
@click="hideImported = !hideImported"
|
||||
class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200"
|
||||
:class="hideImported ? 'bg-accent' : 'bg-border'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200"
|
||||
:class="hideImported ? 'translate-x-[18px]' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="activeTab === 'done'" class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<span class="text-xs text-muted">Seeds uniquement</span>
|
||||
<button
|
||||
@click="seedingOnly = !seedingOnly"
|
||||
class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200"
|
||||
:class="seedingOnly ? 'bg-green-500' : 'bg-border'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200"
|
||||
:class="seedingOnly ? 'translate-x-[18px]' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<span class="text-xs text-muted">Masquer importés</span>
|
||||
<button
|
||||
@click="hideImported = !hideImported"
|
||||
class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200"
|
||||
:class="hideImported ? 'bg-accent' : 'bg-border'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200"
|
||||
:class="hideImported ? 'translate-x-[18px]' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -162,12 +200,12 @@
|
||||
<span class="text-[10px] px-2 py-0.5 rounded border" :class="stateBadge(t.state).class">
|
||||
{{ stateBadge(t.state).label }}
|
||||
</span>
|
||||
<span class="text-xs text-muted">{{ t.client_name }}</span>
|
||||
<span v-if="columns.client" class="text-xs text-muted">{{ t.client_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-sm font-semibold text-primary">{{ clampedProgress(t) }}%</p>
|
||||
<p class="text-xs text-muted mt-0.5">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</p>
|
||||
<p v-if="columns.size" class="text-xs text-muted mt-0.5">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -176,8 +214,8 @@
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
:class="t.state === 'seeding' || t.state === 'unknown' ? 'bg-green-500' :
|
||||
t.state === 'error' ? 'bg-red-500' :
|
||||
t.state === 'paused' ? 'bg-muted' : 'bg-accent'"
|
||||
t.state === 'error' ? 'bg-red-500' :
|
||||
t.state === 'paused' ? 'bg-muted' : 'bg-accent'"
|
||||
:style="{ width: `${clampedProgress(t)}%` }"
|
||||
/>
|
||||
</div>
|
||||
@@ -187,7 +225,17 @@
|
||||
<div class="flex items-center gap-3 text-xs text-muted">
|
||||
<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>
|
||||
|
||||
<template v-if="t.state === 'seeding'">
|
||||
<span class="text-green-500">Complété</span>
|
||||
<span v-if="columns.ratio" class="font-mono"
|
||||
:class="t.ratio >= 1 ? 'text-green-400' : t.ratio >= 0.5 ? 'text-yellow-500' : 'text-muted'">
|
||||
R {{ t.ratio?.toFixed(2) ?? '0.00' }}
|
||||
</span>
|
||||
<span v-if="columns.uploaded">↑ {{ formatSize(t.uploaded ?? 0) }}</span>
|
||||
<span v-if="columns.upspeed && t.upspeed > 0">{{ formatSpeed(t.upspeed) }}</span>
|
||||
</template>
|
||||
|
||||
<span v-if="t.state === 'unknown'" class="text-yellow-500">État non remonté par le client</span>
|
||||
</div>
|
||||
|
||||
@@ -212,11 +260,11 @@
|
||||
<!-- Badge import -->
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded border"
|
||||
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' :
|
||||
t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' :
|
||||
'border-border text-muted'"
|
||||
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' :
|
||||
t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' :
|
||||
'border-border text-muted'"
|
||||
>
|
||||
{{ t.organizeState === 'done' ? `Importé (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
||||
{{ t.organizeState === 'done' ? `Importé (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
||||
t.organizeState === 'partial' ? `En cours (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
||||
'Non importé' }}
|
||||
</span>
|
||||
@@ -229,6 +277,49 @@
|
||||
>
|
||||
{{ importing[t.hash] ? '...' : 'Importer' }}
|
||||
</button>
|
||||
|
||||
<!-- Bouton supprimer -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="confirmDelete = confirmDelete === t.hash ? null : t.hash"
|
||||
:disabled="deleting[t.hash]"
|
||||
class="w-7 h-7 flex items-center justify-center rounded-lg border border-border text-muted hover:border-red-500/40 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||
<path d="M10 11v6M14 11v6M9 6V4h6v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Popup confirmation -->
|
||||
<div
|
||||
v-if="confirmDelete === t.hash"
|
||||
class="absolute bottom-full right-0 mb-2 bg-card border border-border rounded-xl p-3 z-10 w-52 shadow-xl"
|
||||
>
|
||||
<p class="text-xs text-primary font-medium mb-2">Supprimer ce torrent ?</p>
|
||||
<label class="flex items-center gap-2 text-xs text-muted mb-3 cursor-pointer">
|
||||
<input type="checkbox" v-model="deleteWithFiles[t.hash]" class="accent-red-500" />
|
||||
Supprimer aussi les fichiers
|
||||
</label>
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
@click="deleteTorrent(t)"
|
||||
:disabled="deleting[t.hash]"
|
||||
class="flex-1 text-xs py-1 px-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
{{ deleting[t.hash] ? '...' : 'Confirmer' }}
|
||||
</button>
|
||||
<button
|
||||
@click="confirmDelete = null"
|
||||
class="flex-1 text-xs py-1 px-2 rounded-lg border border-border text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -248,42 +339,57 @@ 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 importing = ref<Record<string, boolean>>({})
|
||||
const importingAll = ref(false)
|
||||
const activeTab = ref<'active' | 'done'>('active')
|
||||
const hideImported = ref(false)
|
||||
const search = ref('')
|
||||
const sortOpen = ref(false)
|
||||
const activeSort = ref<'none' | 'name' | 'size' | 'progress' | 'state'>('none')
|
||||
const sortDir = ref<'asc' | 'desc'>('asc')
|
||||
const sortRef = ref<HTMLElement | null>(null)
|
||||
// ─── State ────────────────────────────────────────────────────
|
||||
const torrents = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const polling = ref(true)
|
||||
const noClients = ref(false)
|
||||
const importing = ref<Record<string, boolean>>({})
|
||||
const importingAll = ref(false)
|
||||
const confirmDelete = ref<string | null>(null)
|
||||
const deleting = ref<Record<string, boolean>>({})
|
||||
const deleteWithFiles = ref<Record<string, boolean>>({})
|
||||
const activeTab = ref<'active' | 'done'>('active')
|
||||
const hideImported = ref(false)
|
||||
const seedingOnly = ref(false)
|
||||
const search = ref('')
|
||||
const sortOpen = ref(false)
|
||||
const colOpen = ref(false)
|
||||
const activeSort = ref<'none' | 'name' | 'size' | 'progress' | 'state' | 'ratio'>('none')
|
||||
const sortDir = ref<'asc' | 'desc'>('asc')
|
||||
const sortRef = ref<HTMLElement | null>(null)
|
||||
const colRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onClickOutside(sortRef, () => { sortOpen.value = false })
|
||||
const columns = ref({
|
||||
client : true,
|
||||
size : true,
|
||||
ratio : true,
|
||||
uploaded: true,
|
||||
upspeed : true,
|
||||
})
|
||||
|
||||
const sortOptions = [
|
||||
{ label: 'Nom', value: 'name' },
|
||||
{ label: 'Taille', value: 'size' },
|
||||
{ label: 'Progression', value: 'progress' },
|
||||
{ label: 'État', value: 'state' },
|
||||
const columnOptions = [
|
||||
{ key: 'client', label: 'Client' },
|
||||
{ key: 'size', label: 'Taille / Téléchargé' },
|
||||
{ key: 'ratio', label: 'Ratio' },
|
||||
{ key: 'uploaded', label: 'Uploadé (total)' },
|
||||
{ key: 'upspeed', label: 'Vitesse upload' },
|
||||
]
|
||||
|
||||
function setSort(val: typeof activeSort.value) {
|
||||
if (activeSort.value === val) {
|
||||
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
activeSort.value = val
|
||||
sortDir.value = 'asc'
|
||||
}
|
||||
sortOpen.value = false
|
||||
}
|
||||
const sortOptions = [
|
||||
{ label: 'Nom', value: 'name' },
|
||||
{ label: 'Taille', value: 'size' },
|
||||
{ label: 'Progression', value: 'progress' },
|
||||
{ label: 'État', value: 'state' },
|
||||
{ label: 'Ratio', value: 'ratio' },
|
||||
]
|
||||
|
||||
onClickOutside(sortRef, () => { sortOpen.value = false })
|
||||
onClickOutside(colRef, () => { colOpen.value = false })
|
||||
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// FIX : unknown retiré de activeTorrents, ajouté dans doneTorrents
|
||||
// ─── Computed ─────────────────────────────────────────────────
|
||||
const activeTorrents = computed(() =>
|
||||
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error'].includes(t.state))
|
||||
)
|
||||
@@ -293,19 +399,18 @@ const doneTorrents = computed(() =>
|
||||
)
|
||||
|
||||
const visibleTorrents = computed(() => {
|
||||
let list = activeTab.value === 'active'
|
||||
? activeTorrents.value
|
||||
: hideImported.value
|
||||
? doneTorrents.value.filter(t => t.organizeState !== 'done')
|
||||
: doneTorrents.value
|
||||
let list = activeTab.value === 'active' ? activeTorrents.value : doneTorrents.value
|
||||
|
||||
if (activeTab.value === 'done') {
|
||||
if (seedingOnly.value) list = list.filter(t => t.state === 'seeding')
|
||||
if (hideImported.value) list = list.filter(t => t.organizeState !== 'done')
|
||||
}
|
||||
|
||||
// Recherche
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase()
|
||||
list = list.filter(t => t.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
// Tri
|
||||
if (activeSort.value !== 'none') {
|
||||
const dir = sortDir.value === 'asc' ? 1 : -1
|
||||
list = [...list].sort((a, b) => {
|
||||
@@ -314,6 +419,7 @@ const visibleTorrents = computed(() => {
|
||||
case 'size' : return (a.size - b.size) * dir
|
||||
case 'progress': return (a.progress - b.progress) * dir
|
||||
case 'state' : return a.state.localeCompare(b.state) * dir
|
||||
case 'ratio' : return ((a.ratio ?? 0) - (b.ratio ?? 0)) * dir
|
||||
default : return 0
|
||||
}
|
||||
})
|
||||
@@ -329,11 +435,53 @@ const stats = computed(() => [
|
||||
{ label: 'Erreurs', value: torrents.value.filter(t => t.state === 'error').length },
|
||||
])
|
||||
|
||||
// FIX : progress cappé à 100 pour éviter "278.2 GB / 278.1 GB" → 100%
|
||||
// ─── Helpers ──────────────────────────────────────────────────
|
||||
function setSort(val: typeof activeSort.value) {
|
||||
if (activeSort.value === val) {
|
||||
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
activeSort.value = val
|
||||
sortDir.value = 'asc'
|
||||
}
|
||||
sortOpen.value = false
|
||||
}
|
||||
|
||||
function clampedProgress(t: any): number {
|
||||
return Math.min(100, Math.max(0, t.progress ?? 0))
|
||||
}
|
||||
|
||||
function stateBadge(state: string) {
|
||||
const map: Record<string, { label: string; class: string }> = {
|
||||
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },
|
||||
seeding : { label: 'Complété', class: 'border-green-500/40 text-green-500' },
|
||||
paused : { label: 'En pause', class: 'border-border text-muted' },
|
||||
checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' },
|
||||
error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' },
|
||||
unknown : { label: 'État inconnu', class: 'border-yellow-500/40 text-yellow-500' },
|
||||
}
|
||||
return map[state] ?? map.unknown
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes || 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`
|
||||
}
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────
|
||||
async function fetchTorrents() {
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
@@ -361,17 +509,14 @@ function togglePolling() {
|
||||
polling.value ? startPolling() : stopPolling()
|
||||
}
|
||||
|
||||
onMounted(startPolling)
|
||||
onUnmounted(stopPolling)
|
||||
|
||||
async function importTorrent(torrent: any) {
|
||||
importing.value[torrent.hash] = true
|
||||
try {
|
||||
const res = await fetch('/api/organize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method : 'POST',
|
||||
headers : { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name }),
|
||||
body : JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name }),
|
||||
})
|
||||
if (res.ok) {
|
||||
toast(`${torrent.name} importé ✓`, 'success')
|
||||
@@ -397,10 +542,10 @@ async function importAll() {
|
||||
for (const t of toImport) {
|
||||
try {
|
||||
const res = await fetch('/api/organize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method : 'POST',
|
||||
headers : { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ hash: t.hash, save_path: t.save_path, name: t.name }),
|
||||
body : JSON.stringify({ hash: t.hash, save_path: t.save_path, name: t.name }),
|
||||
})
|
||||
res.ok ? done++ : errors++
|
||||
} catch { errors++ }
|
||||
@@ -413,35 +558,28 @@ async function importAll() {
|
||||
: toast(`${done} OK, ${errors} erreur(s)`, 'error')
|
||||
}
|
||||
|
||||
function stateBadge(state: string) {
|
||||
const map: Record<string, { label: string; class: string }> = {
|
||||
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },
|
||||
seeding : { label: 'Complété', class: 'border-green-500/40 text-green-500' },
|
||||
paused : { label: 'En pause', class: 'border-border text-muted' },
|
||||
checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' },
|
||||
error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' },
|
||||
// FIX : badge jaune pour signaler état dégradé sans alarmer
|
||||
unknown : { label: 'État inconnu', class: 'border-yellow-500/40 text-yellow-500' },
|
||||
async function deleteTorrent(torrent: any) {
|
||||
deleting.value[torrent.hash] = true
|
||||
try {
|
||||
const withFiles = deleteWithFiles.value[torrent.hash] ?? false
|
||||
const res = await fetch(`/api/torrent/${torrent.hash}?deleteFiles=${withFiles}`, {
|
||||
method : 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (res.ok) {
|
||||
toast(`"${torrent.name}" supprimé ✓`, 'success')
|
||||
confirmDelete.value = null
|
||||
await fetchTorrents()
|
||||
} else {
|
||||
toast('Erreur lors de la suppression', 'error')
|
||||
}
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
deleting.value[torrent.hash] = false
|
||||
}
|
||||
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`
|
||||
}
|
||||
onMounted(startPolling)
|
||||
onUnmounted(stopPolling)
|
||||
</script>
|
||||
+72
-48
@@ -43,7 +43,21 @@
|
||||
Séries
|
||||
</RouterLink>
|
||||
|
||||
<h1 class="text-2xl font-bold text-primary tracking-tight">{{ data.serie.title }}</h1>
|
||||
<!-- Titre + bouton import manuel -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h1 class="text-2xl font-bold text-primary tracking-tight">{{ data.serie.title }}</h1>
|
||||
<button
|
||||
@click="openManualImport"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-border text-muted hover:text-primary hover:border-secondary transition shrink-0"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
Import manuel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span v-if="data.serie.year" class="text-sm text-muted">{{ data.serie.year }}</span>
|
||||
@@ -61,25 +75,7 @@
|
||||
<p v-if="data.serie.plot" class="text-sm text-secondary leading-relaxed max-w-2xl line-clamp-3">
|
||||
{{ data.serie.plot }}
|
||||
</p>
|
||||
<!--
|
||||
<button
|
||||
@click="manualImportOpen = true"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm rounded-lg border border-border text-muted hover:text-primary hover:border-secondary transition"
|
||||
>
|
||||
Import manuel
|
||||
</button>
|
||||
|
||||
|
||||
<ManualImportModal
|
||||
v-if="manualImportOpen && data"
|
||||
:serie-id="Number(route.params.id)"
|
||||
:serie-name="data.serie.title"
|
||||
:seasons="data.seasons"
|
||||
:organized="organizedByEpisode"
|
||||
@close="manualImportOpen = false"
|
||||
@imported="load(); manualImportOpen = false"
|
||||
/>
|
||||
-->
|
||||
<!-- Boutons intégrale -->
|
||||
<div class="flex flex-wrap gap-2 mt-1">
|
||||
<button
|
||||
@@ -139,7 +135,9 @@
|
||||
</h2>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
{{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
|
||||
· {{ seasonAvailableCount(season) }}/{{ season.episodes.length }} dispo
|
||||
<template v-if="seasonAvailableCount(season) > 0">
|
||||
· {{ seasonAvailableCount(season) }}/{{ season.episodes.length }} dispo
|
||||
</template>
|
||||
<span
|
||||
v-if="season.organized_state !== 'none'"
|
||||
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px]"
|
||||
@@ -149,7 +147,7 @@
|
||||
>
|
||||
{{ season.organized_state === 'complete'
|
||||
? `✓ ${season.organized_count} importé${season.organized_count > 1 ? 's' : ''}`
|
||||
: `${season.organized_count}/${seasonAvailableCount(season)} importés` }}
|
||||
: `${season.organized_count}/${seasonAvailableCount(season) || season.episodes.length} importés` }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -169,7 +167,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progression saison — dans le header, sous les contrôles -->
|
||||
<!-- Progression saison -->
|
||||
<div
|
||||
v-if="torrentProgress(extractHash(season.torrent)) && torrentProgress(extractHash(season.torrent))!.progress < 100"
|
||||
class="px-5 pb-3 -mt-1"
|
||||
@@ -206,7 +204,6 @@
|
||||
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2, '0')}` }}
|
||||
</span>
|
||||
<span class="text-sm text-primary truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
|
||||
<!-- Badge Hors Fankai -->
|
||||
<span
|
||||
v-if="ep.fankai === false || ep.torrent?.fankai === false"
|
||||
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-purple-500/10 text-purple-400 border-purple-500/20"
|
||||
@@ -250,7 +247,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- Modal import manuel -->
|
||||
<ManualImportModal
|
||||
v-if="manualImportOpen && data"
|
||||
:serie-id="Number(route.params.id)"
|
||||
:serie-name="data.serie.title"
|
||||
:seasons="data.seasons"
|
||||
:organized="organizedByEpisode"
|
||||
:initial-path="mediaPath"
|
||||
@close="manualImportOpen = false"
|
||||
@imported="load(); fetchOrganized(); manualImportOpen = false"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -260,11 +271,10 @@ import { RouterLink, useRoute } from 'vue-router'
|
||||
import { ArrowLeft, Tv, ChevronUp, Download, Loader, Check, X } from 'lucide-vue-next'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import ManualImportModal from '@/components/ManualImportModal.vue'
|
||||
import type { Season } from '@/stores/series'
|
||||
import ManualImportModal from "@/components/ManualImportModal.vue";
|
||||
|
||||
// ── Icône d'état du bouton ────────────────────────────────────
|
||||
// states: 'idle' | 'loading' | 'done' | 'unavailable'
|
||||
const EpStateIcon = defineComponent({
|
||||
props: {
|
||||
state: { type: String, default: 'idle' },
|
||||
@@ -300,9 +310,12 @@ const route = useRoute()
|
||||
const store = useSeriesStore()
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const collapsedSeasons = ref<Set<number>>(new Set())
|
||||
const downloading = ref<string[]>([])
|
||||
const downloaded = ref<string[]>([])
|
||||
const collapsedSeasons = ref<Set<number>>(new Set())
|
||||
const downloading = ref<string[]>([])
|
||||
const downloaded = ref<string[]>([])
|
||||
const manualImportOpen = ref(false)
|
||||
const organizedByEpisode = ref<Record<string, any>>({})
|
||||
const mediaPath = ref('/')
|
||||
|
||||
function isDownloading(key: string) { return downloading.value.includes(key) }
|
||||
function isDownloaded(key: string) { return downloaded.value.includes(key) }
|
||||
@@ -316,17 +329,28 @@ let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const data = computed(() => store.currentSerie)
|
||||
|
||||
|
||||
const manualImportOpen = ref(false)
|
||||
const organizedByEpisode = ref<Record<string, any>>({})
|
||||
|
||||
// Dans onMounted, après load() :
|
||||
async function fetchOrganized() {
|
||||
try {
|
||||
const res = await fetch('/api/organized', { credentials: 'include' })
|
||||
const res = await fetch(`/api/organized/${route.params.id}`, { credentials: 'include' })
|
||||
if (res.ok) organizedByEpisode.value = await res.json()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/settings', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const s = await res.json()
|
||||
mediaPath.value = s.mediaPath || '/'
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function openManualImport() {
|
||||
fetchOrganized()
|
||||
manualImportOpen.value = true
|
||||
}
|
||||
|
||||
// ── Polling downloads ─────────────────────────────────────────
|
||||
async function fetchActiveDownloads() {
|
||||
try {
|
||||
@@ -362,28 +386,28 @@ function isAlreadyQueued(torrent: { magnet?: string | null } | null | undefined)
|
||||
type BtnState = 'idle' | 'loading' | 'done' | 'unavailable'
|
||||
|
||||
function epState(ep: any): BtnState {
|
||||
if (ep.organized) return 'done'
|
||||
if (isDownloaded(`ep-${ep.id}`)) return 'done'
|
||||
if (isAlreadyQueued(ep.torrent)) return 'done'
|
||||
if (ep.organized) return 'done'
|
||||
if (isDownloaded(`ep-${ep.id}`)) return 'done'
|
||||
if (isAlreadyQueued(ep.torrent)) return 'done'
|
||||
const prog = torrentProgress(extractHash(ep.torrent))
|
||||
if (prog && prog.progress < 100) return 'loading'
|
||||
if (!ep.torrent || !ep.available) return 'unavailable'
|
||||
if (isDownloading(`ep-${ep.id}`)) return 'loading'
|
||||
if (prog && prog.progress < 100) return 'loading'
|
||||
if (!ep.torrent || !ep.available) return 'unavailable'
|
||||
if (isDownloading(`ep-${ep.id}`)) return 'loading'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function epBtnState(key: string, torrent: any): BtnState {
|
||||
if (isDownloaded(key) || isAlreadyQueued(torrent)) return 'done'
|
||||
if (isDownloading(key)) return 'loading'
|
||||
if (isDownloaded(key) || isAlreadyQueued(torrent)) return 'done'
|
||||
if (isDownloading(key)) return 'loading'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function seasonBtnState(season: any): BtnState {
|
||||
if (season.organized_state === 'complete') return 'done'
|
||||
if (isDownloaded(`season-${season.id}`)) return 'done'
|
||||
if (isAlreadyQueued(season.torrent)) return 'done'
|
||||
if (isDownloading(`season-${season.id}`)) return 'loading'
|
||||
if (!season.torrent) return 'unavailable'
|
||||
if (season.organized_state === 'complete') return 'done'
|
||||
if (isDownloaded(`season-${season.id}`)) return 'done'
|
||||
if (isAlreadyQueued(season.torrent)) return 'done'
|
||||
if (isDownloading(`season-${season.id}`)) return 'loading'
|
||||
if (!season.torrent) return 'unavailable'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
@@ -438,7 +462,7 @@ function formatDuration(seconds: number): string {
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
// fetchOrganized()
|
||||
fetchSettings()
|
||||
fetchActiveDownloads()
|
||||
pollTimer = setInterval(fetchActiveDownloads, 5000)
|
||||
})
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
:key="serie.id"
|
||||
:to="`/series/${serie.id}`"
|
||||
class="group flex flex-col gap-1.5 transition-transform duration-200 hover:-translate-y-0.5"
|
||||
:class="{ 'opacity-40': !serie.has_torrents }"
|
||||
:class="{ 'opacity-40': !serie.has_torrents && serie.download_state === 'none' }"
|
||||
>
|
||||
<div class="relative aspect-[2/3] rounded-lg overflow-hidden bg-card border border-border">
|
||||
<img
|
||||
|
||||
@@ -66,6 +66,15 @@
|
||||
>
|
||||
Hardlink
|
||||
</button>
|
||||
<button
|
||||
@click="form.organizeMode = 'copy'"
|
||||
class="px-4 py-2 text-xs rounded-lg border transition-colors"
|
||||
:class="form.organizeMode === 'copy'
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
Copier
|
||||
</button>
|
||||
<button
|
||||
@click="form.organizeMode = 'move'"
|
||||
class="px-4 py-2 text-xs rounded-lg border transition-colors"
|
||||
@@ -96,6 +105,13 @@
|
||||
description="Télécharge les NFO et images depuis GitLab lors de l'import (Kodi, Infuse, etc.)."
|
||||
/>
|
||||
|
||||
<SettingsToggle
|
||||
v-if="form.organizeMode === 'move'"
|
||||
v-model="form.deleteTorrentOnMove"
|
||||
label="Supprimer le torrent après déplacement"
|
||||
description="Supprime automatiquement le torrent du client après un import en mode Déplacer."
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
@@ -140,11 +156,12 @@ const isDocker = ref(false)
|
||||
const scanResult = ref<{ found: number; added: number } | null>(null)
|
||||
|
||||
const form = ref({
|
||||
mediaPath : '',
|
||||
completePath : '',
|
||||
organizeMode : 'hardlink' as 'hardlink' | 'move',
|
||||
nfoSupport : false,
|
||||
autoImport : true,
|
||||
mediaPath : '',
|
||||
completePath : '',
|
||||
organizeMode : 'hardlink' as 'hardlink' | 'copy' | 'move',
|
||||
nfoSupport : false,
|
||||
autoImport : true,
|
||||
deleteTorrentOnMove: false,
|
||||
})
|
||||
|
||||
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.7.1
|
||||
Reference in new issue
Block a user