专业的JAVA编程教程与资源

网站首页 > java教程 正文

Spring Boot + Minio 去掉电影网站视频广告:基于 M3U8播放地址

temp10 2025-01-21 21:37:53 java教程 107 ℃ 0 评论

背景介绍

在视频点播系统中,广告视频片段常常嵌入到正常的视频播放流中,尤其是在 .m3u8 格式的播放列表文件中。传统的去广告方案需要识别广告的具体片段,这通常依赖于广告片段的已知标识。然而,在实际应用中,广告片段可能是通过插入不同的播放片段地址或者通过片段时长、顺序等特征来表现出来。因此,如何自动识别和去除广告,成为了一个挑战。

什么是 M3U8?

M3U8 是一个基于 UTF-8 编码的媒体播放列表格式,常用于流媒体视频播放。它包含了视频的播放顺序和每个视频片段的路径。M3U8 文件通常会引导播放器按顺序加载 .ts(MPEG-TS)格式的视频片段。

Spring Boot + Minio 去掉电影网站视频广告:基于 M3U8播放地址

为什么需要去除广告?

在流媒体视频中,广告通常通过将广告视频片段插入到主视频流的中间进行播放。用户往往希望能够跳过或去除这些广告内容,以便享受无广告的观看体验。

需求分析

我们需要解决以下几个问题:

  1. 解析 .m3u8 文件,提取所有的 .ts 片段。
  2. 通过片段地址前缀的占比来识别正常的视频片段和广告片段。
  3. 去除广告片段,保留正常的视频片段。
  4. 生成新的 .m3u8 文件,并将其上传至 Minio 存储服务,供前端播放。

解决方案

我们通过以下步骤来实现这一方案:

  1. 解析 M3U8 文件:解析 .m3u8 文件,提取出所有的 .ts 片段。
    2.统计片段地址前缀的出现次数:统计所有 .ts 片段的 前缀 部分(即去掉文件名后的路径部分)。计算出现频率,前缀出现次数最多的认为是 正常的视频片段地址。
  2. 通过前缀占比判断广告片段:如果某个 .ts 片段的前缀与正常前缀匹配,那么认为它是正常的视频片段。反之,认为它是广告片段并删除。
  3. 生成新的 M3U8 文件:去除广告片段,保留正常的视频片段,生成新的 M3U8 播放地址。

系统架构

  1. Spring Boot:提供后端服务接口,处理视频广告去除逻辑。
  2. MinIO:用于存储和管理 .ts 片段和 M3U8 文件。

主要步骤

配置 Spring Boot 和 MinIO

首先,我们需要在 Spring Boot 项目中集成 MinIO,用来存储视频文件(包括 .m3u8 文件和 .ts 片段)。以下是相关的配置:

# application.yml 配置文件
minio:
  endpoint: minio.example.com
  access-key: your-access-key
  secret-key: your-secret-key
  bucket: your-video-bucket

解析 M3U8 文件

M3U8 文件本质上是一个文本文件,包含了多个 .ts 文件的路径。在 Spring Boot 中,我们可以读取这个文件,解析出其中的片段信息。

import cn.hutool.core.util.URLUtil;
import lombok.extern.slf4j.Slf4j;

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;

/**
 * @author klklmoon
 */
@Slf4j
public class M3U8ParserService {


