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
|
||||
├── 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
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
|
||||
+4
-2
@@ -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() {
|
||||
</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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ export default async function WatchPage({ params, searchParams }: WatchPageProps
|
||||
progress: {
|
||||
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 { 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,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 (
|
||||
<div
|
||||
className="video-player-container"
|
||||
@@ -410,9 +435,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>
|
||||
|
||||
@@ -589,7 +621,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 +643,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)}
|
||||
|
||||
+87
-79
@@ -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<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 +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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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",
|
||||
"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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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