383 lines
11 KiB
Go
383 lines
11 KiB
Go
package subtitles
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"fmt"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/abadojack/whatlanggo"
|
|
"golang.org/x/text/language"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/lang"
|
|
)
|
|
|
|
// LanguageDetectionSource describes where an upload language was resolved from.
|
|
type LanguageDetectionSource string
|
|
|
|
const (
|
|
LanguageSourceFilename LanguageDetectionSource = "filename"
|
|
LanguageSourceMetadata LanguageDetectionSource = "metadata"
|
|
LanguageSourceContent LanguageDetectionSource = "content"
|
|
LanguageSourceManual LanguageDetectionSource = "manual"
|
|
)
|
|
|
|
// LanguageDetection holds a resolved subtitle language and its origin.
|
|
type LanguageDetection struct {
|
|
Language string `json:"language"`
|
|
Source LanguageDetectionSource `json:"source"`
|
|
}
|
|
|
|
var (
|
|
subtitleTimestampLine = regexp.MustCompile(`^\d{1,2}:\d{2}:\d{2}[,.]\d{3}\s*-->\s*\d{1,2}:\d{2}:\d{2}[,.]\d{3}`)
|
|
assDialoguePrefix = regexp.MustCompile(`(?i)^dialogue:\s*\d`)
|
|
metadataLanguageLine = regexp.MustCompile(`(?i)^(?:language|lang)\s*:\s*(.+)$`)
|
|
vttLanguageLine = regexp.MustCompile(`(?i)language\s*:\s*([^;]+)`)
|
|
)
|
|
|
|
var filenameLanguageSkipTokens = map[string]struct{}{
|
|
"forced": {}, "sdh": {}, "hi": {}, "cc": {}, "sub": {}, "subs": {},
|
|
"subtitle": {}, "subtitles": {}, "caption": {}, "captions": {},
|
|
"the": {}, "and": {}, "for": {}, "with": {},
|
|
}
|
|
|
|
var filenameLanguageReleaseTokens = map[string]struct{}{
|
|
"webrip": {}, "webdl": {}, "web": {}, "bluray": {}, "bdrip": {}, "dvdrip": {},
|
|
"hdtv": {}, "hdrip": {}, "remux": {}, "proper": {}, "repack": {}, "extended": {},
|
|
"unrated": {}, "dts": {}, "aac": {}, "ac3": {}, "eac3": {}, "truehd": {},
|
|
"atmos": {}, "x264": {}, "x265": {}, "h264": {}, "h265": {}, "hevc": {}, "avc": {},
|
|
"720p": {}, "1080p": {}, "2160p": {}, "4k": {}, "8k": {}, "hdr": {}, "sdr": {},
|
|
}
|
|
|
|
// filenameLanguageAliases maps common subtitle release abbreviations to ISO codes.
|
|
var filenameLanguageAliases = map[string]string{
|
|
"chs": "zh", "cht": "zh", "chi": "zh", "zho": "zh", "cn": "zh",
|
|
"eng": "en", "jpn": "ja", "ger": "de", "deu": "de", "fre": "fr", "fra": "fr",
|
|
"spa": "es", "esp": "es", "ita": "it", "por": "pt", "pob": "pt", "br": "pt",
|
|
"rus": "ru", "pol": "pl", "cze": "cs", "ces": "cs", "dan": "da", "dut": "nl",
|
|
"nld": "nl", "swe": "sv", "nor": "no", "fin": "fi", "gre": "el", "ell": "el",
|
|
"rum": "ro", "ron": "ro", "hrv": "hr", "srp": "sr", "bul": "bg", "ukr": "uk",
|
|
"vie": "vi", "ind": "id", "msa": "ms", "may": "ms", "heb": "he", "hin": "hi",
|
|
"kor": "ko", "ara": "ar", "tha": "th", "tur": "tr", "hun": "hu", "slo": "sk",
|
|
"slk": "sk", "slv": "sl", "est": "et", "lav": "lv", "lit": "lt", "ice": "is",
|
|
"isl": "is", "wel": "cy", "cym": "cy", "cat": "ca", "eus": "eu", "baq": "eu",
|
|
}
|
|
|
|
var metadataLanguageNames = map[string]string{
|
|
"english": "en", "spanish": "es", "french": "fr", "german": "de", "italian": "it",
|
|
"portuguese": "pt", "japanese": "ja", "korean": "ko", "chinese": "zh", "russian": "ru",
|
|
"arabic": "ar", "dutch": "nl", "polish": "pl", "swedish": "sv", "norwegian": "no",
|
|
"danish": "da", "finnish": "fi", "greek": "el", "turkish": "tr", "hungarian": "hu",
|
|
"czech": "cs", "romanian": "ro", "hebrew": "he", "hindi": "hi", "thai": "th",
|
|
"vietnamese": "vi", "indonesian": "id", "ukrainian": "uk",
|
|
}
|
|
|
|
// DetectSubtitleLanguage resolves a subtitle language from filename, embedded
|
|
// metadata, or dialogue text.
|
|
func DetectSubtitleLanguage(filename string, format SubtitleFormat, data []byte) LanguageDetection {
|
|
if language, ok := languageFromFilename(filename); ok {
|
|
return LanguageDetection{Language: language, Source: LanguageSourceFilename}
|
|
}
|
|
if language, ok := languageFromMetadata(format, data); ok {
|
|
return LanguageDetection{Language: language, Source: LanguageSourceMetadata}
|
|
}
|
|
if language, ok := languageFromContent(data, format); ok {
|
|
return LanguageDetection{Language: language, Source: LanguageSourceContent}
|
|
}
|
|
return LanguageDetection{}
|
|
}
|
|
|
|
// ResolveUploadLanguage prefers auto-detected language and falls back to the
|
|
// user-provided hint when detection fails. When preferUserLanguage is true, the
|
|
// user-provided language is used as an explicit override.
|
|
func ResolveUploadLanguage(filename string, format SubtitleFormat, data []byte, userLanguage string, preferUserLanguage bool) (LanguageDetection, error) {
|
|
if preferUserLanguage {
|
|
if manual := canonicalLanguageToken(userLanguage); manual != "" {
|
|
return LanguageDetection{Language: manual, Source: LanguageSourceManual}, nil
|
|
}
|
|
return LanguageDetection{}, fmt.Errorf("invalid subtitle language")
|
|
}
|
|
|
|
if detected := DetectSubtitleLanguage(filename, format, data); detected.Language != "" {
|
|
return detected, nil
|
|
}
|
|
if manual := canonicalLanguageToken(userLanguage); manual != "" {
|
|
return LanguageDetection{Language: manual, Source: LanguageSourceManual}, nil
|
|
}
|
|
return LanguageDetection{}, fmt.Errorf("could not detect subtitle language")
|
|
}
|
|
|
|
func languageFromFilename(filename string) (string, bool) {
|
|
base := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))
|
|
tokens := splitFilenameTokens(base)
|
|
for i := len(tokens) - 1; i >= 0; i-- {
|
|
token := strings.Trim(tokens[i], "[](){}")
|
|
if token == "" {
|
|
continue
|
|
}
|
|
lower := strings.ToLower(token)
|
|
if _, skip := filenameLanguageSkipTokens[lower]; skip {
|
|
continue
|
|
}
|
|
if _, skip := filenameLanguageReleaseTokens[lower]; skip {
|
|
continue
|
|
}
|
|
if containsDigit(token) {
|
|
continue
|
|
}
|
|
if language, ok := filenameLanguageToken(token); ok {
|
|
return language, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func filenameLanguageToken(token string) (string, bool) {
|
|
trimmed := strings.TrimSpace(token)
|
|
if len(trimmed) < 2 || len(trimmed) > 3 {
|
|
return "", false
|
|
}
|
|
for _, r := range trimmed {
|
|
if r < 'A' || r > 'Z' {
|
|
if r < 'a' || r > 'z' {
|
|
return "", false
|
|
}
|
|
}
|
|
}
|
|
lower := strings.ToLower(trimmed)
|
|
if mapped, ok := filenameLanguageAliases[lower]; ok {
|
|
return mapped, true
|
|
}
|
|
language := canonicalLanguageToken(trimmed)
|
|
if language == "" {
|
|
return "", false
|
|
}
|
|
return language, true
|
|
}
|
|
|
|
func containsDigit(value string) bool {
|
|
for _, r := range value {
|
|
if r >= '0' && r <= '9' {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func languageFromMetadata(format SubtitleFormat, data []byte) (string, bool) {
|
|
switch format {
|
|
case FormatASS, FormatSSA:
|
|
return languageFromASSMetadata(data)
|
|
case FormatVTT:
|
|
return languageFromVTTMetadata(data)
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func languageFromASSMetadata(data []byte) (string, bool) {
|
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(strings.ToLower(line), "[") {
|
|
continue
|
|
}
|
|
if matches := metadataLanguageLine.FindStringSubmatch(line); len(matches) == 2 {
|
|
if language, ok := languageFromMetadataValue(matches[1]); ok {
|
|
return language, true
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func languageFromVTTMetadata(data []byte) (string, bool) {
|
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || strings.EqualFold(line, "WEBVTT") {
|
|
continue
|
|
}
|
|
if matches := vttLanguageLine.FindStringSubmatch(line); len(matches) == 2 {
|
|
if language, ok := languageFromMetadataValue(matches[1]); ok {
|
|
return language, true
|
|
}
|
|
}
|
|
if strings.Contains(line, "-->") {
|
|
break
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func languageFromContent(data []byte, format SubtitleFormat) (string, bool) {
|
|
text := extractSubtitleDialogue(data, format)
|
|
if len([]rune(text)) < 40 {
|
|
return "", false
|
|
}
|
|
|
|
info := whatlanggo.Detect(text)
|
|
if !info.IsReliable() {
|
|
return "", false
|
|
}
|
|
|
|
code := info.Lang.Iso6391()
|
|
if code == "" {
|
|
code = info.Lang.Iso6393()
|
|
}
|
|
if language := canonicalLanguageToken(code); language != "" {
|
|
return language, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func extractSubtitleDialogue(data []byte, format SubtitleFormat) string {
|
|
switch format {
|
|
case FormatASS, FormatSSA:
|
|
return extractASSDialogue(data)
|
|
case FormatVTT:
|
|
return extractVTTDialogue(data)
|
|
default:
|
|
return extractSRTDialogue(data)
|
|
}
|
|
}
|
|
|
|
func extractSRTDialogue(data []byte) string {
|
|
var b strings.Builder
|
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || subtitleTimestampLine.MatchString(line) {
|
|
continue
|
|
}
|
|
if _, err := fmt.Sscanf(line, "%d", new(int)); err == nil {
|
|
continue
|
|
}
|
|
appendDialogueLine(&b, line)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func extractVTTDialogue(data []byte) string {
|
|
var b strings.Builder
|
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
|
inCue := false
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
inCue = false
|
|
continue
|
|
}
|
|
if strings.EqualFold(line, "WEBVTT") || strings.HasPrefix(strings.ToUpper(line), "NOTE") {
|
|
continue
|
|
}
|
|
if strings.Contains(line, "-->") {
|
|
inCue = true
|
|
continue
|
|
}
|
|
if inCue {
|
|
appendDialogueLine(&b, line)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func extractASSDialogue(data []byte) string {
|
|
var b strings.Builder
|
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if !assDialoguePrefix.MatchString(line) {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, ",", 10)
|
|
if len(parts) < 10 {
|
|
continue
|
|
}
|
|
appendDialogueLine(&b, strings.TrimSpace(parts[9]))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func appendDialogueLine(b *strings.Builder, line string) {
|
|
cleaned := strings.TrimSpace(stripASSTags(line))
|
|
if cleaned == "" {
|
|
return
|
|
}
|
|
if b.Len() > 0 {
|
|
b.WriteByte(' ')
|
|
}
|
|
b.WriteString(cleaned)
|
|
}
|
|
|
|
func stripASSTags(line string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(line))
|
|
inTag := false
|
|
for _, r := range line {
|
|
switch {
|
|
case r == '{':
|
|
inTag = true
|
|
case r == '}':
|
|
inTag = false
|
|
case !inTag:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func splitFilenameTokens(base string) []string {
|
|
replaced := strings.NewReplacer("_", ".", "-", ".", " ", ".").Replace(base)
|
|
raw := strings.Split(replaced, ".")
|
|
tokens := make([]string, 0, len(raw))
|
|
for _, token := range raw {
|
|
token = strings.TrimSpace(token)
|
|
if token != "" {
|
|
tokens = append(tokens, token)
|
|
}
|
|
}
|
|
return tokens
|
|
}
|
|
|
|
func languageFromMetadataValue(value string) (string, bool) {
|
|
if language := canonicalLanguageToken(value); language != "" {
|
|
return language, true
|
|
}
|
|
if mapped, ok := metadataLanguageNames[strings.ToLower(strings.TrimSpace(value))]; ok {
|
|
return mapped, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// NormalizeLanguageCode canonicalizes a subtitle language code to ISO 639-1 base form.
|
|
func NormalizeLanguageCode(value string) (string, error) {
|
|
language := canonicalLanguageToken(value)
|
|
if language == "" {
|
|
return "", fmt.Errorf("invalid subtitle language")
|
|
}
|
|
return language, nil
|
|
}
|
|
|
|
func canonicalLanguageToken(value string) string {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
candidate := lang.Canonical(trimmed)
|
|
tag, err := language.Parse(candidate)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
base, conf := tag.Base()
|
|
if conf == language.No {
|
|
return ""
|
|
}
|
|
return strings.ToLower(base.String())
|
|
}
|