feat: ajout d'une "notification" dans la navbar si une nouvelle versions est disponible

feat: activation de l'import manuel
This commit is contained in:
MasutaYunikon committed 2026-04-10 18:05:47 +02:00
1 parent 71961eb998
commit c94ff7da3f
6 files changed
+198 -60

No files matched your search

+4 -4
View File
@@ -111,14 +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'
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'
if: matrix.target != 'windows-x64' && matrix.target != 'windows-x64-legacy'
run: |
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
+21 -1
View File
@@ -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
+12
View File
@@ -480,6 +480,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 {
+10 -9
View File
@@ -138,7 +138,7 @@
<!-- Folder Picker -->
<FolderPicker
v-if="pickerOpen"
:initial-path="selectedFolder || '/'"
:initial-path="selectedFolder || props.initialPath || '/'"
@select="onFolderSelect"
@cancel="pickerOpen = false"
/>
@@ -153,10 +153,11 @@ 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,10 +166,10 @@ const emit = defineEmits<{
}>()
// ─── State ────────────────────────────────────────────────────
const step = ref<'folder' | 'match' | 'done'>('folder')
const pickerOpen = ref(false)
const selectedFolder = ref('')
const scanning = ref(false)
const step = ref<'folder' | 'match' | 'done'>('folder')
const pickerOpen = ref(false)
const selectedFolder = ref(props.initialPath || '')
const scanning = ref(false)
const importing = ref(false)
const importError = ref('')
const importResult = ref({ done: 0, errors: [] as { file: string; error: string }[] })
+83
View File
@@ -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>
+68 -46
View File
@@ -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
@@ -169,7 +165,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 +202,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 +245,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 +269,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 +308,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 +327,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 +384,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 +460,7 @@ function formatDuration(seconds: number): string {
onMounted(() => {
load()
// fetchOrganized()
fetchSettings()
fetchActiveDownloads()
pollTimer = setInterval(fetchActiveDownloads, 5000)
})