// 本文件负责将剧集视频按清单写入 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 }