    public M3U8VO parse(String m3u8Url) {
        M3U8VO m3u8 = new M3U8VO();
        m3u8.setUrl(m3u8Url);
        URI uri = URLUtil.toURI(m3u8Url);
        String domain = uri.getScheme() + "://" + uri.getHost();
        String path = getUrlPath(m3u8Url);
        m3u8.setDomain(domain);
        double duration = 0;
        boolean discontinuity = false;
        M3U8KeyVO m3u8Key = null;
        M3U8StreamInfoVO streamInfo = null;

        // 读取 M3U8 文件
        try (InputStream inputStream = new URL(m3u8Url).openStream();
             BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {

            String line;
            while ((line = reader.readLine()) != null) {
                if (line.startsWith("#EXTINF")) {
                    duration = parseExtInf(line);
                } else if (line.startsWith("#EXT-X-VERSION")) {
                    parseExtXVersion(line, m3u8);
                } else if (line.startsWith("#EXT-X-TARGETDURATION")) {
                    parseExtXTargetDuration(line, m3u8);
                } else if (line.startsWith("#EXT-X-MEDIA-SEQUENCE")) {
                    parseExtXMediaSequence(line, m3u8);
                } else if (line.startsWith("#EXT-X-PLAYLIST-TYPE")) {
                    parseExtXPlayListType(line, m3u8);
                } else if (line.startsWith("#EXT-X-STREAM-INF")) {
                    streamInfo = parseExtXStreamInf(line);
                } else if (streamInfo != null && !line.startsWith("#")) {
                    streamInfo.setUrl(buildSegmentUrl(line, domain, path));
                    m3u8.getStreams().add(streamInfo);
                    streamInfo = null;
                } else if (line.startsWith("#EXT-X-KEY")) {
                    m3u8Key = parseExtXKey(line, m3u8, path);
                    if (!discontinuity) {
                        m3u8.setKey(parseExtXKey(line, m3u8,path));
                    }
                } else if (line.startsWith("#EXT-X-DISCONTINUITY")) {
                    discontinuity = parseExtXDiscontinuity();
                } else if (!line.startsWith("#")) {
                    M3U8SegmentVO segment = new M3U8SegmentVO();
                    segment.setUrl(buildSegmentUrl(line, domain, path));
                    segment.setDuration(duration);
                    segment.setDiscontinuity(discontinuity);
                    if (discontinuity && m3u8Key != null) {
                        segment.setKey(m3u8Key);
                    }
                    m3u8.getSegments().add(segment);
                    discontinuity = false;
                }
            }
        } catch (Exception e) {
            log.error("M3U8文件解析失败,m3u8文件地址:{},失败原因:{}", m3u8Url, e.getMessage());
        }

        return m3u8;
    }

    private double parseExtInf(String line) {
        String durationString = line.split(":")[1].split(",")[0];
        return Double.parseDouble(durationString);
    }

    private void parseExtXVersion(String line, M3U8VO m3u8) {
        String version = line.split(":")[1];
        m3u8.setVersion(version);
    }

    private void parseExtXTargetDuration(String line, M3U8VO m3u8) {
        double targetDuration = Double.parseDouble(line.split(":")[1]);
        m3u8.setTargetDuration(targetDuration);
    }

    private void parseExtXMediaSequence(String line, M3U8VO m3u8) {
        int mediaSequence = Integer.parseInt(line.split(":")[1]);
        m3u8.setMediaSequence(mediaSequence);
    }

    private void parseExtXPlayListType(String line, M3U8VO m3u8) {
        String playListType = line.split(":")[1];
        m3u8.setPlayListType(playListType);
    }

    private M3U8StreamInfoVO parseExtXStreamInf(String line) {
        String[] parts = line.split(":")[1].split(",");
        int bandwidth = 0;
        String resolution = null;

        for (String part : parts) {
            if (part.startsWith("BANDWIDTH")) {
                bandwidth = Integer.parseInt(part.split("=")[1]);
            } else if (part.startsWith("RESOLUTION")) {
                resolution = part.split("=")[1];
            }
        }
        M3U8StreamInfoVO streamInfo = new M3U8StreamInfoVO();
        streamInfo.setBandwidth(bandwidth);
        streamInfo.setResolution(resolution);
        return streamInfo;
    }

    private M3U8KeyVO parseExtXKey(String line, M3U8VO m3u8, String path) {
        String[] parts = line.split(":")[1].split(",");
        String method = null;
        String uri = null;
        String iv = null;

        for (String part : parts) {
            if (part.startsWith("METHOD")) {
                method = part.split("=")[1];
            } else if (part.startsWith("URI")) {
                uri = part.split("=")[1].replace("\"", "");
            } else if (part.startsWith("IV")) {
                iv = part.split("=")[1];
            }
        }
        M3U8KeyVO key = new M3U8KeyVO();
        if (method != null) {
            key.setMethod(method);
        }
        if (uri != null) {
            key.setUri(buildSegmentUrl(uri, m3u8.getDomain(), path));
        }
        if (iv != null) {
            key.setIv(iv);
        }
        return key;
    }

    private boolean parseExtXDiscontinuity() {
        return true;
    }

    private String buildSegmentUrl(String line, String domain, String path) {
        String url;
        if (!line.contains("http") && !line.contains("https")) {
            if (line.startsWith("/")) {
                url = domain + line;
            } else {
                url = domain + path + line;
            }
        } else {
            url = line;
        }
        return url;
    }

    private String getUrlPath(String url) {
        URI uri = URLUtil.toURI(url);
        String path = uri.getPath();
        int lastSlashIndex = path.lastIndexOf('/');
        // 如果找到了斜杠,则截取斜杠之前的部分
        if (lastSlashIndex != -1) {
            path = path.substring(0, lastSlashIndex + 1);
        }
        return path;
    }
}

识别广告片段

假设广告片段可以通过文件名或某些特征来识别。例如,广告片段的 .ts 文件正常的视频.ts文件的路径不一致。我们可以遍历 .ts 文件列表,筛选出广告片段并标记。

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.ArrayList;

public class AdRemover {

