You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何解决Go中HLS流下载合并MP3的测试文件不一致问题?

问题:HLS流合并测试中,合并文件与原文件不一致的解决方法

原下载函数代码

func DownloadM3u8(filepath string, dlbar *bar.ProgressBar, segments []string) error {

    file, _ := os.OpenFile(filepath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)

    // 启动goroutine
    var wg sync.WaitGroup

    for _, segment := range segments {
        wg.Add(1)
        downloadSeg(&wg, segment, file, dlbar)
    }

    return nil
}


func downloadSeg(wg *sync.WaitGroup, segmentURI string, file *os.File, dlbar *bar.ProgressBar) {
    defer wg.Done()
    resp, err := http.Get(segmentURI)

    if err != nil {
        return
    }

    defer resp.Body.Close()

    // 写入文件
    if dlbar == nil {
        _, err = io.Copy(io.MultiWriter(file), resp.Body)
    } else {
        _, err = io.Copy(io.MultiWriter(file, dlbar), resp.Body)
    }

    if err != nil {
        return
    }

}

传入的segments为URI列表,示例格式:

[
https://url-adc.com/s/933030/30032, 
https://url-adc.com/s/933030/303220,
https://url-adc.com/s/933030/34230,
https://url-adc.com/s/933030/35290,
]

测试思路

使用已下载完成的Test[medium].mp3文件,将其分割为[]byte片段并绑定到模拟URL,通过httptest包返回对应字节数据,验证合并后的文件与原文件是否一致。测试函数代码:

func TestDownloadM3u8(t *testing.T) {

    path := "../../testdata/"
    track := readTestFile("Test[medium].mp3") // 返回[]byte

    fileName := "some filename that exists" // 预期生成的临时mp3文件名
    path = filepath.Join(path, fileName)
       // 从实际m3u8文件提取分段信息
    segments := extractSegments(fileResp, track)

    // 启动测试服务器模拟分段下载
    testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
        // 根据URL参数返回对应分段字节
        n, _ := strconv.Atoi(req.URL.String()[1:])
        if bs, ok := segments[n]; ok {
            res.WriteHeader(http.StatusOK)
            res.Write(bs)
        }
    }))

    defer testServer.Close()

    segmentURIs := make([]string, 0)
    for k := range segments {
        segmentURIs = append(segmentURIs, testServer.URL+"/"+strconv.Itoa(k))
    }
    soundcloud.DownloadM3u8(path, nil, segmentURIs)

    // 读取下载后的文件
    file, err := ioutil.ReadFile(path)
    if err != nil {
        t.Errorf("读取文件时出错: %s", err)
    }
    // 对比文件内容
    if !bytes.Equal(file, track) {
        t.Errorf("预期两个文件内容一致,但实际不一致")
    }
    // os.Remove(path) // 可选:删除测试文件
}

分段提取与分割逻辑

extractSegments函数读取m3u8播放列表获取分段数量,再分割测试文件:

func extractSegments(fileP []byte, testfile []byte) map[int][]byte {
    segments := make(map[int][]byte, 0)
    reader := bytes.NewReader(fileP)

    pl, listType, err := m3u8.DecodeFrom(reader, true)

    if err != nil {
        return nil
    }

    switch listType {
    case m3u8.MEDIA:
        mediapl := pl.(*m3u8.MediaPlaylist)
        for i, segment := range mediapl.Segments {
            if segment == nil {
                continue
            }
                     // 此处需要将testfile分割为对应片段
            segments[i] = ...
        }
    }

    return segments
}

最初的分割逻辑:

var segmentSize int
    numSegments := len(segments)

    fileSize := len(testfile)
    segmentSize = fileSize / numSegments

    for i := 0; i < numSegments-1; i++ {
        start := i * segmentSize
        end := (i + 1) * segmentSize
        segments[i] = testfile[start:end]
    }

    // 处理最后一段
    start := (numSegments - 1) * segmentSize
    end := fileSize
    if fileSize%numSegments == 0 {
        end = (numSegments-1)*segmentSize + segmentSize
    }
    segments[int(numSegments-1)] = testfile[start:end]

测试后发现合并文件与原文件不一致,需调整代码。


问题原因与修复方案

核心问题1:并发写入导致顺序混乱

原下载函数用goroutine并行写入文件,但os.File的Write操作并非并发安全,且goroutine执行顺序不确定,会导致分段内容乱序写入,最终文件字节顺序与原文件不符。

核心问题2:分割逻辑的小瑕疵

原分割逻辑对余数的处理冗余,虽不是主要问题,但可优化避免潜在的字节遗漏。


修复步骤

步骤1:保证分段写入的顺序性

提供两种方案:

  • 方案A(测试场景快速修复):取消并行,按顺序下载写入
  • 方案B(生产级并发安全):并行下载到内存缓冲区,再按顺序写入文件

方案A代码修改:

