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:
1 parent
71961eb998
commit
c94ff7da3f
6 files changed
+198
-60
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }[] })
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user