mirror of
https://github.com/nicetry247/offlineacademy.git
synced 2026-07-26 20:09:57 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc6ec4bdbe | |||
| b614295629 | |||
| 7d0f976da0 | |||
| 33e5e61b11 | |||
| 3effbdcb57 | |||
| 6076267abd | |||
| 0ed055b83b | |||
| ae093a62bf |
@@ -457,7 +457,7 @@ Supported video extensions include common browser-playable formats such as `.mp4
|
||||
|
||||
### Subtitles
|
||||
|
||||
OfflineAcademy supports subtitle files stored beside the matching video. Use `.vtt` for the best browser compatibility; `.srt` files are detected as best-effort tracks.
|
||||
OfflineAcademy supports subtitle files stored beside the matching video. `.vtt` files are served directly; `.srt` files are detected and converted to browser-compatible WebVTT when served to the video player.
|
||||
|
||||
Single subtitle track:
|
||||
|
||||
|
||||
+1
-29
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Header } from '@/components/Header'
|
||||
import { getAnalyticsData } from '@/lib/analytics'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -23,35 +24,6 @@ export const metadata: Metadata = {
|
||||
description: 'Learning analytics and progress overview.',
|
||||
}
|
||||
|
||||
interface AnalyticsData {
|
||||
totalCourses: number
|
||||
completedCourses: number
|
||||
inProgressCourses: number
|
||||
totalLessons: number
|
||||
completedLessons: number
|
||||
inProgressLessons: number
|
||||
notStartedLessons: number
|
||||
totalWatchedHours: number
|
||||
totalWatchedMinutes: number
|
||||
weeklyWatchedHours: number
|
||||
weeklyWatchedMinutes: number
|
||||
weeklyCompletedLessons: number
|
||||
lessonsByType: Array<{ type: string; _count: number }>
|
||||
completedByType: Record<string, number>
|
||||
completionRate: number
|
||||
lessonCompletionRate: number
|
||||
}
|
||||
|
||||
async function getAnalyticsData(): Promise<AnalyticsData> {
|
||||
const res = await fetch('http://127.0.0.1:6767/api/progress?type=analytics', {
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch analytics')
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
const lessonTypeIcons: Record<string, React.ReactNode> = {
|
||||
VIDEO: <Film className="h-4 w-4 text-red-400" />,
|
||||
AUDIO: <Music className="h-4 w-4 text-purple-400" />,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { existsSync, statSync, createReadStream } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { join, resolve } from 'path'
|
||||
import { getCoursesRootPath } from '@/lib/scanner'
|
||||
import { convertSrtToVtt } from '@/lib/subtitles'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -79,6 +80,22 @@ export async function GET(
|
||||
txt: 'text/plain; charset=utf-8',
|
||||
}
|
||||
const contentType = mimeTypes[actualExt || ''] || 'application/octet-stream'
|
||||
|
||||
// Browsers expect WebVTT for <track>. Serve .srt subtitles as converted WebVTT.
|
||||
if (actualExt === 'srt') {
|
||||
const srtContent = await readFile(actualPath, 'utf8')
|
||||
const vttContent = convertSrtToVtt(srtContent)
|
||||
return new NextResponse(vttContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Length': Buffer.byteLength(vttContent, 'utf8').toString(),
|
||||
'Content-Type': 'text/vtt; charset=utf-8',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Content-Disposition': `inline; filename="${actualPath.split('/').pop()?.replace(/\.srt$/i, '.vtt')}"`,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Handle range requests (video seeking)
|
||||
if (range) {
|
||||
|
||||
@@ -18,6 +18,9 @@ export async function GET(
|
||||
progress: {
|
||||
where: { userId: 'local-user' },
|
||||
},
|
||||
subtitles: {
|
||||
orderBy: { lang: 'asc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getAnalyticsData } from '@/lib/analytics'
|
||||
import { z } from 'zod'
|
||||
|
||||
const progressSchema = z.object({
|
||||
@@ -165,89 +166,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (type === 'analytics') {
|
||||
// Get analytics data for dashboard
|
||||
const allProgress = await prisma.progress.findMany({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
course: { hidden: false },
|
||||
},
|
||||
})
|
||||
|
||||
const completedLessons = allProgress.filter(p => p.lessonId && p.completed)
|
||||
const inProgressLessons = allProgress.filter(p => p.lessonId && !p.completed && p.position > 0)
|
||||
const notStartedCount = await prisma.lesson.count({
|
||||
where: {
|
||||
module: { course: { hidden: false } },
|
||||
progress: { none: { userId: 'local-user' } },
|
||||
},
|
||||
})
|
||||
|
||||
const totalCourses = await prisma.course.count({ where: { hidden: false } })
|
||||
const completedCourses = await prisma.progress.count({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
lessonId: null,
|
||||
moduleId: null,
|
||||
completed: true,
|
||||
course: { hidden: false },
|
||||
},
|
||||
})
|
||||
const inProgressCourses = await prisma.progress.count({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
lessonId: null,
|
||||
moduleId: null,
|
||||
completed: false,
|
||||
course: { hidden: false },
|
||||
},
|
||||
})
|
||||
|
||||
// Calculate total watched time (in seconds)
|
||||
const totalWatchedSeconds = allProgress.reduce((sum, p) => sum + (p.position || 0), 0)
|
||||
|
||||
// Get weekly activity (last 7 days)
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
const recentProgress = allProgress.filter(p => new Date(p.lastWatched) >= sevenDaysAgo)
|
||||
const weeklyWatchedSeconds = recentProgress.reduce((sum, p) => sum + (p.position || 0), 0)
|
||||
const weeklyCompleted = recentProgress.filter(p => p.lessonId && p.completed).length
|
||||
|
||||
// Lessons by type
|
||||
const lessonsByType = await prisma.lesson.groupBy({
|
||||
by: ['type'],
|
||||
_count: true,
|
||||
where: { module: { course: { hidden: false } } },
|
||||
})
|
||||
|
||||
// Completed lessons by type
|
||||
const completedByType: Record<string, number> = {}
|
||||
for (const p of completedLessons) {
|
||||
if (!p.lessonId) continue
|
||||
const lesson = await prisma.lesson.findUnique({ where: { id: p.lessonId }, select: { type: true } })
|
||||
if (lesson) {
|
||||
completedByType[lesson.type] = (completedByType[lesson.type] || 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
totalCourses,
|
||||
completedCourses,
|
||||
inProgressCourses,
|
||||
totalLessons: completedLessons.length + inProgressLessons.length + notStartedCount,
|
||||
completedLessons: completedLessons.length,
|
||||
inProgressLessons: inProgressLessons.length,
|
||||
notStartedLessons: notStartedCount,
|
||||
totalWatchedHours: Math.round(totalWatchedSeconds / 3600 * 10) / 10,
|
||||
totalWatchedMinutes: Math.round(totalWatchedSeconds / 60),
|
||||
weeklyWatchedHours: Math.round(weeklyWatchedSeconds / 3600 * 10) / 10,
|
||||
weeklyWatchedMinutes: Math.round(weeklyWatchedSeconds / 60),
|
||||
weeklyCompletedLessons: weeklyCompleted,
|
||||
lessonsByType,
|
||||
completedByType,
|
||||
completionRate: totalCourses > 0 ? Math.round((completedCourses / totalCourses) * 100) : 0,
|
||||
lessonCompletionRate: (completedLessons.length + inProgressLessons.length + notStartedCount) > 0
|
||||
? Math.round((completedLessons.length / (completedLessons.length + inProgressLessons.length + notStartedCount)) * 100)
|
||||
: 0,
|
||||
})
|
||||
return NextResponse.json(await getAnalyticsData())
|
||||
}
|
||||
|
||||
if (lessonId) {
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ export const metadata: Metadata = {
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'en_US',
|
||||
url: 'http://localhost:6767',
|
||||
url: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:6969',
|
||||
siteName: 'OfflineAcademy',
|
||||
title: 'OfflineAcademy — Your Personal Offline Learning Center',
|
||||
description: 'A self-hosted, privacy-first learning platform for offline courses.',
|
||||
|
||||
+4
-1
@@ -15,6 +15,7 @@ export default function ScanPage() {
|
||||
const [result, setResult] = useState<{
|
||||
coursesCreated: number
|
||||
coursesUpdated: number
|
||||
coursesDeleted: number
|
||||
modulesCreated: number
|
||||
modulesUpdated: number
|
||||
lessonsCreated: number
|
||||
@@ -53,7 +54,9 @@ export default function ScanPage() {
|
||||
|
||||
setResult(data)
|
||||
addLog(`Scan completed in ${data.duration}ms`)
|
||||
addLog(`Courses: ${data.coursesCreated} created, ${data.coursesUpdated} updated`)
|
||||
addLog(
|
||||
`Courses: ${data.coursesCreated} created, ${data.coursesUpdated} updated, ${data.coursesDeleted} deleted`
|
||||
)
|
||||
addLog(`Modules: ${data.modulesCreated} created, ${data.modulesUpdated} updated`)
|
||||
addLog(`Lessons: ${data.lessonsCreated} created, ${data.lessonsUpdated} updated`)
|
||||
|
||||
|
||||
@@ -410,6 +410,18 @@ export function VideoPlayer({
|
||||
}
|
||||
}, [selectedSubtitle, subtitles])
|
||||
|
||||
const hasSubtitles = Boolean(subtitles && subtitles.length > 0)
|
||||
const captionsEnabled = selectedSubtitle !== 'off'
|
||||
const toggleCaptions = () => {
|
||||
if (!hasSubtitles) return
|
||||
if (captionsEnabled) {
|
||||
setSelectedSubtitle('off')
|
||||
return
|
||||
}
|
||||
const defaultIndex = subtitles?.findIndex(track => track.default) ?? -1
|
||||
setSelectedSubtitle(defaultIndex >= 0 ? defaultIndex : 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="video-player-container"
|
||||
@@ -563,6 +575,22 @@ export function VideoPlayer({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasSubtitles && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'text-white hover:bg-white/20 font-bold text-xs',
|
||||
captionsEnabled && 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
)}
|
||||
onClick={toggleCaptions}
|
||||
aria-label={captionsEnabled ? 'Turn captions off' : 'Turn captions on'}
|
||||
title={captionsEnabled ? 'Captions on' : 'Captions off'}
|
||||
>
|
||||
CC
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Fullscreen - FIRST */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export interface AnalyticsData {
|
||||
totalCourses: number
|
||||
completedCourses: number
|
||||
inProgressCourses: number
|
||||
totalLessons: number
|
||||
completedLessons: number
|
||||
inProgressLessons: number
|
||||
notStartedLessons: number
|
||||
totalWatchedHours: number
|
||||
totalWatchedMinutes: number
|
||||
weeklyWatchedHours: number
|
||||
weeklyWatchedMinutes: number
|
||||
weeklyCompletedLessons: number
|
||||
lessonsByType: Array<{ type: string; _count: number }>
|
||||
completedByType: Record<string, number>
|
||||
completionRate: number
|
||||
lessonCompletionRate: number
|
||||
}
|
||||
|
||||
export async function getAnalyticsData(): Promise<AnalyticsData> {
|
||||
const allProgress = await prisma.progress.findMany({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
course: { hidden: false },
|
||||
},
|
||||
})
|
||||
|
||||
const completedLessons = allProgress.filter(p => p.lessonId && p.completed)
|
||||
const inProgressLessons = allProgress.filter(p => p.lessonId && !p.completed && p.position > 0)
|
||||
const notStartedCount = await prisma.lesson.count({
|
||||
where: {
|
||||
module: { course: { hidden: false } },
|
||||
progress: { none: { userId: 'local-user' } },
|
||||
},
|
||||
})
|
||||
|
||||
const totalCourses = await prisma.course.count({ where: { hidden: false } })
|
||||
const completedCourses = await prisma.progress.count({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
lessonId: null,
|
||||
moduleId: null,
|
||||
completed: true,
|
||||
course: { hidden: false },
|
||||
},
|
||||
})
|
||||
const inProgressCourses = await prisma.progress.count({
|
||||
where: {
|
||||
userId: 'local-user',
|
||||
lessonId: null,
|
||||
moduleId: null,
|
||||
completed: false,
|
||||
course: { hidden: false },
|
||||
},
|
||||
})
|
||||
|
||||
const totalWatchedSeconds = allProgress.reduce((sum, p) => sum + (p.position || 0), 0)
|
||||
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
const recentProgress = allProgress.filter(p => new Date(p.lastWatched) >= sevenDaysAgo)
|
||||
const weeklyWatchedSeconds = recentProgress.reduce((sum, p) => sum + (p.position || 0), 0)
|
||||
const weeklyCompleted = recentProgress.filter(p => p.lessonId && p.completed).length
|
||||
|
||||
const lessonsByType = await prisma.lesson.groupBy({
|
||||
by: ['type'],
|
||||
_count: true,
|
||||
where: { module: { course: { hidden: false } } },
|
||||
})
|
||||
|
||||
const completedByType: Record<string, number> = {}
|
||||
for (const p of completedLessons) {
|
||||
if (!p.lessonId) continue
|
||||
const lesson = await prisma.lesson.findUnique({ where: { id: p.lessonId }, select: { type: true } })
|
||||
if (lesson) {
|
||||
completedByType[lesson.type] = (completedByType[lesson.type] || 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
const totalLessons = completedLessons.length + inProgressLessons.length + notStartedCount
|
||||
|
||||
return {
|
||||
totalCourses,
|
||||
completedCourses,
|
||||
inProgressCourses,
|
||||
totalLessons,
|
||||
completedLessons: completedLessons.length,
|
||||
inProgressLessons: inProgressLessons.length,
|
||||
notStartedLessons: notStartedCount,
|
||||
totalWatchedHours: Math.round(totalWatchedSeconds / 3600 * 10) / 10,
|
||||
totalWatchedMinutes: Math.round(totalWatchedSeconds / 60),
|
||||
weeklyWatchedHours: Math.round(weeklyWatchedSeconds / 3600 * 10) / 10,
|
||||
weeklyWatchedMinutes: Math.round(weeklyWatchedSeconds / 60),
|
||||
weeklyCompletedLessons: weeklyCompleted,
|
||||
lessonsByType,
|
||||
completedByType,
|
||||
completionRate: totalCourses > 0 ? Math.round((completedCourses / totalCourses) * 100) : 0,
|
||||
lessonCompletionRate: totalLessons > 0 ? Math.round((completedLessons.length / totalLessons) * 100) : 0,
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,7 @@ async function downloadCourseThumbnail(courseName: string, courseSlug: string):
|
||||
interface ScanResult {
|
||||
coursesCreated: number
|
||||
coursesUpdated: number
|
||||
coursesDeleted: number
|
||||
modulesCreated: number
|
||||
modulesUpdated: number
|
||||
lessonsCreated: number
|
||||
@@ -249,6 +250,7 @@ interface ScanOptions {
|
||||
interface ScanResult {
|
||||
coursesCreated: number
|
||||
coursesUpdated: number
|
||||
coursesDeleted: number
|
||||
modulesCreated: number
|
||||
modulesUpdated: number
|
||||
lessonsCreated: number
|
||||
@@ -314,6 +316,7 @@ async function scanCourses(options: ScanOptions = {}): Promise<ScanResult> {
|
||||
const result: ScanResult = {
|
||||
coursesCreated: 0,
|
||||
coursesUpdated: 0,
|
||||
coursesDeleted: 0,
|
||||
modulesCreated: 0,
|
||||
modulesUpdated: 0,
|
||||
lessonsCreated: 0,
|
||||
@@ -446,6 +449,16 @@ async function scanCourses(options: ScanOptions = {}): Promise<ScanResult> {
|
||||
}
|
||||
}
|
||||
|
||||
const foundSlugs = new Set(courses.map((c) => slugify(c.name)))
|
||||
const staleCourses = existingCourses.filter((c) => !c.hidden && !foundSlugs.has(c.slug))
|
||||
if (staleCourses.length > 0) {
|
||||
const staleIds = staleCourses.map((c) => c.id)
|
||||
await prisma.course.deleteMany({
|
||||
where: { id: { in: staleIds } },
|
||||
})
|
||||
result.coursesDeleted = staleCourses.length
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.errors.push('Failed to scan courses root: ' + error)
|
||||
}
|
||||
|
||||
+91
-8
@@ -69,16 +69,12 @@ export function getSubtitleFormat(fileName: string): SubtitleFormat | null {
|
||||
return ext === 'vtt' || ext === 'srt' ? ext : null
|
||||
}
|
||||
|
||||
export function parseSubtitleForVideo(
|
||||
function tryExactBasename(
|
||||
videoBaseName: string,
|
||||
subtitleFileName: string,
|
||||
src: string
|
||||
subtitleBaseName: string,
|
||||
src: string,
|
||||
format: SubtitleFormat
|
||||
): SubtitleTrackInput | null {
|
||||
const format = getSubtitleFormat(subtitleFileName)
|
||||
if (!format) return null
|
||||
|
||||
const subtitleBaseName = basename(subtitleFileName, extname(subtitleFileName))
|
||||
|
||||
if (subtitleBaseName === videoBaseName) {
|
||||
return {
|
||||
src,
|
||||
@@ -88,7 +84,15 @@ export function parseSubtitleForVideo(
|
||||
isDefault: true,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function tryDottedLangSuffix(
|
||||
videoBaseName: string,
|
||||
subtitleBaseName: string,
|
||||
src: string,
|
||||
format: SubtitleFormat
|
||||
): SubtitleTrackInput | null {
|
||||
const languagePrefix = `${videoBaseName}.`
|
||||
if (!subtitleBaseName.startsWith(languagePrefix)) return null
|
||||
|
||||
@@ -105,6 +109,59 @@ export function parseSubtitleForVideo(
|
||||
}
|
||||
}
|
||||
|
||||
function tryEmbeddedLangSegment(
|
||||
videoBaseName: string,
|
||||
subtitleBaseName: string,
|
||||
src: string,
|
||||
format: SubtitleFormat
|
||||
): SubtitleTrackInput | null {
|
||||
const subtitleParts = subtitleBaseName.split('-')
|
||||
const videoParts = videoBaseName.split('-')
|
||||
if (subtitleParts.length < videoParts.length + 1) return null
|
||||
|
||||
for (let index = 1; index < subtitleParts.length - 1; index += 1) {
|
||||
const candidateParts = [...subtitleParts]
|
||||
candidateParts.splice(index, 1)
|
||||
if (candidateParts.join('-') !== videoBaseName) continue
|
||||
|
||||
const rawLanguage = subtitleParts[index]
|
||||
if (!rawLanguage || rawLanguage.includes('/')) continue
|
||||
|
||||
const lang = normalizeSubtitleLang(rawLanguage)
|
||||
return {
|
||||
src,
|
||||
lang,
|
||||
label: languageCodeToLabel(lang),
|
||||
format,
|
||||
isDefault: lang === 'en',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function parseSubtitleForVideo(
|
||||
videoBaseName: string,
|
||||
subtitleFileName: string,
|
||||
src: string
|
||||
): SubtitleTrackInput | null {
|
||||
const format = getSubtitleFormat(subtitleFileName)
|
||||
if (!format) return null
|
||||
|
||||
const subtitleBaseName = basename(subtitleFileName, extname(subtitleFileName))
|
||||
|
||||
const direct = tryExactBasename(videoBaseName, subtitleBaseName, src, format)
|
||||
if (direct) return direct
|
||||
|
||||
const dotted = tryDottedLangSuffix(videoBaseName, subtitleBaseName, src, format)
|
||||
if (dotted) return dotted
|
||||
|
||||
const embedded = tryEmbeddedLangSegment(videoBaseName, subtitleBaseName, src, format)
|
||||
if (embedded) return embedded
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function buildSubtitleTrackMap(
|
||||
videoFileNames: string[],
|
||||
subtitleFileNames: string[],
|
||||
@@ -142,3 +199,29 @@ export function sortSubtitleTracks(tracks: SubtitleTrackInput[]): SubtitleTrackI
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
}
|
||||
|
||||
export function convertSrtToVtt(content: string): string {
|
||||
const normalized = content
|
||||
.replace(/^\uFEFF/, '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.trim()
|
||||
|
||||
if (!normalized) return 'WEBVTT\n\n'
|
||||
if (normalized.startsWith('WEBVTT')) return normalized.endsWith('\n') ? normalized : `${normalized}\n`
|
||||
|
||||
const vttBody = normalized
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
if (/^\d+$/.test(line.trim())) return ''
|
||||
return line.replace(
|
||||
/(\d{2}:\d{2}:\d{2}),(\d{3})\s+-->\s+(\d{2}:\d{2}:\d{2}),(\d{3})/g,
|
||||
'$1.$2 --> $3.$4'
|
||||
)
|
||||
})
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
|
||||
return `WEBVTT\n\n${vttBody}\n`
|
||||
}
|
||||
|
||||
+19
-3
@@ -1,7 +1,7 @@
|
||||
// OfflineAcademy Service Worker v3 — Minimal, Chrome-install-criteria focused
|
||||
// OfflineAcademy Service Worker v4 — Offline support without stale Next.js dev chunks
|
||||
// Logs to console so you can verify it's controlling the page
|
||||
|
||||
const CACHE = "oa-v5"
|
||||
const CACHE = "oa-v6"
|
||||
const ASSETS = [
|
||||
'/',
|
||||
'/site.webmanifest',
|
||||
@@ -29,6 +29,22 @@ self.addEventListener('fetch', e => {
|
||||
const url = new URL(e.request.url)
|
||||
console.log('[SW] Fetch:', url.pathname)
|
||||
|
||||
// Never cache Next.js runtime/dev chunks or HMR/RSC payloads. Stale chunks can keep old runtime errors alive.
|
||||
if (
|
||||
url.pathname.startsWith('/_next/') ||
|
||||
url.pathname === '/__nextjs_original-stack-frame' ||
|
||||
url.pathname === '/__nextjs_launch-editor'
|
||||
) {
|
||||
e.respondWith(fetch(e.request))
|
||||
return
|
||||
}
|
||||
|
||||
// Keep analytics fully live while debugging/runtime data is dynamic.
|
||||
if (url.pathname === '/analytics') {
|
||||
e.respondWith(fetch(e.request))
|
||||
return
|
||||
}
|
||||
|
||||
// Navigation (main page) — network first, cache fallback
|
||||
if (e.request.mode === 'navigate') {
|
||||
e.respondWith(networkFirst(e.request))
|
||||
@@ -86,4 +102,4 @@ self.addEventListener('message', e => {
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[SW] Loaded - version 3')
|
||||
console.log('[SW] Loaded - version 4')
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { scanCoursesDirectory } from '@/lib/scanner'
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = '/tmp/offlineacademy-stale-scan'
|
||||
const coursesRoot = join(root, 'My_Courses')
|
||||
const coursePath = join(coursesRoot, 'Stale Course', '01 - Module')
|
||||
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await mkdir(coursePath, { recursive: true })
|
||||
await writeFile(join(coursePath, '01 - Lesson.mp4'), '')
|
||||
|
||||
await prisma.setting.upsert({
|
||||
where: { key: 'coursesRoot' },
|
||||
update: { value: coursesRoot },
|
||||
create: { key: 'coursesRoot', value: coursesRoot },
|
||||
})
|
||||
|
||||
await scanCoursesDirectory({ maxConcurrency: 1, maxLessonsPerCourse: 10, maxModulesPerCourse: 5 })
|
||||
|
||||
const before = await prisma.course.count()
|
||||
assert(before === 1, `expected 1 course before delete, got ${before}`)
|
||||
|
||||
await rm(join(coursesRoot, 'Stale Course'), { recursive: true, force: true })
|
||||
|
||||
await scanCoursesDirectory({ maxConcurrency: 1, maxLessonsPerCourse: 10, maxModulesPerCourse: 5 })
|
||||
|
||||
const after = await prisma.course.count()
|
||||
assert(after === 0, `expected 0 courses after delete, got ${after}`)
|
||||
|
||||
console.log('stale course deletion verification passed')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildSubtitleTrackMap, languageCodeToLabel, normalizeSubtitleLang } from '@/lib/subtitles'
|
||||
import { buildSubtitleTrackMap, convertSrtToVtt, languageCodeToLabel, normalizeSubtitleLang } from '@/lib/subtitles'
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) {
|
||||
@@ -34,4 +34,9 @@ assert(normalizeSubtitleLang('PT-br') === 'pt-BR', 'pt-BR normalization failed')
|
||||
assert(languageCodeToLabel('fil') === 'Filipino', 'Filipino label failed')
|
||||
assert(!tracks.has('unrelated'), 'unrelated subtitle should not create a track group')
|
||||
|
||||
const converted = convertSrtToVtt('1\n00:00:01,250 --> 00:00:04,500\nHello captions\n')
|
||||
assert(converted.startsWith('WEBVTT'), 'SRT conversion should emit WEBVTT header')
|
||||
assert(converted.includes('00:00:01.250 --> 00:00:04.500'), 'SRT conversion should rewrite comma timestamps')
|
||||
assert(!converted.includes('00:00:01,250'), 'SRT conversion should not keep comma timestamps')
|
||||
|
||||
console.log('subtitle parser verification passed')
|
||||
|
||||
Reference in New Issue
Block a user