8 Commits

Author SHA1 Message Date
Nice Try cc6ec4bdbe fix: order lesson subtitles in API response 2026-06-26 21:05:27 +00:00
Nice Try b614295629 fix: embed language segments like -english-onehack.us.srt too 2026-06-26 21:05:07 +00:00
Nice Try 7d0f976da0 feat: multi-language subtitle support (SRT/VTT) 2026-06-26 20:12:48 +00:00
Nice Try 33e5e61b11 fix: read analytics data directly on server 2026-06-26 19:07:39 +00:00
Nice Try 3effbdcb57 test: verify stale course deletion after missing folders 2026-06-26 17:18:15 +00:00
Nice Try 6076267abd fix: delete stale courses from DB when folders are missing 2026-06-26 17:16:55 +00:00
Nice Try 0ed055b83b fix: remove hardcoded 6767 from app metadata and analytics fetch 2026-06-26 15:48:39 +00:00
Nice Try ae093a62bf Merge multilingual subtitle implementation 2026-06-26 14:34:25 +00:00
14 changed files with 335 additions and 128 deletions
+1 -1
View File
@@ -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
View File
@@ -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" />,
+18 -1
View File
@@ -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) {
+3
View File
@@ -18,6 +18,9 @@ export async function GET(
progress: {
where: { userId: 'local-user' },
},
subtitles: {
orderBy: { lang: 'asc' },
},
},
})
+2 -83
View File
@@ -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
View File
@@ -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
View File
@@ -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`)
+28
View File
@@ -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"
+101
View File
@@ -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,
}
}
+13
View File
@@ -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
View File
@@ -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
View File
@@ -1,7 +1,7 @@
// OfflineAcademy Service Worker v3Minimal, Chrome-install-criteria focused
// OfflineAcademy Service Worker v4Offline 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')
+47
View File
@@ -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()
})
+6 -1
View File
@@ -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')