diff --git a/README.md b/README.md
index 84fb564..a24d283 100644
--- a/README.md
+++ b/README.md
@@ -445,6 +445,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 +455,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. Use `.vtt` for the best browser compatibility; `.srt` files are detected as best-effort tracks.
+
+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
diff --git a/app/progress/page.tsx b/app/progress/page.tsx
index ef7aa32..11857c4 100644
--- a/app/progress/page.tsx
+++ b/app/progress/page.tsx
@@ -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',
],
},
diff --git a/app/scan/page.tsx b/app/scan/page.tsx
index 2957833..cd15bf8 100755
--- a/app/scan/page.tsx
+++ b/app/scan/page.tsx
@@ -182,7 +182,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 +205,8 @@ export default function ScanPage() {
How the scanner reads it: course folder → module folder → lesson files.
-
Matching rule: subtitle files should share the same base name as the video, like lesson.mp4 + lesson.srt.
+
Matching rule: subtitle files can either share the exact video base name, like lesson.mp4 + lesson.vtt, or use language suffixes for multiple tracks, like lesson.en.vtt + lesson.es.vtt.
+
Subtitle formats: .vtt is preferred for browser playback; .srt files are detected and listed as best-effort tracks.
Sorting: numeric prefixes like 01, 02, 10 keep lessons in learning order.
diff --git a/app/watch/[lessonId]/WatchPageClient.tsx b/app/watch/[lessonId]/WatchPageClient.tsx
index f87290a..0f582cf 100755
--- a/app/watch/[lessonId]/WatchPageClient.tsx
+++ b/app/watch/[lessonId]/WatchPageClient.tsx
@@ -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 (
)
}
diff --git a/app/watch/[lessonId]/page.tsx b/app/watch/[lessonId]/page.tsx
index e021458..866ed28 100755
--- a/app/watch/[lessonId]/page.tsx
+++ b/app/watch/[lessonId]/page.tsx
@@ -22,6 +22,12 @@ export default async function WatchPage({ params, searchParams }: WatchPageProps
progress: {
where: { userId: 'local-user' },
},
+ subtitles: {
+ orderBy: [
+ { isDefault: 'desc' },
+ { label: 'asc' },
+ ],
+ },
},
})
diff --git a/components/VideoPlayer.tsx b/components/VideoPlayer.tsx
index 6f148c2..7a5d1c8 100755
--- a/components/VideoPlayer.tsx
+++ b/components/VideoPlayer.tsx
@@ -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(() => {
+ const defaultIndex = subtitles?.findIndex(track => track.default) ?? -1
+ return defaultIndex >= 0 ? defaultIndex : 'off'
+ })
const settingsMenuRef = useRef(null)
const controlsTimeoutRef = useRef>()
const progressSaveTimeoutRef = useRef>()
@@ -385,6 +396,20 @@ 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])
+
return (
- {subtitles && (
-
- )}
+ {subtitles?.map((track, index) => (
+
+ ))}
@@ -589,7 +621,7 @@ export function VideoPlayer({
{showSettingsMenu && (
Speed
@@ -611,6 +643,39 @@ export function VideoPlayer({
{playbackRate === rate &&
}
))}
+ {subtitles && subtitles.length > 0 && (
+ <>
+
+
Subtitles
+
{
+ 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'}
+ >
+ Off
+ {selectedSubtitle === 'off' && }
+
+ {subtitles.map((track, index) => (
+
{
+ 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}
+ >
+ {track.label}
+ {selectedSubtitle === index && }
+
+ ))}
+ >
+ )}
setShowShortcuts(true)}
diff --git a/lib/scanner.ts b/lib/scanner.ts
index 684ac80..d54d597 100755
--- a/lib/scanner.ts
+++ b/lib/scanner.ts
@@ -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'
@@ -557,16 +558,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()
- 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 +591,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 +685,7 @@ async function scanLessons(
courseRoot: coursesRoot,
topic: basename(modulePath),
source: quizApiSource,
- lessonOrder: lessonUpserts.length,
+ lessonOrder: lessonRecords.length,
autoFetch: autoFetchQuizzes,
})
diff --git a/lib/subtitles.ts b/lib/subtitles.ts
new file mode 100644
index 0000000..8d5a37b
--- /dev/null
+++ b/lib/subtitles.ts
@@ -0,0 +1,144 @@
+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 = {
+ 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
+}
+
+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))
+
+ if (subtitleBaseName === videoBaseName) {
+ return {
+ src,
+ lang: 'en',
+ label: 'English',
+ format,
+ isDefault: true,
+ }
+ }
+
+ 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',
+ }
+}
+
+export function buildSubtitleTrackMap(
+ videoFileNames: string[],
+ subtitleFileNames: string[],
+ toRelativePath: (fileName: string) => string
+): Map {
+ const map = new Map()
+ 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)
+ })
+}
diff --git a/package-lock.json b/package-lock.json
index a028d01..c60195a 100755
--- a/package-lock.json
+++ b/package-lock.json
@@ -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,
diff --git a/package.json b/package.json
index f460612..ee27ac4 100755
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 1f08c2e..32756e8 100755
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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")
diff --git a/scripts/verify-subtitle-parser.ts b/scripts/verify-subtitle-parser.ts
new file mode 100644
index 0000000..c69e61d
--- /dev/null
+++ b/scripts/verify-subtitle-parser.ts
@@ -0,0 +1,37 @@
+import { buildSubtitleTrackMap, 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')
+
+console.log('subtitle parser verification passed')
diff --git a/scripts/verify-subtitle-scan.ts b/scripts/verify-subtitle-scan.ts
new file mode 100644
index 0000000..bdbd3c9
--- /dev/null
+++ b/scripts/verify-subtitle-scan.ts
@@ -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()
+ })