mirror of
https://github.com/nicetry247/offlineacademy.git
synced 2026-08-12 03:46:53 +00:00
feat: add multilingual subtitle tracks
This commit is contained in:
+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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user