mirror of
https://github.com/nicetry247/offlineacademy.git
synced 2026-07-26 11:58:44 +00:00
feat: add multilingual subtitle tracks
This commit is contained in:
@@ -445,6 +445,9 @@ My_Courses/
|
|||||||
│ └── 01 - Welcome.mp4
|
│ └── 01 - Welcome.mp4
|
||||||
├── 02 - Core Concepts/
|
├── 02 - Core Concepts/
|
||||||
│ ├── 01 - Lesson One.mp4
|
│ ├── 01 - Lesson One.mp4
|
||||||
|
│ ├── 01 - Lesson One.en.vtt
|
||||||
|
│ ├── 01 - Lesson One.es.vtt
|
||||||
|
│ ├── 01 - Lesson One.ja.vtt
|
||||||
│ └── 02 - Lesson Two.mp4
|
│ └── 02 - Lesson Two.mp4
|
||||||
└── 03 - Practice/
|
└── 03 - Practice/
|
||||||
└── 01 - Lab Walkthrough.mp4
|
└── 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.
|
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
|
## Data & Persistence
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ const liveGroups: FeatureGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
'Bookmarks with timestamp notes',
|
'Bookmarks with timestamp notes',
|
||||||
'Jump-to-time bookmark playback',
|
'Jump-to-time bookmark playback',
|
||||||
'Subtitle support for .srt and .vtt',
|
'Multi-language subtitle tracks for .vtt/.srt files',
|
||||||
'Inline document viewing for lessons',
|
'Inline document viewing for lessons',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-2
@@ -182,7 +182,8 @@ export default function ScanPage() {
|
|||||||
├── Course Name 1/
|
├── Course Name 1/
|
||||||
│ ├── 01 - Introduction/
|
│ ├── 01 - Introduction/
|
||||||
│ │ ├── 01 - Introduction.mp4
|
│ │ ├── 01 - Introduction.mp4
|
||||||
│ │ ├── 01 - Introduction.srt
|
│ │ ├── 01 - Introduction.en.vtt
|
||||||
|
│ │ ├── 01 - Introduction.es.vtt
|
||||||
│ │ ├── 02 - Overview.pdf
|
│ │ ├── 02 - Overview.pdf
|
||||||
│ │ └── 03 - Notes.txt
|
│ │ └── 03 - Notes.txt
|
||||||
│ ├── 02 - Advanced/
|
│ ├── 02 - Advanced/
|
||||||
@@ -204,7 +205,8 @@ export default function ScanPage() {
|
|||||||
</pre>
|
</pre>
|
||||||
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
|
<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>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>
|
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ import Link from 'next/link'
|
|||||||
import { formatDuration, formatTime, cn } from '@/lib/utils'
|
import { formatDuration, formatTime, cn } from '@/lib/utils'
|
||||||
import { getCourseDisplayName } from '@/lib/course-display'
|
import { getCourseDisplayName } from '@/lib/course-display'
|
||||||
|
|
||||||
|
interface SubtitleTrackType {
|
||||||
|
id?: string
|
||||||
|
src: string
|
||||||
|
lang: string
|
||||||
|
label: string
|
||||||
|
format?: string
|
||||||
|
isDefault: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface LessonType {
|
interface LessonType {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
@@ -33,6 +42,7 @@ interface LessonType {
|
|||||||
filePath: string
|
filePath: string
|
||||||
thumbnail: string | null
|
thumbnail: string | null
|
||||||
subtitlePath: string | null
|
subtitlePath: string | null
|
||||||
|
subtitles?: SubtitleTrackType[]
|
||||||
progress?: { completed: boolean; position: number; lastWatched: string } | null
|
progress?: { completed: boolean; position: number; lastWatched: string } | null
|
||||||
quiz?: {
|
quiz?: {
|
||||||
version: 1
|
version: 1
|
||||||
@@ -118,13 +128,28 @@ function markCompleteFn(lessonId: string, courseId: string, moduleId: string, po
|
|||||||
}
|
}
|
||||||
|
|
||||||
function VideoContent({ lesson, lessonProgress, onSave, onEnd, onTimeUpdate, seekRequest }: {
|
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 }
|
lessonProgress: { completed: boolean; position: number; lastWatched: string }
|
||||||
onSave: (position: number) => void
|
onSave: (position: number) => void
|
||||||
onEnd: () => void
|
onEnd: () => void
|
||||||
onTimeUpdate: (time: number) => void
|
onTimeUpdate: (time: number) => void
|
||||||
seekRequest: { time: number; nonce: number } | null
|
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 (
|
return (
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
src={'/api/files/' + lesson.filePath}
|
src={'/api/files/' + lesson.filePath}
|
||||||
@@ -135,7 +160,7 @@ function VideoContent({ lesson, lessonProgress, onSave, onEnd, onTimeUpdate, see
|
|||||||
onProgressSave={onSave}
|
onProgressSave={onSave}
|
||||||
onEnded={onEnd}
|
onEnded={onEnd}
|
||||||
seekRequest={seekRequest}
|
seekRequest={seekRequest}
|
||||||
subtitles={lesson.subtitlePath ? '/api/files/' + lesson.subtitlePath : undefined}
|
subtitles={subtitleTracks}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export default async function WatchPage({ params, searchParams }: WatchPageProps
|
|||||||
progress: {
|
progress: {
|
||||||
where: { userId: 'local-user' },
|
where: { userId: 'local-user' },
|
||||||
},
|
},
|
||||||
|
subtitles: {
|
||||||
|
orderBy: [
|
||||||
|
{ isDefault: 'desc' },
|
||||||
|
{ label: 'asc' },
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,17 @@ import { cn, formatTime } from '@/lib/utils'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { KeyboardShortcutsOverlay } from '@/components/KeyboardShortcutsOverlay'
|
import { KeyboardShortcutsOverlay } from '@/components/KeyboardShortcutsOverlay'
|
||||||
|
|
||||||
|
interface VideoSubtitleTrack {
|
||||||
|
src: string
|
||||||
|
srcLang: string
|
||||||
|
label: string
|
||||||
|
default?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface VideoPlayerProps {
|
interface VideoPlayerProps {
|
||||||
src: string
|
src: string
|
||||||
poster?: string
|
poster?: string
|
||||||
subtitles?: string
|
subtitles?: VideoSubtitleTrack[]
|
||||||
onTimeUpdate?: (time: number) => void
|
onTimeUpdate?: (time: number) => void
|
||||||
onEnded?: () => void
|
onEnded?: () => void
|
||||||
onProgressSave?: (time: number) => void
|
onProgressSave?: (time: number) => void
|
||||||
@@ -46,6 +53,10 @@ export function VideoPlayer({
|
|||||||
const [isPiPActive, setIsPiPActive] = useState(false)
|
const [isPiPActive, setIsPiPActive] = useState(false)
|
||||||
const [hoverProgress, setHoverProgress] = useState<{ time: number; x: number } | null>(null)
|
const [hoverProgress, setHoverProgress] = useState<{ time: number; x: number } | null>(null)
|
||||||
const [showSettingsMenu, setShowSettingsMenu] = useState(false)
|
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 settingsMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||||
const progressSaveTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
const progressSaveTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||||
@@ -385,6 +396,20 @@ export function VideoPlayer({
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [showSettingsMenu])
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="video-player-container"
|
className="video-player-container"
|
||||||
@@ -410,9 +435,16 @@ export function VideoPlayer({
|
|||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
className="w-full h-full cursor-pointer"
|
className="w-full h-full cursor-pointer"
|
||||||
>
|
>
|
||||||
{subtitles && (
|
{subtitles?.map((track, index) => (
|
||||||
<track kind="subtitles" src={subtitles} srcLang="en" label="English" default />
|
<track
|
||||||
)}
|
key={`${track.srcLang}-${track.src}`}
|
||||||
|
kind="subtitles"
|
||||||
|
src={track.src}
|
||||||
|
srcLang={track.srcLang}
|
||||||
|
label={track.label}
|
||||||
|
default={selectedSubtitle === index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -589,7 +621,7 @@ export function VideoPlayer({
|
|||||||
|
|
||||||
{showSettingsMenu && (
|
{showSettingsMenu && (
|
||||||
<div
|
<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"
|
role="menu"
|
||||||
>
|
>
|
||||||
<div className="px-3 py-2 text-xs font-semibold text-white/60 uppercase tracking-wider">Speed</div>
|
<div className="px-3 py-2 text-xs font-semibold text-white/60 uppercase tracking-wider">Speed</div>
|
||||||
@@ -611,6 +643,39 @@ export function VideoPlayer({
|
|||||||
{playbackRate === rate && <Check className="h-4 w-4" />}
|
{playbackRate === rate && <Check className="h-4 w-4" />}
|
||||||
</button>
|
</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" />
|
<hr className="border-white/10 my-1" />
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowShortcuts(true)}
|
onClick={() => setShowShortcuts(true)}
|
||||||
|
|||||||
+87
-79
@@ -8,6 +8,7 @@ import { getLocalThumbnailUrl } from '@/lib/thumbnail-index-server'
|
|||||||
import { ensureModuleQuizCache, syncQuizLessonFromCache, shouldSkipQuizGeneration } from '@/lib/quiz'
|
import { ensureModuleQuizCache, syncQuizLessonFromCache, shouldSkipQuizGeneration } from '@/lib/quiz'
|
||||||
import { applyCourseMetadata, isMetadataFileName, readCourseMetadataFile } from '@/lib/course-metadata'
|
import { applyCourseMetadata, isMetadataFileName, readCourseMetadataFile } from '@/lib/course-metadata'
|
||||||
import { getLocalCourseThumbnailPath, ensurePublicThumbnail } from '@/lib/course-thumbnail-utils'
|
import { getLocalCourseThumbnailPath, ensurePublicThumbnail } from '@/lib/course-thumbnail-utils'
|
||||||
|
import { buildSubtitleTrackMap } from '@/lib/subtitles'
|
||||||
import { writeFile, mkdir } from 'fs/promises'
|
import { writeFile, mkdir } from 'fs/promises'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
|
|
||||||
@@ -557,16 +558,31 @@ async function scanLessons(
|
|||||||
.filter(e => e.isFile() && !e.name.startsWith('.'))
|
.filter(e => e.isFile() && !e.name.startsWith('.'))
|
||||||
.sort((a, b) => naturalSort(a.name, b.name))
|
.sort((a, b) => naturalSort(a.name, b.name))
|
||||||
|
|
||||||
// Build a map of subtitle files by their base name (for matching with videos)
|
const subtitleFiles = files.filter(file => ['srt', 'vtt'].includes(extname(file.name).slice(1).toLowerCase()))
|
||||||
const subtitleMap = new Map<string, string>()
|
|
||||||
for (const file of files) {
|
// Filter files to only include supported lesson types. Subtitle files are attached to videos, not created as lessons.
|
||||||
const ext = extname(file.name).slice(1).toLowerCase()
|
const supportedFiles = files.filter(file => {
|
||||||
if (['srt', 'vtt'].includes(ext)) {
|
if (file.name === 'quiz_cache.json' || isMetadataFileName(file.name)) {
|
||||||
const baseName = basename(file.name, extname(file.name))
|
return false
|
||||||
const relativePath = relative(coursesRoot, join(modulePath, file.name)).replace(/\\/g, '/')
|
|
||||||
subtitleMap.set(baseName, relativePath)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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
|
// Get existing lessons in one query
|
||||||
const existingLessons = await prisma.lesson.findMany({
|
const existingLessons = await prisma.lesson.findMany({
|
||||||
@@ -575,90 +591,82 @@ async function scanLessons(
|
|||||||
})
|
})
|
||||||
const existingLessonMap = new Map(existingLessons.map(l => [l.slug, l]))
|
const existingLessonMap = new Map(existingLessons.map(l => [l.slug, l]))
|
||||||
|
|
||||||
// Filter files to only include supported types
|
const lessonRecords: Array<{ slug: string; baseName: string }> = []
|
||||||
const supportedFiles = files.filter(file => {
|
|
||||||
if (file.name === 'quiz_cache.json' || isMetadataFileName(file.name)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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 mimeType = getMimeType(file.name)
|
||||||
const lessonType = getLessonType(mimeType)
|
const lessonType = getLessonType(mimeType)
|
||||||
if (lessonType === 'OTHER' && !['json', 'txt', 'srt', 'vtt'].includes(extname(file.name).slice(1))) {
|
const baseName = basename(file.name, extname(file.name))
|
||||||
return false
|
const baseSlug = slugify(baseName)
|
||||||
}
|
|
||||||
// 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
|
|
||||||
})
|
|
||||||
|
|
||||||
// Prepare lesson upserts
|
let lessonSlug = baseSlug
|
||||||
const lessonUpserts = supportedFiles
|
if (moduleSeenSlugs.has(lessonSlug)) {
|
||||||
.slice(0, maxLessonsPerCourse)
|
const typedSlug = `${baseSlug}-${lessonType.toLowerCase()}`
|
||||||
.map((file, index) => {
|
lessonSlug = typedSlug
|
||||||
const relativePath = relative(coursesRoot, join(modulePath, file.name)).replace(/\\/g, '/')
|
let suffix = 2
|
||||||
const mimeType = getMimeType(file.name)
|
while (moduleSeenSlugs.has(lessonSlug)) {
|
||||||
const lessonType = getLessonType(mimeType)
|
lessonSlug = `${typedSlug}-${suffix}`
|
||||||
const baseName = basename(file.name, extname(file.name))
|
suffix += 1
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
moduleSeenSlugs.add(lessonSlug)
|
}
|
||||||
|
moduleSeenSlugs.add(lessonSlug)
|
||||||
|
|
||||||
// Check if there's a matching subtitle file
|
const subtitleTracks = subtitleMap.get(baseName) || []
|
||||||
const subtitlePath = subtitleMap.get(baseName) || null
|
const defaultSubtitle = subtitleTracks.find(track => track.isDefault) || subtitleTracks[0] || null
|
||||||
|
|
||||||
return {
|
const lesson = await prisma.lesson.upsert({
|
||||||
where: { moduleId_slug: { moduleId, slug: lessonSlug } },
|
where: { moduleId_slug: { moduleId, slug: lessonSlug } },
|
||||||
update: {
|
update: {
|
||||||
title: baseName,
|
title: baseName,
|
||||||
order: index,
|
order: index,
|
||||||
filePath: relativePath,
|
filePath: relativePath,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
mimeType,
|
mimeType,
|
||||||
type: lessonType,
|
type: lessonType,
|
||||||
duration: null,
|
duration: null,
|
||||||
thumbnail: null,
|
thumbnail: null,
|
||||||
subtitlePath,
|
subtitlePath: defaultSubtitle?.src || null,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
title: baseName,
|
title: baseName,
|
||||||
slug: lessonSlug,
|
slug: lessonSlug,
|
||||||
order: index,
|
order: index,
|
||||||
filePath: relativePath,
|
filePath: relativePath,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
mimeType,
|
mimeType,
|
||||||
type: lessonType,
|
type: lessonType,
|
||||||
duration: null,
|
duration: null,
|
||||||
thumbnail: null,
|
thumbnail: null,
|
||||||
subtitlePath,
|
subtitlePath: defaultSubtitle?.src || null,
|
||||||
moduleId,
|
moduleId,
|
||||||
},
|
},
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (lessonUpserts.length > 0) {
|
await prisma.$transaction([
|
||||||
await prisma.$transaction(
|
prisma.subtitleTrack.deleteMany({ where: { lessonId: lesson.id } }),
|
||||||
lessonUpserts.map(upsert => prisma.lesson.upsert(upsert))
|
...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))
|
const moduleShouldSkipQuiz = await shouldSkipQuizGeneration(modulePath, basename(modulePath))
|
||||||
|
|
||||||
// Update counts for non-quiz lessons first
|
// 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.lessonsCreated += newLessons.length
|
||||||
result.lessonsUpdated += lessonUpserts.length - newLessons.length
|
result.lessonsUpdated += lessonRecords.length - newLessons.length
|
||||||
|
|
||||||
if (!moduleShouldSkipQuiz) {
|
if (!moduleShouldSkipQuiz) {
|
||||||
if (autoFetchQuizzes) {
|
if (autoFetchQuizzes) {
|
||||||
@@ -677,7 +685,7 @@ async function scanLessons(
|
|||||||
courseRoot: coursesRoot,
|
courseRoot: coursesRoot,
|
||||||
topic: basename(modulePath),
|
topic: basename(modulePath),
|
||||||
source: quizApiSource,
|
source: quizApiSource,
|
||||||
lessonOrder: lessonUpserts.length,
|
lessonOrder: lessonRecords.length,
|
||||||
autoFetch: autoFetchQuizzes,
|
autoFetch: autoFetchQuizzes,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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<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
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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)
|
||||||
|
})
|
||||||
|
}
|
||||||
Generated
+7
@@ -29,6 +29,7 @@
|
|||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.35.1",
|
"sharp": "^0.35.1",
|
||||||
"tailwind-merge": "^2.2.0",
|
"tailwind-merge": "^2.2.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@@ -6317,6 +6318,12 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.35.1",
|
"sharp": "^0.35.1",
|
||||||
"tailwind-merge": "^2.2.0",
|
"tailwind-merge": "^2.2.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ model Lesson {
|
|||||||
|
|
||||||
progress Progress[]
|
progress Progress[]
|
||||||
bookmarks Bookmark[]
|
bookmarks Bookmark[]
|
||||||
|
subtitles SubtitleTrack[]
|
||||||
quiz Quiz?
|
quiz Quiz?
|
||||||
quizAttempts QuizAttempt[]
|
quizAttempts QuizAttempt[]
|
||||||
|
|
||||||
@@ -79,6 +80,22 @@ model Lesson {
|
|||||||
@@index([filePath])
|
@@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 {
|
model Progress {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String @default("local-user")
|
userId String @default("local-user")
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user