MiniPlayer/Sources/M3UParser.swift
yumoqing 05afadfd11 feat: 媒体库 - 播放记忆、多播放列表、M3U导入、模糊搜索、喜欢/标签系统
- MediaLibrary.swift: 数据模型(JSON持久化) + 播放列表管理 + 标签/喜欢
- M3UParser.swift: 解析IPTV格式M3U播放列表(EXTINF属性)
- MediaLibraryView.swift: NavigationSplitView侧边栏+内容区UI
- PlayerBridge.swift: 播放位置记忆(暂停/切歌/退出保存,播放恢复)
- 本地视频自动入库,搜索结果临时播放列表,所有操作不中断播放
- 快捷键: Cmd+Shift+L 媒体库, Cmd+Shift+I 导入M3U
2026-06-30 00:28:14 +08:00

118 lines
4.2 KiB
Swift
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.

import Foundation
/// M3U 播放列表解析器(支持 IPTV EXTINF 格式)
enum M3UParser {
struct Channel {
var name: String
var url: String
var group: String?
var logoURL: String?
var type: String // "stream" or "url"
}
/// 从字符串内容解析 M3U
static func parse(_ content: String) -> [Channel] {
let lines = content.components(separatedBy: .newlines)
var channels: [Channel] = []
var pendingName: String?
var pendingGroup: String?
var pendingLogo: String?
for rawLine in lines {
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
guard !line.isEmpty else { continue }
// 跳过 M3U 头部
if line.hasPrefix("#EXTM3U") { continue }
// 跳过其他注释(非 EXTINF)
if line.hasPrefix("#") && !line.hasPrefix("#EXTINF") { continue }
if line.hasPrefix("#EXTINF") {
// 解析 #EXTINF:-1 tvg-name="..." group-title="..." tvg-logo="...",Channel Name
let info = String(line.dropFirst("#EXTINF".count))
// 提取逗号后的频道名
if let commaIdx = info.lastIndex(of: ",") {
let afterComma = String(info[info.index(after: commaIdx)...])
pendingName = afterComma.trimmingCharacters(in: .whitespaces)
}
// 提取属性
let attrs = parseAttributes(info)
if let tvgName = attrs["tvg-name"], (pendingName ?? "").isEmpty {
pendingName = tvgName
}
pendingGroup = attrs["group-title"]
pendingLogo = attrs["tvg-logo"]
} else if !line.hasPrefix("#") {
// URL 行
let url = line
let name = (pendingName ?? "").isEmpty
? (URL(string: url)?.host ?? url)
: pendingName!
let isStream = url.contains(".m3u8") || url.contains(".m3u")
|| url.contains("://") && !url.hasSuffix(".mp4")
channels.append(Channel(
name: name,
url: url,
group: pendingGroup,
logoURL: pendingLogo,
type: isStream ? "stream" : "url"
))
pendingName = nil
pendingGroup = nil
pendingLogo = nil
}
}
return channels
}
/// 从 URL 异步下载并解析 M3U
static func fetchAndParse(url: URL) async throws -> [Channel] {
let (data, _) = try await URLSession.shared.data(from: url)
guard let content = String(data: data, encoding: .utf8) else {
throw M3UError.invalidEncoding
}
return parse(content)
}
// MARK: - 私有
private static func parseAttributes(_ line: String) -> [String: String] {
var attrs: [String: String] = [:]
// 匹配 key="value"
let pattern = #"([a-zA-Z\-]+)="([^"]*)""#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return attrs }
let matches = regex.matches(in: line, range: NSRange(line.startIndex..., in: line))
for m in matches {
guard m.numberOfRanges == 3,
let keyRange = Range(m.range(at: 1), in: line),
let valRange = Range(m.range(at: 2), in: line) else { continue }
attrs[String(line[keyRange])] = String(line[valRange])
}
return attrs
}
enum M3UError: Error, LocalizedError {
case invalidEncoding
case invalidURL
case emptyResult
var errorDescription: String? {
switch self {
case .invalidEncoding: return "无法解码M3U文件(需要UTF-8编码)"
case .invalidURL: return "无效的URL"
case .emptyResult: return "M3U文件中未找到频道"
}
}
}
}