12 Commits

Author SHA1 Message Date
nicetry247 39544c57ce Fix subtitle filename patterns and update port number
Updated subtitle filename matching patterns and local development port.
2026-06-28 17:23:43 +08:00
Nice Try 95aa68b121 docs: add v1.0.2 changelog 2026-06-28 09:21:58 +00:00
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
Nice Try 22871f6679 feat: add multilingual subtitle tracks 2026-06-26 14:34:10 +00:00
Nice Try 7ce7941712 Merge multilingual subtitle planning branch 2026-06-26 13:59:27 +00:00
21 changed files with 841 additions and 208 deletions
+56 -1
View File
@@ -42,6 +42,7 @@
- [Security Notes](#security-notes)
- [Support the Project](#support-the-project)
- [License](#license)
- [Changelog](#changelog)
---
@@ -354,7 +355,7 @@ For a NAS, USB datastore, or other external drive, change only the left side of
```yaml
volumes:
- /mnt/usb-datastore/courses:/app/My_Courses
- /path/to/your/courses:/app/My_Courses
- ./prisma_data:/app/prisma/data
```
@@ -445,6 +446,9 @@ My_Courses/
│ └── 01 - Welcome.mp4
├── 02 - Core Concepts/
│ ├── 01 - Lesson One.mp4
│ ├── 01 - Lesson One.en.vtt
│ ├── 01 - Lesson One.es.vtt
│ ├── 01 - Lesson One.ja.vtt
│ └── 02 - Lesson Two.mp4
└── 03 - Practice/
└── 01 - Lab Walkthrough.mp4
@@ -452,6 +456,29 @@ My_Courses/
Supported video extensions include common browser-playable formats such as `.mp4`, plus additional local media formats depending on browser support.
### Subtitles
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:
```text
01 - Lesson.mp4
01 - Lesson.vtt
```
Multiple language tracks:
```text
01 - Lesson.mp4
01 - Lesson.en.vtt
01 - Lesson.es.vtt
01 - Lesson.ja.vtt
01 - Lesson.fil.vtt
```
Language suffixes become selectable tracks in the video player settings menu. Examples: `en`, `es`, `fr`, `ja`, `fil`, `zh-CN`, `pt-BR`.
---
## Data & Persistence
@@ -551,3 +578,31 @@ OfflineAcademy is released under the **MIT License**.
You are free to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the software, subject to the terms of the MIT License.
See [`LICENSE`](LICENSE) for the full license text.
---
## Changelog
### v1.0.2 — Subtitle & Analytics Polish
This release focuses on making OfflineAcademy smoother for real course libraries, especially Udemy-style folders with external subtitle files.
#### Added
- Multi-language subtitle support for local course videos.
- SRT and VTT subtitle detection during course scans.
- Visible subtitle controls in the video player when tracks are available.
- Support for multiple subtitle tracks per lesson, such as English and Persian.
#### Fixed
- Same-folder subtitle imports for files stored outside the app container and mounted into the course library.
- Subtitle filename matching for common Udemy-style exports, including patterns like `-english-test.us.srt` and `-persian-test.us.srt`.
- Lesson API subtitle ordering so tracks load consistently in the player.
- Analytics page data loading by reading analytics directly on the server instead of relying on a fragile internal fetch.
#### Improved
- Course scanning now better handles real-world course folder layouts.
- Subtitle files are served as browser-compatible `text/vtt`, including converted SRT files.
- Local development workflow was verified on port `6969` after cache cleanup and rebuild.
+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.',
+1 -1
View File
@@ -103,7 +103,7 @@ const liveGroups: FeatureGroup[] = [
items: [
'Bookmarks with timestamp notes',
'Jump-to-time bookmark playback',
'Subtitle support for .srt and .vtt',
'Multi-language subtitle tracks for .vtt/.srt files',
'Inline document viewing for lessons',
],
},
+8 -3
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`)
@@ -182,7 +185,8 @@ export default function ScanPage() {
Course Name 1/
01 - Introduction/
01 - Introduction.mp4
01 - Introduction.srt
01 - Introduction.en.vtt
01 - Introduction.es.vtt
02 - Overview.pdf
03 - Notes.txt
02 - Advanced/
@@ -204,7 +208,8 @@ export default function ScanPage() {
</pre>
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
<p><strong>How the scanner reads it:</strong> course folder module folder lesson files.</p>
<p><strong>Matching rule:</strong> subtitle files should share the same base name as the video, like <code className="bg-background px-1.5 py-0.5 rounded">lesson.mp4</code> + <code className="bg-background px-1.5 py-0.5 rounded">lesson.srt</code>.</p>
<p><strong>Matching rule:</strong> subtitle files can either share the exact video base name, like <code className="bg-background px-1.5 py-0.5 rounded">lesson.mp4</code> + <code className="bg-background px-1.5 py-0.5 rounded">lesson.vtt</code>, or use language suffixes for multiple tracks, like <code className="bg-background px-1.5 py-0.5 rounded">lesson.en.vtt</code> + <code className="bg-background px-1.5 py-0.5 rounded">lesson.es.vtt</code>.</p>
<p><strong>Subtitle formats:</strong> <code className="bg-background px-1.5 py-0.5 rounded">.vtt</code> is preferred for browser playback; <code className="bg-background px-1.5 py-0.5 rounded">.srt</code> files are detected and listed as best-effort tracks.</p>
<p><strong>Sorting:</strong> numeric prefixes like <code className="bg-background px-1.5 py-0.5 rounded">01</code>, <code className="bg-background px-1.5 py-0.5 rounded">02</code>, <code className="bg-background px-1.5 py-0.5 rounded">10</code> keep lessons in learning order.</p>
</div>
</CardContent>
+27 -2
View File
@@ -21,6 +21,15 @@ import Link from 'next/link'
import { formatDuration, formatTime, cn } from '@/lib/utils'
import { getCourseDisplayName } from '@/lib/course-display'
interface SubtitleTrackType {
id?: string
src: string
lang: string
label: string
format?: string
isDefault: boolean
}
interface LessonType {
id: string
title: string
@@ -33,6 +42,7 @@ interface LessonType {
filePath: string
thumbnail: string | null
subtitlePath: string | null
subtitles?: SubtitleTrackType[]
progress?: { completed: boolean; position: number; lastWatched: string } | null
quiz?: {
version: 1
@@ -118,13 +128,28 @@ function markCompleteFn(lessonId: string, courseId: string, moduleId: string, po
}
function VideoContent({ lesson, lessonProgress, onSave, onEnd, onTimeUpdate, seekRequest }: {
lesson: { id: string; filePath: string; thumbnail: string | null; duration: number | null; subtitlePath: string | null }
lesson: { id: string; filePath: string; thumbnail: string | null; duration: number | null; subtitlePath: string | null; subtitles?: SubtitleTrackType[] }
lessonProgress: { completed: boolean; position: number; lastWatched: string }
onSave: (position: number) => void
onEnd: () => void
onTimeUpdate: (time: number) => void
seekRequest: { time: number; nonce: number } | null
}) {
const subtitleTracks = React.useMemo(() => {
if (lesson.subtitles && lesson.subtitles.length > 0) {
return lesson.subtitles.map(track => ({
src: '/api/files/' + track.src,
srcLang: track.lang,
label: track.label,
default: track.isDefault,
}))
}
return lesson.subtitlePath
? [{ src: '/api/files/' + lesson.subtitlePath, srcLang: 'en', label: 'English', default: true }]
: []
}, [lesson.subtitlePath, lesson.subtitles])
return (
<VideoPlayer
src={'/api/files/' + lesson.filePath}
@@ -135,7 +160,7 @@ function VideoContent({ lesson, lessonProgress, onSave, onEnd, onTimeUpdate, see
onProgressSave={onSave}
onEnded={onEnd}
seekRequest={seekRequest}
subtitles={lesson.subtitlePath ? '/api/files/' + lesson.subtitlePath : undefined}
subtitles={subtitleTracks}
/>
)
}
+6
View File
@@ -22,6 +22,12 @@ export default async function WatchPage({ params, searchParams }: WatchPageProps
progress: {
where: { userId: 'local-user' },
},
subtitles: {
orderBy: [
{ isDefault: 'desc' },
{ label: 'asc' },
],
},
},
})
+98 -5
View File
@@ -7,10 +7,17 @@ import { cn, formatTime } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { KeyboardShortcutsOverlay } from '@/components/KeyboardShortcutsOverlay'
interface VideoSubtitleTrack {
src: string
srcLang: string
label: string
default?: boolean
}
interface VideoPlayerProps {
src: string
poster?: string
subtitles?: string
subtitles?: VideoSubtitleTrack[]
onTimeUpdate?: (time: number) => void
onEnded?: () => void
onProgressSave?: (time: number) => void
@@ -46,6 +53,10 @@ export function VideoPlayer({
const [isPiPActive, setIsPiPActive] = useState(false)
const [hoverProgress, setHoverProgress] = useState<{ time: number; x: number } | null>(null)
const [showSettingsMenu, setShowSettingsMenu] = useState(false)
const [selectedSubtitle, setSelectedSubtitle] = useState<number | 'off'>(() => {
const defaultIndex = subtitles?.findIndex(track => track.default) ?? -1
return defaultIndex >= 0 ? defaultIndex : 'off'
})
const settingsMenuRef = useRef<HTMLDivElement>(null)
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
const progressSaveTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
@@ -385,6 +396,32 @@ export function VideoPlayer({
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showSettingsMenu])
useEffect(() => {
const defaultIndex = subtitles?.findIndex(track => track.default) ?? -1
setSelectedSubtitle(defaultIndex >= 0 ? defaultIndex : 'off')
}, [subtitles])
useEffect(() => {
const textTracks = videoRef.current?.textTracks
if (!textTracks) return
for (let index = 0; index < textTracks.length; index += 1) {
textTracks[index].mode = selectedSubtitle === index ? 'showing' : 'disabled'
}
}, [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"
@@ -410,9 +447,16 @@ export function VideoPlayer({
onClick={togglePlay}
className="w-full h-full cursor-pointer"
>
{subtitles && (
<track kind="subtitles" src={subtitles} srcLang="en" label="English" default />
)}
{subtitles?.map((track, index) => (
<track
key={`${track.srcLang}-${track.src}`}
kind="subtitles"
src={track.src}
srcLang={track.srcLang}
label={track.label}
default={selectedSubtitle === index}
/>
))}
</video>
</div>
@@ -531,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"
@@ -589,7 +649,7 @@ export function VideoPlayer({
{showSettingsMenu && (
<div
className="absolute bottom-full right-0 mb-2 w-40 glass-card-elevated rounded-lg p-2 shadow-xl border border-white/10 animate-in zoom-in-95 z-20"
className="absolute bottom-full right-0 mb-2 w-52 glass-card-elevated rounded-lg p-2 shadow-xl border border-white/10 animate-in zoom-in-95 z-20"
role="menu"
>
<div className="px-3 py-2 text-xs font-semibold text-white/60 uppercase tracking-wider">Speed</div>
@@ -611,6 +671,39 @@ export function VideoPlayer({
{playbackRate === rate && <Check className="h-4 w-4" />}
</button>
))}
{subtitles && subtitles.length > 0 && (
<>
<hr className="border-white/10 my-1" />
<div className="px-3 py-2 text-xs font-semibold text-white/60 uppercase tracking-wider">Subtitles</div>
<button
onClick={() => {
setSelectedSubtitle('off')
setShowSettingsMenu(false)
}}
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-white hover:bg-white/10 rounded ${selectedSubtitle === 'off' ? 'bg-primary/20 text-primary' : ''}`}
role="menuitemradio"
aria-checked={selectedSubtitle === 'off'}
>
<span>Off</span>
{selectedSubtitle === 'off' && <Check className="h-4 w-4" />}
</button>
{subtitles.map((track, index) => (
<button
key={`${track.srcLang}-${track.src}`}
onClick={() => {
setSelectedSubtitle(index)
setShowSettingsMenu(false)
}}
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-white hover:bg-white/10 rounded ${selectedSubtitle === index ? 'bg-primary/20 text-primary' : ''}`}
role="menuitemradio"
aria-checked={selectedSubtitle === index}
>
<span className="truncate">{track.label}</span>
{selectedSubtitle === index && <Check className="h-4 w-4 shrink-0" />}
</button>
))}
</>
)}
<hr className="border-white/10 my-1" />
<button
onClick={() => setShowShortcuts(true)}
+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,
}
}
+100 -79
View File
@@ -8,6 +8,7 @@ import { getLocalThumbnailUrl } from '@/lib/thumbnail-index-server'
import { ensureModuleQuizCache, syncQuizLessonFromCache, shouldSkipQuizGeneration } from '@/lib/quiz'
import { applyCourseMetadata, isMetadataFileName, readCourseMetadataFile } from '@/lib/course-metadata'
import { getLocalCourseThumbnailPath, ensurePublicThumbnail } from '@/lib/course-thumbnail-utils'
import { buildSubtitleTrackMap } from '@/lib/subtitles'
import { writeFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
@@ -134,6 +135,7 @@ async function downloadCourseThumbnail(courseName: string, courseSlug: string):
interface ScanResult {
coursesCreated: number
coursesUpdated: number
coursesDeleted: number
modulesCreated: number
modulesUpdated: number
lessonsCreated: number
@@ -248,6 +250,7 @@ interface ScanOptions {
interface ScanResult {
coursesCreated: number
coursesUpdated: number
coursesDeleted: number
modulesCreated: number
modulesUpdated: number
lessonsCreated: number
@@ -313,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,
@@ -445,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)
}
@@ -557,16 +571,31 @@ async function scanLessons(
.filter(e => e.isFile() && !e.name.startsWith('.'))
.sort((a, b) => naturalSort(a.name, b.name))
// Build a map of subtitle files by their base name (for matching with videos)
const subtitleMap = new Map<string, string>()
for (const file of files) {
const ext = extname(file.name).slice(1).toLowerCase()
if (['srt', 'vtt'].includes(ext)) {
const baseName = basename(file.name, extname(file.name))
const relativePath = relative(coursesRoot, join(modulePath, file.name)).replace(/\\/g, '/')
subtitleMap.set(baseName, relativePath)
const subtitleFiles = files.filter(file => ['srt', 'vtt'].includes(extname(file.name).slice(1).toLowerCase()))
// Filter files to only include supported lesson types. Subtitle files are attached to videos, not created as lessons.
const supportedFiles = files.filter(file => {
if (file.name === 'quiz_cache.json' || isMetadataFileName(file.name)) {
return false
}
}
const mimeType = getMimeType(file.name)
const lessonType = getLessonType(mimeType)
if (lessonType === 'OTHER' && !['json', 'txt'].includes(extname(file.name).slice(1).toLowerCase())) {
return false
}
if (['srt', 'vtt'].includes(extname(file.name).slice(1).toLowerCase())) {
return false
}
return true
})
const selectedFiles = supportedFiles.slice(0, maxLessonsPerCourse)
const subtitleMap = buildSubtitleTrackMap(
selectedFiles.map(file => file.name),
subtitleFiles.map(file => file.name),
(fileName) => relative(coursesRoot, join(modulePath, fileName)).replace(/\\/g, '/')
)
// Get existing lessons in one query
const existingLessons = await prisma.lesson.findMany({
@@ -575,90 +604,82 @@ async function scanLessons(
})
const existingLessonMap = new Map(existingLessons.map(l => [l.slug, l]))
// Filter files to only include supported types
const supportedFiles = files.filter(file => {
if (file.name === 'quiz_cache.json' || isMetadataFileName(file.name)) {
return false
}
const lessonRecords: Array<{ slug: string; baseName: string }> = []
for (let index = 0; index < selectedFiles.length; index += 1) {
const file = selectedFiles[index]
const relativePath = relative(coursesRoot, join(modulePath, file.name)).replace(/\\/g, '/')
const mimeType = getMimeType(file.name)
const lessonType = getLessonType(mimeType)
if (lessonType === 'OTHER' && !['json', 'txt', 'srt', 'vtt'].includes(extname(file.name).slice(1))) {
return false
}
// Skip subtitle files for lesson creation - they'll be attached to videos
if (['srt', 'vtt'].includes(extname(file.name).slice(1).toLowerCase())) {
return false
}
return true
})
const baseName = basename(file.name, extname(file.name))
const baseSlug = slugify(baseName)
// Prepare lesson upserts
const lessonUpserts = supportedFiles
.slice(0, maxLessonsPerCourse)
.map((file, index) => {
const relativePath = relative(coursesRoot, join(modulePath, file.name)).replace(/\\/g, '/')
const mimeType = getMimeType(file.name)
const lessonType = getLessonType(mimeType)
const baseName = basename(file.name, extname(file.name))
const baseSlug = slugify(baseName)
let lessonSlug = baseSlug
if (moduleSeenSlugs.has(lessonSlug)) {
const typedSlug = `${baseSlug}-${lessonType.toLowerCase()}`
lessonSlug = typedSlug
let suffix = 2
while (moduleSeenSlugs.has(lessonSlug)) {
lessonSlug = `${typedSlug}-${suffix}`
suffix += 1
}
let lessonSlug = baseSlug
if (moduleSeenSlugs.has(lessonSlug)) {
const typedSlug = `${baseSlug}-${lessonType.toLowerCase()}`
lessonSlug = typedSlug
let suffix = 2
while (moduleSeenSlugs.has(lessonSlug)) {
lessonSlug = `${typedSlug}-${suffix}`
suffix += 1
}
moduleSeenSlugs.add(lessonSlug)
}
moduleSeenSlugs.add(lessonSlug)
// Check if there's a matching subtitle file
const subtitlePath = subtitleMap.get(baseName) || null
const subtitleTracks = subtitleMap.get(baseName) || []
const defaultSubtitle = subtitleTracks.find(track => track.isDefault) || subtitleTracks[0] || null
return {
where: { moduleId_slug: { moduleId, slug: lessonSlug } },
update: {
title: baseName,
order: index,
filePath: relativePath,
fileName: file.name,
mimeType,
type: lessonType,
duration: null,
thumbnail: null,
subtitlePath,
},
create: {
title: baseName,
slug: lessonSlug,
order: index,
filePath: relativePath,
fileName: file.name,
mimeType,
type: lessonType,
duration: null,
thumbnail: null,
subtitlePath,
moduleId,
},
}
const lesson = await prisma.lesson.upsert({
where: { moduleId_slug: { moduleId, slug: lessonSlug } },
update: {
title: baseName,
order: index,
filePath: relativePath,
fileName: file.name,
mimeType,
type: lessonType,
duration: null,
thumbnail: null,
subtitlePath: defaultSubtitle?.src || null,
},
create: {
title: baseName,
slug: lessonSlug,
order: index,
filePath: relativePath,
fileName: file.name,
mimeType,
type: lessonType,
duration: null,
thumbnail: null,
subtitlePath: defaultSubtitle?.src || null,
moduleId,
},
})
if (lessonUpserts.length > 0) {
await prisma.$transaction(
lessonUpserts.map(upsert => prisma.lesson.upsert(upsert))
)
await prisma.$transaction([
prisma.subtitleTrack.deleteMany({ where: { lessonId: lesson.id } }),
...subtitleTracks.map(track => prisma.subtitleTrack.create({
data: {
lessonId: lesson.id,
src: track.src,
lang: track.lang,
label: track.label,
format: track.format,
isDefault: track.src === defaultSubtitle?.src,
},
})),
])
lessonRecords.push({ slug: lessonSlug, baseName })
}
const moduleShouldSkipQuiz = await shouldSkipQuizGeneration(modulePath, basename(modulePath))
// Update counts for non-quiz lessons first
const newLessons = lessonUpserts.filter(u => !existingLessonMap.has(u.where.moduleId_slug.slug))
const newLessons = lessonRecords.filter(record => !existingLessonMap.has(record.slug))
result.lessonsCreated += newLessons.length
result.lessonsUpdated += lessonUpserts.length - newLessons.length
result.lessonsUpdated += lessonRecords.length - newLessons.length
if (!moduleShouldSkipQuiz) {
if (autoFetchQuizzes) {
@@ -677,7 +698,7 @@ async function scanLessons(
courseRoot: coursesRoot,
topic: basename(modulePath),
source: quizApiSource,
lessonOrder: lessonUpserts.length,
lessonOrder: lessonRecords.length,
autoFetch: autoFetchQuizzes,
})
+227
View File
@@ -0,0 +1,227 @@
import { basename, extname } from 'path'
export type SubtitleFormat = 'vtt' | 'srt'
export type SubtitleTrackInput = {
src: string
lang: string
label: string
format: SubtitleFormat
isDefault: boolean
}
const LANGUAGE_LABELS: Record<string, string> = {
en: 'English',
es: 'Spanish',
fr: 'French',
de: 'German',
it: 'Italian',
pt: 'Portuguese',
'pt-BR': 'Portuguese (Brazil)',
zh: 'Chinese',
'zh-CN': 'Chinese (Simplified)',
'zh-TW': 'Chinese (Traditional)',
ja: 'Japanese',
ko: 'Korean',
fil: 'Filipino',
tl: 'Tagalog',
id: 'Indonesian',
ms: 'Malay',
th: 'Thai',
vi: 'Vietnamese',
ar: 'Arabic',
hi: 'Hindi',
ru: 'Russian',
}
export function normalizeSubtitleLang(code: string): string {
const trimmed = code.trim().replace(/_/g, '-')
if (!trimmed) return 'en'
const [language, region, ...rest] = trimmed.split('-')
const normalizedLanguage = language.toLowerCase()
const normalizedRegion = region ? region.toUpperCase() : undefined
const normalizedRest = rest.map(part => part.length === 2 ? part.toUpperCase() : part)
return [normalizedLanguage, normalizedRegion, ...normalizedRest]
.filter(Boolean)
.join('-')
}
export function languageCodeToLabel(code: string): string {
const normalized = normalizeSubtitleLang(code)
if (LANGUAGE_LABELS[normalized]) return LANGUAGE_LABELS[normalized]
const baseLanguage = normalized.split('-')[0]
if (LANGUAGE_LABELS[baseLanguage]) {
const region = normalized.split('-')[1]
return region ? `${LANGUAGE_LABELS[baseLanguage]} (${region})` : LANGUAGE_LABELS[baseLanguage]
}
return normalized
.split('-')
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
export function getSubtitleFormat(fileName: string): SubtitleFormat | null {
const ext = extname(fileName).slice(1).toLowerCase()
return ext === 'vtt' || ext === 'srt' ? ext : null
}
function tryExactBasename(
videoBaseName: string,
subtitleBaseName: string,
src: string,
format: SubtitleFormat
): SubtitleTrackInput | null {
if (subtitleBaseName === videoBaseName) {
return {
src,
lang: 'en',
label: 'English',
format,
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
const rawLanguage = subtitleBaseName.slice(languagePrefix.length)
if (!rawLanguage || rawLanguage.includes('/')) return null
const lang = normalizeSubtitleLang(rawLanguage)
return {
src,
lang,
label: languageCodeToLabel(lang),
format,
isDefault: lang === 'en',
}
}
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[],
toRelativePath: (fileName: string) => string
): Map<string, SubtitleTrackInput[]> {
const map = new Map<string, SubtitleTrackInput[]>()
const videoBaseNames = videoFileNames.map(fileName => basename(fileName, extname(fileName)))
for (const videoBaseName of videoBaseNames) {
for (const subtitleFileName of subtitleFileNames) {
const track = parseSubtitleForVideo(
videoBaseName,
subtitleFileName,
toRelativePath(subtitleFileName)
)
if (!track) continue
const existingTracks = map.get(videoBaseName) || []
const duplicate = existingTracks.some(existing => existing.lang === track.lang && existing.src === track.src)
if (!duplicate) {
existingTracks.push(track)
}
map.set(videoBaseName, sortSubtitleTracks(existingTracks))
}
}
return map
}
export function sortSubtitleTracks(tracks: SubtitleTrackInput[]): SubtitleTrackInput[] {
return [...tracks].sort((a, b) => {
if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1
if (a.lang === 'en' && b.lang !== 'en') return -1
if (b.lang === 'en' && a.lang !== 'en') return 1
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`
}
+7
View File
@@ -29,6 +29,7 @@
"next-themes": "^0.3.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"server-only": "^0.0.1",
"sharp": "^0.35.1",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7",
@@ -6317,6 +6318,12 @@
"node": ">=10"
}
},
"node_modules/server-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"dev": true,
+1
View File
@@ -33,6 +33,7 @@
"next-themes": "^0.3.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"server-only": "^0.0.1",
"sharp": "^0.35.1",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7",
+17
View File
@@ -71,6 +71,7 @@ model Lesson {
progress Progress[]
bookmarks Bookmark[]
subtitles SubtitleTrack[]
quiz Quiz?
quizAttempts QuizAttempt[]
@@ -79,6 +80,22 @@ model Lesson {
@@index([filePath])
}
model SubtitleTrack {
id String @id @default(cuid())
lessonId String
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
src String
lang String
label String
format String
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([lessonId, lang, src])
@@index([lessonId])
}
model Progress {
id String @id @default(cuid())
userId String @default("local-user")
+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()
})
+42
View File
@@ -0,0 +1,42 @@
import { buildSubtitleTrackMap, convertSrtToVtt, languageCodeToLabel, normalizeSubtitleLang } from '@/lib/subtitles'
function assert(condition: unknown, message: string) {
if (!condition) {
throw new Error(message)
}
}
const tracks = buildSubtitleTrackMap(
['01 - Lesson.mp4', '02 - Other.mp4'],
[
'01 - Lesson.vtt',
'01 - Lesson.en.vtt',
'01 - Lesson.es.vtt',
'01 - Lesson.pt-BR.srt',
'02 - Other.ja.vtt',
'unrelated.en.vtt',
],
(fileName) => `Course/Module/${fileName}`
)
const lessonTracks = tracks.get('01 - Lesson') || []
assert(lessonTracks.length === 4, `expected 4 subtitle tracks for 01 - Lesson, got ${lessonTracks.length}`)
assert(lessonTracks[0].lang === 'en', 'legacy subtitle should become default English track')
assert(lessonTracks[0].isDefault === true, 'first/default English track should be default')
assert(lessonTracks.some(track => track.lang === 'es' && track.label === 'Spanish'), 'Spanish track missing')
assert(lessonTracks.some(track => track.lang === 'pt-BR' && track.label === 'Portuguese (Brazil)'), 'pt-BR track missing')
const otherTracks = tracks.get('02 - Other') || []
assert(otherTracks.length === 1, `expected 1 subtitle track for 02 - Other, got ${otherTracks.length}`)
assert(otherTracks[0].lang === 'ja' && otherTracks[0].label === 'Japanese', 'Japanese track missing')
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')
+59
View File
@@ -0,0 +1,59 @@
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-subtitle-scan-smoke'
const coursesRoot = join(root, 'My_Courses')
const modulePath = join(coursesRoot, 'Subtitle Smoke Course', '01 - Module')
await rm(root, { recursive: true, force: true })
await mkdir(modulePath, { recursive: true })
await writeFile(join(modulePath, '01 - Lesson.mp4'), '')
await writeFile(join(modulePath, '01 - Lesson.vtt'), 'WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nLegacy English\n')
await writeFile(join(modulePath, '01 - Lesson.en.vtt'), 'WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nEnglish\n')
await writeFile(join(modulePath, '01 - Lesson.es.vtt'), 'WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nEspañol\n')
await writeFile(join(modulePath, '01 - Lesson.ja.vtt'), 'WEBVTT\n\n00:00:00.000 --> 00:00:01.000\n日本語\n')
await writeFile(join(modulePath, '01 - Lesson.pt-BR.srt'), '1\n00:00:00,000 --> 00:00:01,000\nPortuguês\n')
await prisma.setting.upsert({
where: { key: 'coursesRoot' },
update: { value: coursesRoot },
create: { key: 'coursesRoot', value: coursesRoot },
})
const result = await scanCoursesDirectory({ maxConcurrency: 1, maxLessonsPerCourse: 10, maxModulesPerCourse: 5 })
assert(result.errors.length === 0, `expected no scan errors, got ${JSON.stringify(result.errors)}`)
const lesson = await prisma.lesson.findFirst({
where: { fileName: '01 - Lesson.mp4' },
include: { subtitles: { orderBy: [{ isDefault: 'desc' }, { label: 'asc' }] } },
})
assert(lesson, 'expected scanned lesson to exist')
assert(lesson?.subtitlePath, 'expected legacy subtitlePath fallback to be set')
assert(lesson?.subtitles.length === 5, `expected 5 subtitle tracks, got ${lesson?.subtitles.length}`)
assert(lesson?.subtitles.some(track => track.lang === 'es' && track.label === 'Spanish'), 'expected Spanish subtitle track')
assert(lesson?.subtitles.some(track => track.lang === 'ja' && track.label === 'Japanese'), 'expected Japanese subtitle track')
assert(lesson?.subtitles.some(track => track.lang === 'pt-BR' && track.label === 'Portuguese (Brazil)' && track.format === 'srt'), 'expected pt-BR SRT subtitle track')
assert(lesson?.subtitles.filter(track => track.isDefault).length === 1, 'expected exactly one default subtitle track')
console.log('subtitle scan verification passed')
}
main()
.catch(error => {
console.error(error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})