    // 根据前缀占比来判断正常视频片段和广告片段
    public List<String> removeAdsByPrefix(List<String> tsFiles) {
        Map<String, Integer> prefixCountMap = new HashMap<>();
        List<String> filteredFiles = new ArrayList<>();

        // 统计每个前缀出现的次数
        for (String tsFile : tsFiles) {
            String prefix = getPrefix(tsFile);
            prefixCountMap.put(prefix, prefixCountMap.getOrDefault(prefix, 0) + 1);
        }

        // 找出出现次数最多的前缀(认为是正常视频片段前缀)
        String normalPrefix = getMostFrequentPrefix(prefixCountMap);

        // 根据正常前缀,筛选出正常的片段
        for (String tsFile : tsFiles) {
            if (getPrefix(tsFile).equals(normalPrefix)) {
                filteredFiles.add(tsFile);
            }
        }

        return filteredFiles;  // 返回去除广告后的片段列表
    }

    // 获取片段的前缀部分(即去掉文件名后的路径部分)
    private String getPrefix(String tsFile) {
        int lastSlashIndex = tsFile.lastIndexOf('/');
        if (lastSlashIndex != -1) {
            return tsFile.substring(0, lastSlashIndex + 1); // 获取路径部分
        }
        return tsFile; // 如果没有路径部分,则返回整个字符串
    }

    // 获取出现次数最多的前缀
    private String getMostFrequentPrefix(Map<String, Integer> prefixCountMap) {
        String mostFrequentPrefix = null;
        int maxCount = 0;

        for (Map.Entry<String, Integer> entry : prefixCountMap.entrySet()) {
            if (entry.getValue() > maxCount) {
                mostFrequentPrefix = entry.getKey();
                maxCount = entry.getValue();
            }
        }
        return mostFrequentPrefix;
    }
}

生成新的 M3U8 文件

在去除广告片段后,我们需要生成新的 M3U8 文件,并返回一个新的播放地址。以下是生成新的 M3U8 文件的代码示例:

public String generateM3U8(M3U8VO m3u8) {
        StringBuilder builder = new StringBuilder();

        builder.append("#EXTM3U\n");
        // 添加版本号
        if (m3u8.getVersion() != null) {
            builder.append("#EXT-X-VERSION:").append(m3u8.getVersion()).append("\n");
        }

        // 添加目标时长
        builder.append("#EXT-X-TARGETDURATION:").append(m3u8.getTargetDuration()).append("\n");

        // 添加媒体序列
        builder.append("#EXT-X-MEDIA-SEQUENCE:").append(m3u8.getMediaSequence()).append("\n");
        // 添加媒体序列
        builder.append("#EXT-X-PLAYLIST-TYPE:").append(m3u8.getPlayListType()).append("\n");
        // 添加加密信息
        if (m3u8.getKey() != null) {
            M3U8KeyVO key = m3u8.getKey();
            builder.append("#EXT-X-KEY:METHOD=").append(key.getMethod());
            if (key.getUri() != null) {
                builder.append(",URI=\"").append(key.getUri()).append("\"");
            }
            if (key.getIv() != null) {
                builder.append(",IV=").append(key.getIv());
            }
            builder.append("\n");
        }

        // 添加流信息
        for (M3U8StreamInfoVO stream : m3u8.getStreams()) {
            builder.append("#EXT-X-STREAM-INF:BANDWIDTH=").append(stream.getBandwidth());
            if (stream.getResolution() != null) {
                builder.append(",RESOLUTION=").append(stream.getResolution());
            }
            builder.append("\n").append(stream.getUrl()).append("\n");
        }

        // 添加片段信息
        for (M3U8SegmentVO segment : m3u8.getSegments()) {
            if (segment.isDiscontinuity()) {
                builder.append("#EXT-X-DISCONTINUITY\n");
            }
            if (segment.isDiscontinuity() && segment.getKey() != null) {
                builder.append("#EXT-X-KEY:METHOD=").append(segment.getKey().getMethod());
                if (segment.getKey().getUri() != null) {
                    builder.append(",URI=\"").append(segment.getKey().getUri()).append("\"");
                }
                if (segment.getKey().getIv() != null) {
                    builder.append(",IV=").append(segment.getKey().getIv());
                }
                builder.append("\n");
            }
            builder.append("#EXTINF:").append(segment.getDuration()).append(",\n");
            builder.append(segment.getUrl()).append("\n");
        }
        builder.append("#EXT-X-ENDLIST");
        return builder.toString();
    }

存储和提供 M3U8 文件

为了存储和提供生成的 M3U8 文件,我们可以将其上传到 MinIO,并返回一个可访问的 URL。

import io.minio.MinioClient;
import io.minio.errors.MinioException;
import io.minio.messages.Item;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.io.InputStream;

@Service
public class MinIOService {

