76 lines
2.1 KiB
Go
76 lines
2.1 KiB
Go
// 本文件负责将剧集视频按清单写入 ZIP,仅打包原始媒体字节,不执行转码或拼接。
|
|
package videoarchive
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
const maxFilenameRunes = 180
|
|
|
|
// Entry 描述 ZIP 中单个视频的对象存储位置和归档名称。
|
|
type Entry struct {
|
|
ObjectKey string
|
|
Name string
|
|
}
|
|
|
|
// Manifest 描述一个剧集视频压缩包的下载名称和文件清单。
|
|
type Manifest struct {
|
|
Name string
|
|
Entries []Entry
|
|
}
|
|
|
|
// OpenObject 按对象键打开原始媒体流,调用方负责提供具体存储实现。
|
|
type OpenObject func(context.Context, string) (io.ReadCloser, error)
|
|
|
|
// Write 按清单顺序写入 ZIP,并确保每个对象流在复制后立即关闭。
|
|
func Write(ctx context.Context, target io.Writer, entries []Entry, open OpenObject) error {
|
|
writer := zip.NewWriter(target)
|
|
for _, entry := range entries {
|
|
body, err := open(ctx, entry.ObjectKey)
|
|
if err != nil {
|
|
return fmt.Errorf("打开归档视频 %q 失败: %w", entry.Name, err)
|
|
}
|
|
header := &zip.FileHeader{Name: entry.Name, Method: zip.Deflate}
|
|
header.SetMode(0o644)
|
|
part, createErr := writer.CreateHeader(header)
|
|
if createErr == nil {
|
|
_, createErr = io.Copy(part, body)
|
|
}
|
|
closeErr := body.Close()
|
|
if createErr != nil {
|
|
return fmt.Errorf("写入归档视频 %q 失败: %w", entry.Name, createErr)
|
|
}
|
|
if closeErr != nil {
|
|
return fmt.Errorf("关闭归档视频 %q 失败: %w", entry.Name, closeErr)
|
|
}
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
return fmt.Errorf("完成 ZIP 写入失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SafeFilename 清理文件系统不允许的字符并限制长度,避免归档名形成路径或无法保存。
|
|
func SafeFilename(value, fallback string) string {
|
|
cleaned := strings.Map(func(r rune) rune {
|
|
if unicode.IsControl(r) || strings.ContainsRune(`\/:*?"<>|`, r) {
|
|
return -1
|
|
}
|
|
return r
|
|
}, strings.TrimSpace(value))
|
|
cleaned = strings.Trim(cleaned, ". ")
|
|
if cleaned == "" {
|
|
cleaned = fallback
|
|
}
|
|
runes := []rune(cleaned)
|
|
if len(runes) > maxFilenameRunes {
|
|
cleaned = string(runes[:maxFilenameRunes])
|
|
}
|
|
return cleaned
|
|
}
|