package media import ( "fmt" "path/filepath" "strings" "unicode" ) const ( maxSeriesBytes = 90 maxEpisodeBytes = 140 maxEpisodeIDBytes = 64 maxFilenameBytes = 255 ) // RelativeVideoPath は worker と web が共有する保存先の相対パスを返す。 func RelativeVideoPath(seriesTitle, episodeTitle, episodeID string) (string, error) { series := sanitizeComponent(seriesTitle, maxSeriesBytes) episode := sanitizeComponent(episodeTitle, maxEpisodeBytes) id := sanitizeComponent(episodeID, -1) if len(id) > maxEpisodeIDBytes { return "", fmt.Errorf("エピソードIDはサニタイズ後%dバイト以下である必要があります: %dバイト", maxEpisodeIDBytes, len(id)) } // A directory entry must fit within the NFS namlen limit. Keep the episode // ID intact and use the remaining bytes for the episode title. const separators = " [].mp4" seriesInFilename := series titleBytes := maxFilenameBytes - len(seriesInFilename) - len(id) - len(separators) if titleBytes < 0 { titleBytes = 0 } episode = truncateComponent(episode, titleBytes, false) return filepath.Join(series, seriesInFilename+" "+episode+" ["+id+"].mp4"), nil } func sanitizeComponent(value string, maxBytes int) string { var result []rune lastReplacement := false for _, r := range value { invalid := unicode.IsControl(r) || strings.ContainsRune(`<>:"/\|?*`, r) if invalid { if !lastReplacement { result = append(result, '_') lastReplacement = true } continue } if unicode.IsSpace(r) { r = ' ' } result = append(result, r) lastReplacement = false } clean := strings.Trim(string(result), " .") if clean == "" || clean == "." || clean == ".." { return "名称未設定" } return truncateComponent(clean, maxBytes, true) } func truncateComponent(value string, maxBytes int, fallback bool) string { if maxBytes < 0 { return value } var limited []rune bytes := 0 for _, r := range value { size := len(string(r)) if bytes+size > maxBytes { break } limited = append(limited, r) bytes += size } clean := strings.Trim(string(limited), " .") if fallback && (clean == "" || clean == "." || clean == "..") { return "名称未設定" } return clean }