stash/pkg/file/video/caption.go

192 lines
5.6 KiB
Go

package video
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/asticode/go-astisub"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/txn"
"golang.org/x/text/language"
)
var CaptionExts = []string{"vtt", "srt"} // in a case where vtt and srt files are both provided prioritize vtt file due to native support
// to be used for captions without a language code in the filename
// ISO 639-1 uses 2 or 3 a-z chars for codes so 00 is a safe non valid choise
// https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
const LangUnknown = "00"
// GetCaptionPath generates the path of a caption
// from a given file path, wanted language and caption sufffix
func GetCaptionPath(path, lang, suffix string) string {
ext := filepath.Ext(path)
fn := strings.TrimSuffix(path, ext)
captionExt := ""
if len(lang) == 0 || lang == LangUnknown {
captionExt = suffix
} else {
captionExt = lang + "." + suffix
}
return fn + "." + captionExt
}
// ReadSubs reads a captions file
func ReadSubs(path string) (*astisub.Subtitles, error) {
return astisub.OpenFile(path)
}
// IsValidLanguage checks whether the given string is a valid
// ISO 639 language code
func IsValidLanguage(lang string) bool {
_, err := language.ParseBase(lang)
return err == nil
}
// IsLangInCaptions returns true if lang is present
// in the captions
func IsLangInCaptions(lang string, ext string, captions []*models.VideoCaption) bool {
for _, caption := range captions {
if lang == caption.LanguageCode && ext == caption.CaptionType {
return true
}
}
return false
}
// getCaptionPrefix returns the prefix used to search for video files for the provided caption path
func getCaptionPrefix(captionPath string) string {
basename := strings.TrimSuffix(captionPath, filepath.Ext(captionPath)) // caption filename without the extension
// a caption file can be something like scene_filename.srt or scene_filename.en.srt
// if a language code is present and valid remove it from the basename
languageExt := filepath.Ext(basename)
if len(languageExt) > 2 && IsValidLanguage(languageExt[1:]) {
basename = strings.TrimSuffix(basename, languageExt)
}
return basename + "."
}
// GetCaptionsLangFromPath returns the language code from a given captions path
// If no valid language is present LangUknown is returned
func getCaptionsLangFromPath(captionPath string) string {
langCode := LangUnknown
basename := strings.TrimSuffix(captionPath, filepath.Ext(captionPath)) // caption filename without the extension
languageExt := filepath.Ext(basename)
if len(languageExt) > 2 && IsValidLanguage(languageExt[1:]) {
langCode = languageExt[1:]
}
return langCode
}
type CaptionUpdater interface {
GetCaptions(ctx context.Context, fileID models.FileID) ([]*models.VideoCaption, error)
UpdateCaptions(ctx context.Context, fileID models.FileID, captions []*models.VideoCaption) error
}
// associates captions to scene/s with the same basename
func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manager, fqb models.FileFinder, w CaptionUpdater) {
captionLang := getCaptionsLangFromPath(captionPath)
captionPrefix := getCaptionPrefix(captionPath)
if err := txn.WithTxn(ctx, txnMgr, func(ctx context.Context) error {
var err error
files, er := fqb.FindAllByPath(ctx, captionPrefix+"*")
if er != nil {
return fmt.Errorf("searching for scene %s: %w", captionPrefix, er)
}
for _, f := range files {
// found some files
// filter out non video files
switch f.(type) {
case *models.VideoFile:
break
default:
continue
}
fileID := f.Base().ID
path := f.Base().Path
logger.Debugf("Matched captions to file %s", path)
captions, er := w.GetCaptions(ctx, fileID)
if er == nil {
fileExt := filepath.Ext(captionPath)
ext := fileExt[1:]
if !IsLangInCaptions(captionLang, ext, captions) { // only update captions if language code is not present
newCaption := &models.VideoCaption{
LanguageCode: captionLang,
Filename: filepath.Base(captionPath),
CaptionType: ext,
}
captions = append(captions, newCaption)
er = w.UpdateCaptions(ctx, fileID, captions)
if er == nil {
logger.Debugf("Updated captions for file %s. Added %s", path, captionLang)
}
}
}
}
return err
}); err != nil {
logger.Error(err.Error())
}
}
// CleanCaptions removes non existent/accessible language codes from captions
func CleanCaptions(ctx context.Context, f *models.VideoFile, txnMgr txn.Manager, w CaptionUpdater) error {
captions, err := w.GetCaptions(ctx, f.ID)
if err != nil {
return fmt.Errorf("getting captions for file %s: %w", f.Path, err)
}
if len(captions) == 0 {
return nil
}
filePath := f.Path
changed := false
var newCaptions []*models.VideoCaption
for _, caption := range captions {
captionPath := caption.Path(filePath)
_, err := os.Stat(captionPath)
if errors.Is(err, os.ErrNotExist) {
logger.Infof("Removing non existent caption %s for %s", caption.Filename, f.Path)
changed = true
} else {
// other errors are ignored for the purposes of cleaning
newCaptions = append(newCaptions, caption)
}
}
if changed {
fn := func(ctx context.Context) error {
return w.UpdateCaptions(ctx, f.ID, newCaptions)
}
// possible that we are already in a transaction and txnMgr is nil
// in that case just call the function directly
if txnMgr == nil {
err = fn(ctx)
} else {
err = txn.WithTxn(ctx, txnMgr, fn)
}
if err != nil {
return fmt.Errorf("updating captions for file %s: %w", f.Path, err)
}
}
return nil
}