func DownloadM3u8(filepath string, dlbar *bar.ProgressBar, segments []string) error {
    // 不要忽略文件打开错误
    file, err := os.OpenFile(filepath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
    if err != nil {
        return err
    }
    defer file.Close() // 必须关闭文件,确保内容刷入磁盘

    // 按顺序下载每个分段
    for _, segment := range segments {
        downloadSeg(nil, segment, file, dlbar)
    }

    return nil
}

// 调整downloadSeg,移除WaitGroup相关逻辑
func downloadSeg(_ *sync.WaitGroup, segmentURI string, file *os.File, dlbar *bar.ProgressBar) {
    resp, err := http.Get(segmentURI)
    if err != nil {
        return
    }
    defer resp.Body.Close()

    var writer io.Writer = file
    if dlbar != nil {
        writer = io.MultiWriter(file, dlbar)
    }
    _, err = io.Copy(writer, resp.Body)
    if err != nil {
        return
    }
}

方案B代码修改:

type segmentData struct {
    index int
    data  []byte
    err   error
}

func DownloadM3u8(filepath string, dlbar *bar.ProgressBar, segments []string) error {
    file, err := os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY, 0644)
    if err != nil {
        return err
    }
    defer file.Close()

    var wg sync.WaitGroup
    dataChan := make(chan segmentData, len(segments))

    // 并行下载所有分段到内存
    for i, segment := range segments {
        wg.Add(1)
        go func(idx int, uri string) {
            defer wg.Done()
            resp, err := http.Get(uri)
            if err != nil {
                dataChan <- segmentData{index: idx, err: err}
                return
            }
            defer resp.Body.Close()

            data, err := io.ReadAll(resp.Body)
            if err != nil {
                dataChan <- segmentData{index: idx, err: err}
                return
            }

            dataChan <- segmentData{index: idx, data: data}
        }(i, segment)
    }

    // 等待所有下载完成后关闭通道
    go func() {
        wg.Wait()
        close(dataChan)
    }()

    // 收集并按原始索引排序分段
    segmentList := make([]segmentData, 0, len(segments))
    for sd := range dataChan {
        segmentList = append(segmentList, sd)
    }

    sort.Slice(segmentList, func(i, j int) bool {
        return segmentList[i].index < segmentList[j].index
    })

    // 检查下载错误
    for _, sd := range segmentList {
        if sd.err != nil {
            return sd.err
        }
    }

    // 按顺序写入文件
    totalWritten := 0
    for _, sd := range segmentList {
        n, err := file.Write(sd.data)
        if err != nil {
            return err
        }
        totalWritten += n
        if dlbar != nil {
            dlbar.Add(n)
        }
    }

    return nil
}

步骤2:优化分割逻辑

将余数均匀分配给前几个分段,确保所有字节都被正确分割:

func extractSegments(fileP []byte, testfile []byte) map[int][]byte {
    segments := make(map[int][]byte)
    reader := bytes.NewReader(fileP)

    pl, listType, err := m3u8.DecodeFrom(reader, true)
    if err != nil {
        return nil
    }

    switch listType {
    case m3u8.MEDIA:
        mediapl := pl.(*m3u8.MediaPlaylist)
        numSegments := len(mediapl.Segments)
        if numSegments == 0 {
            return segments
        }
        fileSize := len(testfile)
        segmentSize := fileSize / numSegments
        remaining := fileSize % numSegments

        current := 0
        for i, segment := range mediapl.Segments {
            if segment == nil {
                continue
            }
            end := current + segmentSize
            // 前remaining个分段多分配1字节,处理余数
            if i < remaining {
                end += 1
            }
            // 防止越界
            if end > fileSize {
                end = fileSize
            }
            segments[i] = testfile[current:end]
            current = end
        }
    }

    return segments
}

步骤3:测试函数优化

使用临时文件避免覆盖已有文件,同时完善错误处理:

func TestDownloadM3u8(t *testing.T) {
    track := readTestFile("Test[medium].mp3")
    if track == nil {
        t.Fatal("无法读取测试文件")
    }

    // 创建临时文件
    tmpFile, err := os.CreateTemp("", "test-m3u8-*.mp3")
    if err != nil {
        t.Fatalf("创建临时文件失败: %s", err)
    }
    tmpPath := tmpFile.Name()
    defer func() {
        tmpFile.Close()
        os.Remove(tmpPath) // 测试结束后自动删除
    }()

    segments := extractSegments(fileResp, track)
    if segments == nil {
        t.Fatal("提取分段失败")
    }

    testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
        n, err := strconv.Atoi(req.URL.String()[1:])
        if err != nil {
            res.WriteHeader(http.StatusBadRequest)
            return
        }
        if bs, ok := segments[n]; ok {
            res.WriteHeader(http.StatusOK)
            res.Write(bs)
        } else {
            res.WriteHeader(http.StatusNotFound)
        }
    }))
    defer testServer.Close()

    // 按索引顺序生成URI,确保请求顺序正确
    segmentURIs := make([]string, 0, len(segments))
    for i := 0; i < len(segments); i++ {
        if _, ok := segments[i]; ok {
            segmentURIs = append(segmentURIs, testServer.URL+"/"+strconv.Itoa(i))
        }
    }

    err = soundcloud.DownloadM3u8(tmpPath, nil, segmentURIs)
    if err != nil {
        t.Fatalf("下载合并失败: %s", err)
    }

    downloaded, err := ioutil.ReadFile(tmpPath)
    if err != nil {
        t.Fatalf("读取下载文件失败: %s", err)
    }

    if !bytes.Equal(downloaded, track) {
        t.Errorf("文件内容不一致,原文件长度%d,下载文件长度%d", len(track), len(downloaded))
    }
}

总结

测试失败的核心原因是并发写入导致分段顺序混乱,修复后确保分段按原始顺序写入文件,配合优化后的分割逻辑,即可让合并后的文件与原文件完全一致。

内容的提问来源于stack exchange,提问作者AhmedO

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.04 07:20:33