- MediaLibrary.swift: 数据模型(JSON持久化) + 播放列表管理 + 标签/喜欢 - M3UParser.swift: 解析IPTV格式M3U播放列表(EXTINF属性) - MediaLibraryView.swift: NavigationSplitView侧边栏+内容区UI - PlayerBridge.swift: 播放位置记忆(暂停/切歌/退出保存,播放恢复) - 本地视频自动入库,搜索结果临时播放列表,所有操作不中断播放 - 快捷键: Cmd+Shift+L 媒体库, Cmd+Shift+I 导入M3U
118 lines
4.2 KiB
Swift
118 lines
4.2 KiB
Swift
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文件中未找到频道"
|
||
}
|
||
}
|
||
}
|
||
}
|