    @Value("${minio.bucket}")
    private String bucketName;

    private final MinioClient minioClient;

    public MinIOService(MinioClient minioClient) {
        this.minioClient = minioClient;
    }

    public String uploadFile(String filePath, InputStream fileStream) throws MinioException {
        try {
            minioClient.putObject(bucketName, filePath, fileStream);
            return minioClient.getObjectUrl(bucketName, filePath);
        } catch (Exception e) {
            throw new MinioException("Error uploading file to MinIO", e);
        }
    }
}

完整流程

最终,Spring Boot 后端服务提供接口,结合上述各步骤的逻辑,完成广告删除的操作。以下是一个简化的流程:

  1. 接收请求:前端传入 M3U8 播放地址。
  2. 解析 M3U8 文件:读取并解析 M3U8 文件,提取出 .ts 文件列表。
  3. 去除广告片段:根据特征识别并删除广告片段。
  4. 生成新的 M3U8 文件:将剩余的 .ts 文件生成新的 M3U8 播放列表。
  5. 上传至 MinIO:将新的 M3U8 文件上传至 MinIO,并生成一个新的播放地址。
  6. 返回结果:返回新的播放地址给前端,用户可以播放去广告的视频。
    示例代码:去广告接口
@RestController
@RequestMapping("/video")
public class VideoController {

    @Autowired
    private MinIOService minIOService;

    @Autowired
    private M3U8ParserService m3U8ParserService;

    @Autowired
    private AdRemover adRemover;

    @Autowired
    private M3U8Generator m3U8Generator;

    @PostMapping("/remove-ads")
    public CommonResult<String> removeAds(@RequestParam String m3u8Url) {
        try {
            // 步骤 1: 解析 m3u8 文件
            List<String> tsFiles = m3U8ParserService.parse(m3u8Url);

            // 步骤 2: 去除广告片段
            List<String> filteredFiles = adRemover.removeAds(tsFiles);

            // 步骤 3: 生成新的 m3u8 文件
            String newM3U8File = "new_playlist.m3u8";
            m3U8ParserService.generateM3U8(filteredFiles, newM3U8File);

            // 步骤 4: 上传至 MinIO
            String newPlayUrl = minIOService.uploadFile(newM3U8File, new FileInputStream(newM3U8File));

            return  CommonResult.success(newPlayUrl);
        } catch (Exception e) {
            return CommonResult.error(e.getMessage());
        }
    }
}

总结

通过使用 Spring Boot 和 MinIO,结合解析 M3U8 文件、去除广告片段和重新生成播放地址的逻辑,我们能够实现一个去除电影网站视频广告的功能。这种方法不仅提升了用户体验,还能有效地为视频流平台提供灵活的广告管理机制。

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表