545 lines
18 KiB
Go
545 lines
18 KiB
Go
package service
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf16"
|
|
"unicode/utf8"
|
|
|
|
dramapkg "juhe-factory/api/internal/drama"
|
|
"juhe-factory/api/internal/model"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/richardlehane/mscfb"
|
|
"golang.org/x/text/encoding/simplifiedchinese"
|
|
"golang.org/x/text/transform"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
const maxDramaImportBytes = 3 * 1024 * 1024
|
|
|
|
var chapterTitlePattern = regexp.MustCompile(`(?im)^\s*(?:#+\s*|[*+-]\s*)?((?:第[零〇一二三四五六七八九十百千万两\d]+[章节回卷篇部集幕].*)|(?:chapter\s+\d+.*))\s*$`)
|
|
|
|
type DramaChapter struct {
|
|
Title string `json:"title"`
|
|
Content string `json:"content,omitempty"`
|
|
CharCount int `json:"char_count"`
|
|
}
|
|
|
|
type DramaImportPreview struct {
|
|
ImportToken uuid.UUID `json:"import_token"`
|
|
HasChapters bool `json:"has_chapters"`
|
|
TotalCharacters int `json:"total_characters"`
|
|
Chapters []DramaChapter `json:"chapters"`
|
|
SingleEpisodeConfirmationNeeded bool `json:"single_episode_confirmation_required"`
|
|
}
|
|
|
|
func (s *Creative) PreviewDramaImport(userID, projectID uuid.UUID, filename string, data []byte, rawText string) (*DramaImportPreview, error) {
|
|
if err := s.requirePremiumProject(userID, projectID); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(data) > maxDramaImportBytes || len([]byte(rawText)) > maxDramaImportBytes {
|
|
return nil, errors.New("小说文件或文本不能超过3MB")
|
|
}
|
|
text, sourceType, err := parseDramaSource(filename, data, rawText)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
text = normalizeDramaText(text)
|
|
if text == "" {
|
|
return nil, errors.New("未读取到有效小说正文")
|
|
}
|
|
chapters := splitDramaChapters(text)
|
|
metadata := make([]DramaChapter, 0, len(chapters))
|
|
for _, chapter := range chapters {
|
|
metadata = append(metadata, DramaChapter{Title: chapter.Title, CharCount: len([]rune(chapter.Content))})
|
|
}
|
|
previewJSON, _ := json.Marshal(map[string]any{"has_chapters": len(chapters) > 0, "chapters": metadata})
|
|
hash := sha256.Sum256([]byte(text))
|
|
session := model.DramaImportSession{
|
|
ID: uuid.New(), UserID: userID, ProjectID: projectID, SourceType: sourceType,
|
|
SourceFilename: cleanDramaFilename(filename), RawContent: text, ContentSHA256: hex.EncodeToString(hash[:]),
|
|
PreviewData: previewJSON, ExpiresAt: time.Now().Add(30 * time.Minute),
|
|
}
|
|
if err := s.DB.Create(&session).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &DramaImportPreview{
|
|
ImportToken: session.ID, HasChapters: len(chapters) > 0, TotalCharacters: len([]rune(text)),
|
|
Chapters: metadata, SingleEpisodeConfirmationNeeded: len(chapters) == 0,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Creative) ConfirmDramaImport(userID, projectID, token uuid.UUID, chaptersPerEpisode, startEpisodeNo int, treatAsSingle bool) ([]model.ProjectEpisode, error) {
|
|
if chaptersPerEpisode < 1 {
|
|
chaptersPerEpisode = 1
|
|
}
|
|
if startEpisodeNo < 1 {
|
|
return nil, errors.New("起始集数无效")
|
|
}
|
|
created := make([]model.ProjectEpisode, 0)
|
|
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
|
var project model.CreativeProject
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").Where("id=? AND user_id=? AND deleted_at IS NULL", projectID, userID).Take(&project).Error; err != nil {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
var session model.DramaImportSession
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=? AND user_id=? AND project_id=?", token, userID, projectID).Take(&session).Error; err != nil {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
if session.ConsumedAt != nil || time.Now().After(session.ExpiresAt) {
|
|
return errors.New("导入预览已失效,请重新上传")
|
|
}
|
|
chapters := splitDramaChapters(session.RawContent)
|
|
if len(chapters) == 0 {
|
|
if !treatAsSingle {
|
|
return errors.New("需要确认将全部内容作为一集导入")
|
|
}
|
|
chapters = []DramaChapter{{Title: fmt.Sprintf("第%d集", startEpisodeNo), Content: session.RawContent, CharCount: len([]rune(session.RawContent))}}
|
|
chaptersPerEpisode = 1
|
|
}
|
|
for offset, index := 0, 0; index < len(chapters); offset, index = offset+1, index+chaptersPerEpisode {
|
|
end := index + chaptersPerEpisode
|
|
if end > len(chapters) {
|
|
end = len(chapters)
|
|
}
|
|
group := chapters[index:end]
|
|
episodeNo := startEpisodeNo + offset
|
|
name := fmt.Sprintf("第%d集", episodeNo)
|
|
if len(group) == 1 && strings.TrimSpace(group[0].Title) != "" {
|
|
name = group[0].Title
|
|
}
|
|
name = truncateRunes(strings.TrimSpace(name), 160)
|
|
exists, err := episodeNameExists(tx, projectID, nil, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if exists {
|
|
return errors.New("同一项目下不可有同名剧集")
|
|
}
|
|
parts := make([]string, 0, len(group))
|
|
for _, chapter := range group {
|
|
parts = append(parts, strings.TrimSpace(chapter.Content))
|
|
}
|
|
content := strings.TrimSpace(strings.Join(parts, "\n\n"))
|
|
if err := dramapkg.ValidateEpisodeContent(content); err != nil {
|
|
return fmt.Errorf("第%d集:%w", episodeNo, err)
|
|
}
|
|
episode := model.ProjectEpisode{ID: uuid.New(), ProjectID: projectID, EpisodeNo: episodeNo, Name: name, AudioSource: "video_audio", Status: "draft"}
|
|
if err := tx.Create(&episode).Error; err != nil {
|
|
return err
|
|
}
|
|
hash := sha256.Sum256([]byte(content))
|
|
source := model.EpisodeSource{ID: uuid.New(), EpisodeID: episode.ID, Title: episode.Name, RawContent: content, CharCount: len([]rune(content)), SourceType: session.SourceType, SourceFilename: session.SourceFilename, ContentSHA256: hex.EncodeToString(hash[:])}
|
|
if err := tx.Create(&source).Error; err != nil {
|
|
return err
|
|
}
|
|
created = append(created, episode)
|
|
}
|
|
now := time.Now()
|
|
return tx.Model(&session).Updates(map[string]any{"consumed_at": now, "raw_content": ""}).Error
|
|
})
|
|
return created, err
|
|
}
|
|
|
|
func (s *Creative) EpisodeSource(userID, projectID, episodeID uuid.UUID) (*model.EpisodeSource, error) {
|
|
var source model.EpisodeSource
|
|
result := s.DB.Table("episode_sources source").Joins("JOIN project_episodes episode ON episode.id=source.episode_id AND episode.deleted_at IS NULL").Joins("JOIN creative_projects project ON project.id=episode.project_id AND project.deleted_at IS NULL").Where("source.episode_id=? AND episode.project_id=? AND project.user_id=?", episodeID, projectID, userID).Limit(1).Find(&source)
|
|
if result.Error != nil {
|
|
return nil, result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return nil, gorm.ErrRecordNotFound
|
|
}
|
|
return &source, nil
|
|
}
|
|
|
|
func (s *Creative) SaveEpisodeSource(userID, projectID, episodeID uuid.UUID, content, title string) (*model.EpisodeSource, error) {
|
|
content = normalizeDramaText(content)
|
|
if err := dramapkg.ValidateEpisodeContent(content); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.requirePremiumEpisode(userID, projectID, episodeID); err != nil {
|
|
return nil, err
|
|
}
|
|
hash := sha256.Sum256([]byte(content))
|
|
source := model.EpisodeSource{ID: uuid.New(), EpisodeID: episodeID, Title: truncateRunes(strings.TrimSpace(title), 200), RawContent: content, CharCount: len([]rune(content)), SourceType: "pasted_text", ContentSHA256: hex.EncodeToString(hash[:])}
|
|
err := s.DB.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "episode_id"}}, DoUpdates: clause.AssignmentColumns([]string{"title", "raw_content", "char_count", "source_type", "source_filename", "content_sha256", "updated_at"})}).Create(&source).Error
|
|
return &source, err
|
|
}
|
|
|
|
func (s *Creative) requirePremiumProject(userID, projectID uuid.UUID) error {
|
|
var count int64
|
|
err := s.DB.Table("creative_projects").Where("id=? AND user_id=? AND project_type='premium_drama' AND deleted_at IS NULL", projectID, userID).Count(&count).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Creative) requirePremiumEpisode(userID, projectID, episodeID uuid.UUID) error {
|
|
var count int64
|
|
err := s.DB.Table("project_episodes episode").Joins("JOIN creative_projects project ON project.id=episode.project_id").Where("episode.id=? AND episode.project_id=? AND episode.deleted_at IS NULL AND project.user_id=? AND project.project_type='premium_drama' AND project.deleted_at IS NULL", episodeID, projectID, userID).Count(&count).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseDramaSource(filename string, data []byte, rawText string) (string, string, error) {
|
|
if strings.TrimSpace(rawText) != "" {
|
|
if len(data) > 0 {
|
|
return "", "", errors.New("文件和粘贴文本只能选择一种")
|
|
}
|
|
if looksLikeBinary([]byte(rawText)) {
|
|
return "", "", errors.New("粘贴内容包含二进制控制字符")
|
|
}
|
|
if looksLikeHTMLOrScript([]byte(rawText)) {
|
|
return "", "", errors.New("不支持HTML或脚本内容")
|
|
}
|
|
return rawText, "pasted_text", nil
|
|
}
|
|
if len(data) == 0 {
|
|
return "", "", errors.New("请选择小说文件或粘贴文本")
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(filename))
|
|
switch ext {
|
|
case ".txt":
|
|
text, err := decodePlainText(data)
|
|
return text, "txt", err
|
|
case ".docx":
|
|
text, err := extractDOCX(data)
|
|
return text, "docx", err
|
|
case ".doc":
|
|
text, err := extractLegacyDOC(data)
|
|
return text, "doc", err
|
|
default:
|
|
return "", "", errors.New("仅支持TXT、DOC和DOCX文件")
|
|
}
|
|
}
|
|
|
|
func decodePlainText(data []byte) (string, error) {
|
|
if hasDisallowedBinarySignature(data) || bytes.IndexByte(data, 0) >= 0 || looksLikeBinary(data) {
|
|
return "", errors.New("TXT文件包含二进制内容")
|
|
}
|
|
if looksLikeHTMLOrScript(data) {
|
|
return "", errors.New("不支持HTML或脚本文件")
|
|
}
|
|
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
|
|
if utf8.Valid(data) {
|
|
return string(data), nil
|
|
}
|
|
decoded, err := io.ReadAll(transform.NewReader(bytes.NewReader(data), simplifiedchinese.GB18030.NewDecoder()))
|
|
if err != nil || !utf8.Valid(decoded) {
|
|
return "", errors.New("TXT文件编码不支持,请使用UTF-8或GB18030")
|
|
}
|
|
return string(decoded), nil
|
|
}
|
|
|
|
func extractDOCX(data []byte) (string, error) {
|
|
if len(data) < 4 || !bytes.Equal(data[:4], []byte{'P', 'K', 3, 4}) {
|
|
return "", errors.New("DOCX文件结构无效")
|
|
}
|
|
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
return "", errors.New("DOCX文件结构无效")
|
|
}
|
|
if len(reader.File) > 1000 {
|
|
return "", errors.New("DOCX文件包含过多内容")
|
|
}
|
|
var document, contentTypes []byte
|
|
var expanded uint64
|
|
for _, file := range reader.File {
|
|
expanded += file.UncompressedSize64
|
|
if expanded > 20*1024*1024 {
|
|
return "", errors.New("DOCX展开后内容过大")
|
|
}
|
|
name := strings.ToLower(strings.ReplaceAll(file.Name, "\\", "/"))
|
|
if strings.Contains(name, "vbaproject") || strings.Contains(name, "activex") || strings.Contains(name, "embeddings/") {
|
|
return "", errors.New("DOCX包含宏、ActiveX或嵌入对象")
|
|
}
|
|
if name == "[content_types].xml" {
|
|
stream, openErr := file.Open()
|
|
if openErr != nil {
|
|
return "", openErr
|
|
}
|
|
contentTypes, err = io.ReadAll(io.LimitReader(stream, 1024*1024))
|
|
stream.Close()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
if name == "word/document.xml" {
|
|
stream, openErr := file.Open()
|
|
if openErr != nil {
|
|
return "", openErr
|
|
}
|
|
document, err = io.ReadAll(io.LimitReader(stream, 12*1024*1024))
|
|
stream.Close()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
}
|
|
contentTypeText := strings.ToLower(string(contentTypes))
|
|
if len(document) == 0 || !strings.Contains(contentTypeText, "wordprocessingml.document.main+xml") || strings.Contains(contentTypeText, "macroenabled") {
|
|
return "", errors.New("DOCX缺少Word正文结构")
|
|
}
|
|
decoder := xml.NewDecoder(bytes.NewReader(document))
|
|
var output strings.Builder
|
|
for {
|
|
token, tokenErr := decoder.Token()
|
|
if tokenErr == io.EOF {
|
|
break
|
|
}
|
|
if tokenErr != nil {
|
|
return "", errors.New("DOCX正文XML无效")
|
|
}
|
|
switch value := token.(type) {
|
|
case xml.CharData:
|
|
output.Write([]byte(value))
|
|
case xml.EndElement:
|
|
if value.Name.Local == "p" {
|
|
output.WriteString("\n")
|
|
} else if value.Name.Local == "tab" {
|
|
output.WriteString("\t")
|
|
}
|
|
}
|
|
}
|
|
return output.String(), nil
|
|
}
|
|
|
|
func extractLegacyDOC(data []byte) (string, error) {
|
|
magic := []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}
|
|
if len(data) < len(magic) || !bytes.Equal(data[:len(magic)], magic) {
|
|
return "", errors.New("DOC文件结构无效")
|
|
}
|
|
reader, err := mscfb.New(bytes.NewReader(data))
|
|
if err != nil {
|
|
return "", errors.New("DOC文件结构无效")
|
|
}
|
|
streams := map[string][]byte{}
|
|
for entry, nextErr := reader.Next(); nextErr == nil; entry, nextErr = reader.Next() {
|
|
name := strings.ToLower(entry.Name)
|
|
if strings.Contains(name, "vba") || strings.Contains(name, "macros") || strings.Contains(name, "objectpool") {
|
|
return "", errors.New("DOC包含宏或嵌入对象")
|
|
}
|
|
if name == "worddocument" || name == "0table" || name == "1table" {
|
|
content, readErr := io.ReadAll(io.LimitReader(entry, 12*1024*1024))
|
|
if readErr != nil {
|
|
return "", readErr
|
|
}
|
|
streams[name] = content
|
|
}
|
|
}
|
|
word := streams["worddocument"]
|
|
if len(word) < 0x1AA || binary.LittleEndian.Uint16(word[:2]) != 0xA5EC {
|
|
return "", errors.New("DOC缺少有效WordDocument流")
|
|
}
|
|
flags := binary.LittleEndian.Uint16(word[0x0A:0x0C])
|
|
tableName := "0table"
|
|
if flags&0x0200 != 0 {
|
|
tableName = "1table"
|
|
}
|
|
table := streams[tableName]
|
|
fcClx := int(binary.LittleEndian.Uint32(word[0x1A2:0x1A6]))
|
|
lcbClx := int(binary.LittleEndian.Uint32(word[0x1A6:0x1AA]))
|
|
text, parseErr := extractDOCPieces(word, table, fcClx, lcbClx)
|
|
if parseErr != nil || strings.TrimSpace(text) == "" {
|
|
return "", errors.New("DOC正文无法安全解析")
|
|
}
|
|
return text, nil
|
|
}
|
|
|
|
func extractDOCPieces(word, table []byte, offset, length int) (string, error) {
|
|
if offset < 0 || length <= 0 || offset+length > len(table) {
|
|
return "", errors.New("invalid CLX")
|
|
}
|
|
clx := table[offset : offset+length]
|
|
pos := 0
|
|
for pos < len(clx) && clx[pos] == 0x01 {
|
|
if pos+3 > len(clx) {
|
|
return "", io.ErrUnexpectedEOF
|
|
}
|
|
size := int(binary.LittleEndian.Uint16(clx[pos+1 : pos+3]))
|
|
pos += 3 + size
|
|
}
|
|
if pos+5 > len(clx) || clx[pos] != 0x02 {
|
|
return "", errors.New("missing Pcdt")
|
|
}
|
|
plcSize := int(binary.LittleEndian.Uint32(clx[pos+1 : pos+5]))
|
|
pos += 5
|
|
if plcSize < 4 || pos+plcSize > len(clx) {
|
|
return "", errors.New("invalid PlcPcd")
|
|
}
|
|
plc := clx[pos : pos+plcSize]
|
|
pieces := (plcSize - 4) / 12
|
|
if pieces <= 0 || pieces > 100000 {
|
|
return "", errors.New("invalid piece count")
|
|
}
|
|
cpBytes := (pieces + 1) * 4
|
|
if cpBytes+pieces*8 > len(plc) {
|
|
return "", io.ErrUnexpectedEOF
|
|
}
|
|
var output strings.Builder
|
|
for i := 0; i < pieces; i++ {
|
|
cpStart := int(binary.LittleEndian.Uint32(plc[i*4 : i*4+4]))
|
|
cpEnd := int(binary.LittleEndian.Uint32(plc[(i+1)*4 : (i+1)*4+4]))
|
|
if cpEnd <= cpStart {
|
|
continue
|
|
}
|
|
pcd := cpBytes + i*8
|
|
rawFC := binary.LittleEndian.Uint32(plc[pcd+2 : pcd+6])
|
|
compressed := rawFC&0x40000000 != 0
|
|
fc := int(rawFC & 0x3FFFFFFF)
|
|
chars := cpEnd - cpStart
|
|
if compressed {
|
|
fc /= 2
|
|
if fc < 0 || fc+chars > len(word) {
|
|
continue
|
|
}
|
|
decoded, _ := io.ReadAll(transform.NewReader(bytes.NewReader(word[fc:fc+chars]), simplifiedchinese.GB18030.NewDecoder()))
|
|
output.Write(decoded)
|
|
} else {
|
|
byteLen := chars * 2
|
|
if fc < 0 || fc+byteLen > len(word) {
|
|
continue
|
|
}
|
|
units := make([]uint16, chars)
|
|
for j := 0; j < chars; j++ {
|
|
units[j] = binary.LittleEndian.Uint16(word[fc+j*2 : fc+j*2+2])
|
|
}
|
|
output.WriteString(string(utf16.Decode(units)))
|
|
}
|
|
}
|
|
return output.String(), nil
|
|
}
|
|
|
|
func splitDramaChapters(content string) []DramaChapter {
|
|
matches := chapterTitlePattern.FindAllStringSubmatchIndex(content, -1)
|
|
if len(matches) == 0 {
|
|
return nil
|
|
}
|
|
chapters := make([]DramaChapter, 0, len(matches))
|
|
for i, match := range matches {
|
|
start := match[0]
|
|
if i == 0 {
|
|
start = 0
|
|
}
|
|
end := len(content)
|
|
if i+1 < len(matches) {
|
|
end = matches[i+1][0]
|
|
}
|
|
body := strings.TrimSpace(content[start:end])
|
|
if body == "" {
|
|
continue
|
|
}
|
|
title := strings.TrimSpace(content[match[2]:match[3]])
|
|
chapters = append(chapters, DramaChapter{Title: title, Content: body, CharCount: len([]rune(body))})
|
|
}
|
|
return chapters
|
|
}
|
|
|
|
func normalizeDramaText(value string) string {
|
|
value = strings.ReplaceAll(value, "\x00", "")
|
|
value = strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n")
|
|
lines := strings.Split(value, "\n")
|
|
cleaned := make([]string, 0, len(lines))
|
|
blank := false
|
|
for _, line := range lines {
|
|
line = strings.TrimRightFunc(line, unicode.IsSpace)
|
|
if strings.TrimSpace(line) == "" {
|
|
if !blank {
|
|
cleaned = append(cleaned, "")
|
|
blank = true
|
|
}
|
|
continue
|
|
}
|
|
cleaned = append(cleaned, line)
|
|
blank = false
|
|
}
|
|
return strings.TrimSpace(strings.Join(cleaned, "\n"))
|
|
}
|
|
|
|
func looksLikeBinary(data []byte) bool {
|
|
if len(data) == 0 {
|
|
return false
|
|
}
|
|
controls := 0
|
|
for _, b := range data {
|
|
if b < 0x09 || (b > 0x0D && b < 0x20) {
|
|
controls++
|
|
}
|
|
}
|
|
return controls*100/len(data) > 2
|
|
}
|
|
func looksLikeHTMLOrScript(data []byte) bool {
|
|
sample := strings.ToLower(string(data))
|
|
markers := []string{"<!doctype html", "<html", "<head", "<body", "<script", "</script", "<iframe", "<object", "<embed", "javascript:", "vbscript:", "onerror=", "onload="}
|
|
for _, marker := range markers {
|
|
if strings.Contains(sample, marker) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
func hasDisallowedBinarySignature(data []byte) bool {
|
|
signatures := [][]byte{
|
|
{0x25, 0x50, 0x44, 0x46, 0x2D},
|
|
{0x50, 0x4B, 0x03, 0x04},
|
|
{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1},
|
|
{0x89, 0x50, 0x4E, 0x47},
|
|
{0xFF, 0xD8, 0xFF},
|
|
{0x47, 0x49, 0x46, 0x38},
|
|
{0x4D, 0x5A},
|
|
{0x7F, 0x45, 0x4C, 0x46},
|
|
{0x52, 0x61, 0x72, 0x21},
|
|
{0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C},
|
|
{0x1F, 0x8B},
|
|
}
|
|
data = bytes.TrimSpace(data)
|
|
for _, signature := range signatures {
|
|
if bytes.HasPrefix(data, signature) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
func cleanDramaFilename(value string) string {
|
|
value = filepath.Base(strings.TrimSpace(value))
|
|
return truncateRunes(value, 255)
|
|
}
|
|
func truncateRunes(value string, limit int) string {
|
|
runes := []rune(value)
|
|
if len(runes) > limit {
|
|
return string(runes[:limit])
|
|
}
|
|
return value
|
|
}
|
|
|
|
// Stable chapter ordering is useful in tests and when import previews are reconstructed.
|
|
func sortEpisodesByNumber(items []model.ProjectEpisode) {
|
|
sort.SliceStable(items, func(i, j int) bool { return items[i].EpisodeNo < items[j].EpisodeNo })
|
|
}
|