HLS-builder/main.go
2024-05-08 15:08:41 +08:00

765 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* @Author: BlackTeay
* @Date: 2024-04-30 09:37:39
* @LastEditTime: 2024-05-08 14:43:47
* @LastEditors: BlackTeay
* @Description:
* @FilePath: /hls_builder/main.go
* Copyright 2024 JLNTV NMTD, All Rights Reserved.
*/
package main
import (
"bufio"
"embed"
_ "embed"
"errors"
"fmt"
"image/color"
"log"
"math"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"github.com/google/uuid"
)
//go:embed "font/NotoSansSC-Regular.ttf"
var ttfBytes []byte
//go:embed bin/*
var ffmpegFS embed.FS
// MyTheme 定义了一个自定义的主题结构体。
type MyTheme struct{}
// Color 根据给定的主题颜色名称和变种返回相应的颜色。
// 参数 c 为主题颜色的名称。
// 参数 v 为颜色的变种。
// 返回值为符合 color.Color 接口的颜色值。
func (MyTheme) Color(c fyne.ThemeColorName, v fyne.ThemeVariant) color.Color {
return theme.DefaultTheme().Color(c, v)
}
// Font 根据给定的文本样式返回相应的字体资源。
// 参数 s 为文本的样式,决定了使用的字体。
// 返回值为一个 fyne.Resource 类型的字体资源。
func (MyTheme) Font(s fyne.TextStyle) fyne.Resource {
return fyne.NewStaticResource("./font/NotoSansSC-Regular.ttf", ttfBytes)
}
// Icon 根据给定的图标名称返回相应的图标资源。
// 参数 n 为图标的名称。
// 返回值为一个 fyne.Resource 类型的图标资源。
func (MyTheme) Icon(n fyne.ThemeIconName) fyne.Resource {
return theme.DefaultTheme().Icon(n)
}
// Size 根据给定的主题尺寸名称返回相应的尺寸值。
// 参数 s 为主题尺寸的名称。
// 返回值为相应的尺寸值,单位为 float32。
func (MyTheme) Size(s fyne.ThemeSizeName) float32 {
return theme.DefaultTheme().Size(s)
}
type UploadStats struct {
Successed int
Failed int
TotalSize string
AverageSpeed string
Elapsed string
}
// getFFmpegPath 获取FFmpeg可执行文件的路径。
// 该函数根据运行时操作系统选择对应的FFmpeg文件将该文件从嵌入的文件系统读出
// 并写入到一个临时目录中,然后返回这个临时文件的路径。
// 返回值:
//
// string - FFmpeg可执行文件的临时路径。
func getFFmpegPath() (string, func(), error) {
var ffmpegFileName string
switch runtime.GOOS {
case "windows":
ffmpegFileName = "ffmpeg_windows.exe"
case "darwin":
ffmpegFileName = "ffmpeg_darwin"
}
// 根据操作系统选择的FFmpeg文件名从嵌入的文件系统中读取文件内容
data, err := ffmpegFS.ReadFile("bin/" + ffmpegFileName)
if err != nil {
log.Fatal(err)
}
// 创建临时目录并将FFmpeg文件内容写入该目录中的文件
tmpDir, err := os.MkdirTemp("", "ffmpeg")
if err != nil {
log.Fatal(err)
}
tmpFilePath := filepath.Join(tmpDir, ffmpegFileName)
if err := os.WriteFile(tmpFilePath, data, 0755); err != nil {
log.Fatal(err)
} else {
log.Println("FFmpeg path:", tmpFilePath)
}
cleanup := func() {
os.RemoveAll(tmpDir) // 删除整个临时目录
log.Println("Cleaned up FFmpeg temporary files.")
}
return tmpFilePath, cleanup, nil
}
// getUs3cliPath 获取us3cli二进制文件的临时路径。
// 该函数根据运行的操作系统选择对应的us3cli文件并将其从嵌入的文件系统中读出
// 然后写入到一个临时文件中,最后返回这个临时文件的路径。
// 返回值:
// - string: us3cli临时文件的路径。
func getUs3cliPath() (string, func(), error) {
var us3cliFileName string
switch runtime.GOOS {
case "windows":
us3cliFileName = "us3cli-windows.exe"
case "darwin":
us3cliFileName = "us3cli-mac"
}
// 根据操作系统选择的us3cli文件名从嵌入的文件系统中读取文件内容
data, err := ffmpegFS.ReadFile("bin/" + us3cliFileName)
if err != nil {
log.Fatal(err)
}
// 创建临时目录并将us3cli文件内容写入其中
tmpDir, err := os.MkdirTemp("", "us3cli")
if err != nil {
log.Fatal(err)
}
tmpFilePath := filepath.Join(tmpDir, us3cliFileName)
if err := os.WriteFile(tmpFilePath, data, 0755); err != nil {
log.Fatal(err)
} else {
log.Println("us3cli path:", tmpFilePath)
}
cleanup := func() {
os.RemoveAll(tmpDir) // 删除整个临时目录
log.Println("Cleaned up Us3cli temporary files.")
}
return tmpFilePath, cleanup, nil
}
// main 是应用程序的入口点。
func main() {
// 打印当前工作目录
log.Println("Current path:", os.Getenv("PWD"))
// 初始化应用并设置主题
myApp := app.NewWithID("hls_builder")
myApp.Settings().SetTheme(MyTheme{})
// 创建主窗口并设置大小
myWindow := myApp.NewWindow("HLS-builder 视频切片上传工具")
myWindow.Resize(fyne.NewSize(800, 400))
// 初始化UI组件文件选择提示、输出路径选择提示、进度条
inputFile := widget.NewLabel("请选择mp4文件!")
outputDir := widget.NewLabel("请选择输出路径!")
sliceProgressBar := widget.NewProgressBar()
uploadProgressBar := widget.NewProgressBar()
// 创建选择视频文件按钮并设置点击事件
btnSelectFile := widget.NewButton("选择mp4文件", func() {
// 设置文件类型过滤器
filter := storage.NewExtensionFileFilter([]string{".mp4"})
fileDialog := dialog.NewFileOpen(func(file fyne.URIReadCloser, err error) {
if err != nil {
log.Println("Error opening file:", err)
return
}
if file != nil {
log.Println("Opened file:", file.URI().Path())
inputFile.SetText(file.URI().Path())
}
}, myWindow)
fileDialog.SetFilter(filter)
fileDialog.Show()
})
// 创建选择输出路径按钮并设置点击事件
btnSelectOutput := widget.NewButton("选择输出路径", func() {
dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) {
if err == nil && uri != nil {
log.Println("Selected folder:", uri.Path())
outputDir.SetText(uri.Path())
}
}, myWindow)
})
var randDir string
var hlsDir string
// 初始化上传按钮
uploadLabel := widget.NewLabel("上传切片到Ucloud:")
var btnUpload *widget.Button
btnUpload = widget.NewButton("上传", func() {
btnUpload.Text = "正在上传..."
btnUpload.Disable()
// 调用上传函数,并在上传完成后启用按钮
go upload(hlsDir, randDir, uploadProgressBar, myWindow, func() {
btnUpload.Text = "上传"
btnUpload.Enable()
})
})
// 初始化切片按钮及相关变量
var btnSlice *widget.Button
btnSlice = widget.NewButton("开始切片", func() {
fmt.Println(inputFile.Text)
fmt.Println(outputDir.Text)
// 验证用户选择
if inputFile.Text == "请选择mp4文件!" || outputDir.Text == "请选择输出路径!" {
dialog.ShowError(errors.New("请选择视频文件和输出路径"), myWindow)
return
}
// 禁用UI元素以防止用户干预
btnSelectFile.Disable()
btnSelectOutput.Disable()
btnSlice.Disable()
btnSlice.SetText("切片中...")
// 生成随机目录名并在输出路径下创建该目录
randDir = uuid.New().String()
hlsDir = outputDir.Text + "/" + randDir
err := os.Mkdir(hlsDir, 0755)
if err != nil {
dialog.ShowError(errors.New("建立随机目录出错!"), myWindow)
}
// 调用切片函数并在切片完成后更新UI
go sliceVideo(inputFile.Text, hlsDir, sliceProgressBar, func() {
btnSelectFile.Enable()
btnSelectOutput.Enable()
btnSlice.Enable()
btnSlice.SetText("开始切片")
}, btnSelectFile, btnSelectOutput, btnSlice, btnUpload, myWindow)
})
btnUpload.Disable()
copyRight := widget.NewLabelWithStyle("© 2024 JLNTV ", fyne.TextAlignCenter, fyne.TextStyle{Bold: true})
blogLink := widget.NewHyperlink("BlackTeay", createURL("https://iteay.top"))
copyRightHyperlink := container.NewHBox(copyRight, blogLink)
// 创建一个水平和垂直居中的布局
centeredLayout := container.NewBorder(nil, nil, nil, nil, copyRightHyperlink)
// 组合UI元素
content := container.NewVBox(
inputFile,
btnSelectFile,
outputDir,
btnSelectOutput,
sliceProgressBar,
btnSlice,
uploadLabel,
btnUpload,
uploadProgressBar,
)
// 将内容放置在水平和垂直居中的布局中
centeredContent := container.New(layout.NewVBoxLayout(), content, centeredLayout)
// 设置窗口内容并显示
myWindow.SetContent(centeredContent)
myWindow.ShowAndRun()
}
// sliceVideo 切割视频文件为HLS格式的多个段。
//
// 参数:
// inputFile - 输入视频文件的路径。
// outputDir - 输出目录的路径,切片和播放列表将保存于此目录。
// progressBar - 用于显示切割过程进度的进度条。
// onComplete - 切割完成后调用的回调函数。
// btnSelectFile, btnSelectOutput, btnSlice - 用于用户界面交互的按钮。
// myWindow - 所属的FYNE窗口用于显示对话框和其他UI元素。
//
// 此函数使用FFmpeg工具切割视频将视频文件分解为多个时长为5秒的HLS段并创建一个播放列表。
// 过程中会更新进度条,并在完成时显示一个通知对话框。
func sliceVideo(inputFile, outputDir string, progressBar *widget.ProgressBar, onComplete func(), btnSelectFile, btnSelectOutput, btnSlice *widget.Button, btnUpload *widget.Button, myWindow fyne.Window) {
// 获取FFmpeg可执行文件的路径
ffmpegPath, cleanup, err := getFFmpegPath()
if err != nil {
log.Fatal(err)
}
startTime := time.Now() // 记录开始时间以便计算总耗时
log.Println("开始切割视频")
// 构建FFmpeg命令行配置输出为HLS格式每5秒切一片
cmd := exec.Command(
ffmpegPath,
"-i", inputFile, // 输入文件
"-c", "copy", // 复制编解码器设置,避免重新编码
"-start_number", "0", // HLS段的起始编号
"-hls_time", "5", // 每个HLS段的最大时长
"-hls_list_size", "0", // 播放列表中的最大段数0表示无限制
"-f", "hls", // 输出格式为HLS
"-hls_segment_filename", outputDir+"/%08d.ts", // 段文件的命名方式
outputDir+"/playlist.m3u8", // 输出的m3u8文件位置
)
// 设置管道以读取FFmpeg的stderr用于解析进度信息
cmdReader, err := cmd.StderrPipe()
if err != nil {
log.Fatal("创建stderr管道失败:", err)
}
if err := cmd.Start(); err != nil {
log.Fatalf("cmd.Start()失败: %s\n", err)
}
scanner := bufio.NewScanner(cmdReader)
totalDuration := getTotalDuration(inputFile) // 获取视频的总时长
log.Printf("视频总时长%f\n", totalDuration)
segment := math.Ceil(totalDuration / 5) // 计算所需的段数
log.Println(segment)
// 在后台goroutine中监控FFmpeg的输出更新进度条并处理完成状态
go func() {
var completed bool
for scanner.Scan() {
line := scanner.Text()
log.Println("FFmpeg输出:", line)
// 解析FFmpeg的输出更新进度条
if segmentCount := parseFFmpegProgressOutput(line); segmentCount > 0 {
log.Println(segmentCount / segment)
progressBar.SetValue(segmentCount / segment)
}
// 检查是否已完成切割
if checkFFmpegCompletion(line) {
completed = true
endTime := time.Now() // 记录完成时间
duration := endTime.Sub(startTime) // 计算总耗时
formattedDuration := formatDuration(duration) // 格式化耗时显示
progressBar.SetValue(1.0) // 设置进度条为完成状态
// 显示完成信息的对话框
dialog.ShowInformation("完成", fmt.Sprintf("✅ 视频切片完成!可以将切片直接上传云存储。\n总用时: %s\n存储位置%s", formattedDuration, outputDir), myWindow)
log.Println("视频切片完成")
btnUpload.Enable()
cleanup() // 确保在函数返回时删除临时目录
break
}
}
if err := cmd.Wait(); err != nil {
log.Fatalf("cmd.Wait()失败: %s\n", err)
}
if !completed {
log.Println("❌ 视频切片可能未正确完成")
}
onComplete() // 调用完成回调
}()
}
// getTotalDuration 使用 FFmpeg 提取视频文件的总时长
// 参数:
//
// file string - 视频文件的路径
//
// 返回值:
//
// float64 - 视频的总时长(单位:秒)
func getTotalDuration(file string) float64 {
// 获取 FFmpeg 可执行文件的路径
ffmpegPath, cleanup, err := getFFmpegPath()
if err != nil {
log.Fatal(err)
}
defer cleanup() // 确保在函数返回时删除临时目录
// 构建并启动 FFmpeg 命令,用于获取视频时长
cmd := exec.Command(ffmpegPath, "-i", file)
// 由于 FFmpeg 在只有 "-i" 参数时将输出发送到 stderr因此我们需要捕获 stderr
stderr, err := cmd.StderrPipe()
if err != nil {
log.Fatal("Failed to get stderr pipe:", err)
}
// 启动 FFmpeg 命令
if err := cmd.Start(); err != nil {
log.Println("Failed to start ffmpeg:", err)
return 0.0 // 无法获取时长时返回 0.0
}
// 使用 Scanner 从 stderr 中读取输出
scanner := bufio.NewScanner(stderr)
// 正则表达式用于匹配 FFmpeg 输出的时长信息
durationRegex := regexp.MustCompile(`Duration: (\d{2}):(\d{2}):(\d{2}\.\d{2})`)
var duration float64 = 0.0
for scanner.Scan() {
// 寻找匹配的时长字符串,并解析为秒
matches := durationRegex.FindStringSubmatch(scanner.Text())
if matches != nil {
hours, _ := strconv.Atoi(matches[1])
minutes, _ := strconv.Atoi(matches[2])
seconds, _ := strconv.ParseFloat(matches[3], 64)
duration = float64(hours)*3600 + float64(minutes)*60 + seconds
break // 找到时长后即刻跳出循环
}
}
// 等待 FFmpeg 命令执行完成,不处理命令结束状态
cmd.Wait()
return duration
}
// parseFFmpegProgressOutput 函数解析 FFmpeg 进度输出行,返回匹配到的数字部分。
// 参数:
//
// line string - FFmpeg 进度输出的一行文本。
//
// 返回值:
//
// float64 - 匹配到的数字转换为浮点数后返回,如果没有匹配到则返回 0。
func parseFFmpegProgressOutput(line string) float64 {
re := regexp.MustCompile(`(\d+)\.ts`) // 使用正则表达式编译模式以匹配数字后跟.ts的字符串
matches := re.FindStringSubmatch(line)
if len(matches) > 1 {
number, err := strconv.Atoi(matches[1]) // 将提取的数字字符串转换为int类型
if err != nil {
log.Println("Error converting string to integer:", err) // 记录转换错误日志
return 0
}
return float64(number) // 返回匹配到的数字转换后的浮点数
}
return 0 // 如果没有找到匹配的模式返回0
}
// checkFFmpegCompletion 函数用于检查 FFmpeg 处理进程是否完成。
// 它通过分析 FFmpeg 输出的行中是否包含特定的字符串来判断处理是否完成。
//
// 参数:
// line string - FFmpeg 输出的一行文本。
//
// 返回值:
// bool - 如果行中包含指定的字符串,则返回 true表示处理已完成否则返回 false。
func checkFFmpegCompletion(line string) bool {
// 记录接收到的行,以便于调试
log.Println("line:", line)
// 正则表达式匹配含有 "out#0/hls" 的行,这通常在 FFmpeg 输出最后阶段显示
re := regexp.MustCompile(`out#0/hls`)
// 检查这一行是否匹配正则表达式
matchFound := re.MatchString(line)
log.Println("re", matchFound)
return matchFound
}
// formatDuration 将时间Duration格式化为易读的字符串
// 参数:
//
// d time.Duration - 需要格式化的时间长度
//
// 返回值:
//
// string - 格式化后的时间字符串,格式为 "X小时X分X秒X毫秒",根据实际时间长度包含其中的项
func formatDuration(d time.Duration) string {
// 将时间转换为总秒数
totalSeconds := int(d.Seconds())
// 计算小时、分钟和秒
hours := totalSeconds / 3600
minutes := (totalSeconds % 3600) / 60
seconds := totalSeconds % 60
// 获取毫秒数
milliseconds := int(d.Milliseconds()) % 1000
result := ""
// 如果有小时,则添加到结果中
if hours > 0 {
result += fmt.Sprintf("%d小时", hours)
}
// 如果有小时或者分钟不为0则添加分钟到结果中
if minutes > 0 || hours > 0 {
result += fmt.Sprintf("%d分", minutes)
}
// 如果秒不为0或者同时小时和分钟都不为0则添加秒到结果中
if seconds > 0 || (hours != 0 && minutes != 0) {
result += fmt.Sprintf("%d秒", seconds)
}
// 总是添加毫秒到结果中
result += fmt.Sprintf("%d毫秒", milliseconds)
return result
}
// 上传到Ucloud
func upload(localPath string, keyPath string, progressBar *widget.ProgressBar, myWindow fyne.Window, onComplete func()) {
fmt.Println("上传到Ucloud")
progressBar.SetValue(0)
fmt.Println("localPath:", localPath)
fmt.Println("keyPath:", keyPath)
// 统计 .ts .m3u8 文件数量
tsFiles, m3u8Files, err := countMediaFiles(localPath)
if err != nil {
log.Fatalf("Error counting media files: %s", err)
}
fmt.Printf("Total .ts files: %d\n", tsFiles)
fmt.Printf("Total .m3u8 files: %d\n", m3u8Files)
filesTotal := float64(tsFiles + m3u8Files)
fmt.Printf("Total files: %d\n", int(filesTotal))
// 获取 us3cli 的路径
us3cliPath, cleanup, err := getUs3cliPath()
if err != nil {
log.Fatal(err)
}
defer cleanup() // 确保在函数返回时删除临时目录
bucket := "us3://jlntv-live/replay/" + keyPath
// us3ConfigPath, cleanup, err := getUs3Config()
// if err != nil {
// log.Fatal(err)
// }
// defer cleanup() // 确保在函数返回时删除临时目录
// cmd := exec.Command(us3cliPath, "cp", localPath, bucket, "-r", "--parallel", "20", "--config", us3ConfigPath)
cmd := exec.Command(us3cliPath, "cp", localPath, bucket, "-r", "--parallel", "20", "--accesskey", "TOKEN_6096c736-12b7-4c20-bfa5-e85e0e9c9b65", "--secretkey", "cc8a5965-3325-4231-9f64-a6626f624049", "--endpoint", "cn-bj.ufileos.com")
//输出cmd
fmt.Println(cmd.String())
stdout, err := cmd.StdoutPipe()
if err != nil {
log.Fatal("Error creating stdout pipe:", err)
}
if err := cmd.Start(); err != nil {
log.Fatalf("Failed to start command: %s", err)
}
progress := 0.0
go func() {
buffer := make([]byte, 4096) // 创建一个足够大的buffer
for {
n, err := stdout.Read(buffer)
if n > 0 {
fmt.Print("STDOUT:", string(buffer[:n])) // 打印实时输出
filesUploadedTotal, err := extractTotalValue(string(buffer[:n]))
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Total:", filesUploadedTotal)
}
stats, err := parseUploadStats(string(buffer[:n]))
if err != nil {
fmt.Println("Error parsing upload stats:", err)
} else {
fmt.Printf("Uploaded: %d, Failed: %d, Total Size: %s, Speed: %s\n",
stats.Successed, stats.Failed, stats.TotalSize, stats.AverageSpeed)
if float64(stats.Successed) == filesTotal {
progressBar.SetValue(1)
showCustomDialog := func() {
replayURL := fmt.Sprintf("https://video.jlntv.cn/replay/%s/playlist.m3u8", keyPath)
copyBtn := widget.NewButton("复制地址", func() {
fmt.Println("copyed:", replayURL)
// dialog.ShowInformation("Submitted", "You submitted: "+input.Text, myWindow)
clipboard := myWindow.Clipboard()
clipboard.SetContent(replayURL)
// showToast(myWindow, "地址已复制到剪贴板", 3*time.Second)
dialog.ShowInformation("提示", "地址已复制到剪贴板!", myWindow)
})
// 创建自定义内容的容器
content := container.NewVBox(
widget.NewLabel("Upload Stats:"),
widget.NewLabel(fmt.Sprintf("用时:%s秒", stats.Elapsed)),
widget.NewLabel(fmt.Sprintf("成功:%d", stats.Successed)),
widget.NewLabel(fmt.Sprintf("失败:%d", stats.Failed)),
widget.NewLabel(fmt.Sprintf("平均速度:%s", stats.AverageSpeed)),
widget.NewLabel(fmt.Sprintf("总大小:%s", stats.TotalSize)),
widget.NewLabel(fmt.Sprintf("回看地址: %s", replayURL)),
copyBtn,
)
// 设置内容容器的最小尺寸
content.Resize(fyne.NewSize(500, 600)) // 你可以根据需要调整这个尺寸
// 创建并显示自定义对话框
customDialog := dialog.NewCustom("上传成功", "关闭", content, myWindow)
customDialog.Show()
}
showCustomDialog()
// dialog.ShowInformation("完成", fmt.Sprintf("✅ HLS上传完毕\n总用时:%s秒\n成功:%d 失败:%d 总大小:%s 平均速度:%s", stats.Elapsed, stats.Successed, stats.Failed, stats.TotalSize, stats.AverageSpeed), myWindow)
fmt.Println("Upload completed!")
} else {
progress = filesUploadedTotal / filesTotal
fmt.Println("Progress:", progress)
progressBar.SetValue(progress)
}
}
}
if err != nil {
break
}
}
}()
if err := cmd.Wait(); err != nil {
log.Printf("Command finished with error: %v", err)
}
if onComplete != nil {
onComplete()
}
}
// extractTotalValue 函数从给定的文本中提取总值。
//
// 参数:
//
// text string - 包含待搜索总值的文本。
//
// 返回值:
//
// float64 - 从文本中提取到的总值如果未找到则返回默认值200。
// error - 如果在提取过程中发生错误则返回相应的错误否则返回nil。
func extractTotalValue(text string) (float64, error) {
// 使用正则表达式匹配文本中的总值部分
re := regexp.MustCompile(`Total:(\d+)`)
matches := re.FindStringSubmatch(text)
if len(matches) > 1 {
// 将匹配到的总值字符串转换为整数
total, err := strconv.Atoi(matches[1])
if err != nil {
// 如果转换过程中发生错误,返回错误信息
return 0, err
}
// 返回转换后的总值
return float64(total), nil
}
// 如果未在文本中找到总值返回默认值200和一个错误信息
return 200, fmt.Errorf("no total found in the text")
}
// parseUploadStats 函数解析上传统计信息字符串,并返回一个包含上传统计详情的结构体指针。
// 参数 line 为待解析的上传统计信息字符串。
// 返回 *UploadStats 指向解析出的上传统计详情结构体。
// 返回 error 表示在解析过程中遇到的任何错误。
func parseUploadStats(line string) (*UploadStats, error) {
stats := &UploadStats{}
var err error
// 初始化正则表达式用于匹配不同的上传统计信息。
successedRegex := regexp.MustCompile(`(\d+) Successed`)
failedRegex := regexp.MustCompile(`(\d+) Failed`)
sizeRegex := regexp.MustCompile(`Size: ([\d.]+ MB)`)
speedRegex := regexp.MustCompile(`Average speed ([\d.]+ MB/s)`)
elapsedRegex := regexp.MustCompile(`Elapsed\s*:\s*([\d.]+)s`)
// 提取成功上传的文件数量。
successedMatches := successedRegex.FindStringSubmatch(line)
if len(successedMatches) > 1 {
stats.Successed, err = strconv.Atoi(successedMatches[1])
if err != nil {
return nil, fmt.Errorf("error parsing successed number: %v", err)
}
}
// 提取失败上传的文件数量。
failedMatches := failedRegex.FindStringSubmatch(line)
if len(failedMatches) > 1 {
stats.Failed, err = strconv.Atoi(failedMatches[1])
if err != nil {
return nil, fmt.Errorf("error parsing failed number: %v", err)
}
}
// 提取上传的总大小。
sizeMatches := sizeRegex.FindStringSubmatch(line)
if len(sizeMatches) > 1 {
stats.TotalSize = sizeMatches[1]
}
// 提取平均上传速度。
speedMatches := speedRegex.FindStringSubmatch(line)
if len(speedMatches) > 1 {
stats.AverageSpeed = speedMatches[1]
}
// 提取上传所花费的时间。
elapsedMatches := elapsedRegex.FindStringSubmatch(line)
if len(elapsedMatches) > 1 {
stats.Elapsed = elapsedMatches[1]
}
return stats, nil
}
// countMediaFiles 统计给定目录中 .ts 和 .m3u8 文件的数量。
//
// 参数:
//
// directory string - 需要遍历统计文件的目录路径。
//
// 返回值:
//
// int - .ts 文件的数量。
// int - .m3u8 文件的数量。
// error - 遍历目录过程中遇到的任何错误。
func countMediaFiles(directory string) (int, int, error) {
var tsCount, m3u8Count int // 分别用于记录 .ts 和 .m3u8 文件的数量
// 使用 filepath.Walk 遍历指定目录及其子目录中的所有文件
err := filepath.Walk(directory, func(path string, info os.FileInfo, err error) error {
// 检查遍历过程是否遇到错误
if err != nil {
return err // 如果有错误,则终止遍历并返回该错误
}
// 检查当前路径项是否为文件,不遍历目录
if !info.IsDir() {
// 根据文件扩展名统计 .ts 和 .m3u8 文件数量
switch filepath.Ext(info.Name()) {
case ".ts":
tsCount++ // .ts 文件计数
case ".m3u8":
m3u8Count++ // .m3u8 文件计数
}
}
return nil // 继续遍历下一个文件或目录
})
// 返回 .ts 和 .m3u8 文件的计数结果,以及遍历过程中可能遇到的错误
return tsCount, m3u8Count, err
}
// createURL 是一个用于创建并返回一个url.URL指针的函数。
// 它接受一个字符串类型的参数link表示待解析的URL链接。
// 如果链接解析失败,将会记录错误信息并终止程序。
//
// 参数:
//
// link string - 待解析的URL字符串。
//
// 返回值:
//
// *url.URL - 解析后的URL结构体指针。
func createURL(link string) *url.URL {
// 解析提供的链接
url, err := url.Parse(link)
if err != nil {
// 如果解析失败,记录错误信息并终止程序
log.Fatalf("Error parsing URL: %v", err)
}
return url
}