mirror of
https://github.com/yattee/yattee.git
synced 2025-08-09 20:24:06 +00:00
Merge branch 'main' into add-user-agent-to-header
This commit is contained in:
@@ -502,7 +502,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
keywords: json["keywords"].arrayValue.compactMap { $0.string },
|
||||
streams: extractStreams(from: json),
|
||||
related: extractRelated(from: json),
|
||||
chapters: extractChapters(from: description),
|
||||
chapters: createChapters(from: description, thumbnails: json),
|
||||
captions: extractCaptions(from: json)
|
||||
)
|
||||
}
|
||||
@@ -575,6 +575,22 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
}
|
||||
|
||||
private func createChapters(from description: String, thumbnails: JSON) -> [Chapter] {
|
||||
var chapters = extractChapters(from: description)
|
||||
|
||||
if !chapters.isEmpty {
|
||||
let thumbnailsData = extractThumbnails(from: thumbnails)
|
||||
let thumbnailURL = thumbnailsData.first { $0.quality == .medium }?.url
|
||||
|
||||
for chapter in chapters.indices {
|
||||
if let url = thumbnailURL {
|
||||
chapters[chapter].image = url
|
||||
}
|
||||
}
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
private static var contentItemsKeys = ["items", "videos", "latestVideos", "playlists", "relatedChannels"]
|
||||
|
||||
private func extractChannelPage(from json: JSON, forceNotLast: Bool = false) -> ChannelPage {
|
||||
|
@@ -152,58 +152,94 @@ extension VideosAPI {
|
||||
/*
|
||||
The following chapter patterns are covered:
|
||||
|
||||
start - end - title / start - end: Title / start - end title
|
||||
start - title / start: title / start title / [start] - title / [start]: title / [start] title
|
||||
index. title - start / index. title start
|
||||
title: (start)
|
||||
1) "start - end - title" / "start - end: Title" / "start - end title"
|
||||
2) "start - title" / "start: title" / "start title" / "[start] - title" / "[start]: title" / "[start] title"
|
||||
3) "index. title - start" / "index. title start"
|
||||
4) "title: (start)"
|
||||
5) "(start) title"
|
||||
|
||||
The order is important!
|
||||
These represent:
|
||||
|
||||
- "start" and "end" are timestamps, defining the start and end of the individual chapter
|
||||
- "title" is the name of the chapter
|
||||
- "index" is the chapter's position in a list
|
||||
|
||||
The order of these patterns is important as it determines the priority. The patterns listed first have a higher priority.
|
||||
In the case of multiple matches, the pattern with the highest priority will be chosen - lower number means higher priority.
|
||||
*/
|
||||
let patterns = [
|
||||
"(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?(?:\\s*-\\s*)?(?<end>(?:[0-9]+:){1,2}[0-9]+)?(?:\\s*-\\s*|\\s*[:]\\s*)?(?<title>.*)(?=\\n|$)",
|
||||
"(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?\\s*[-:]?\\s*(?<title>.+)(?=\\n|$)",
|
||||
"(?<=\\n|^)(?<index>[0-9]+\\.\\s)(?<title>.+?)(?:\\s*-\\s*)?(?<start>(?:[0-9]+:){1,2}[0-9]+)(?=\\n|$)",
|
||||
"(?<=\\n|^)(?<title>.+?):\\s*\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)(?=\\n|$)"
|
||||
"(?<=\\n|^)(?<title>.+?):\\s*\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)(?=\\n|$)",
|
||||
"(?<=^|\\n)\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)\\s*(?<title>.+?)(?=\\n|$)"
|
||||
]
|
||||
|
||||
for pattern in patterns {
|
||||
guard let chaptersRegularExpression = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { continue }
|
||||
let chapterLines = chaptersRegularExpression.matches(in: description, range: NSRange(description.startIndex..., in: description))
|
||||
let extractChaptersGroup = DispatchGroup()
|
||||
var capturedChapters: [Int: [Chapter]] = [:]
|
||||
let lock = NSLock()
|
||||
|
||||
if !chapterLines.isEmpty {
|
||||
return chapterLines.compactMap { line in
|
||||
let titleRange = line.range(withName: "title")
|
||||
let startRange = line.range(withName: "start")
|
||||
guard let titleSubstringRange = Range(titleRange, in: description),
|
||||
let startSubstringRange = Range(startRange, in: description)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let titleCapture = String(description[titleSubstringRange]).trimmingCharacters(in: .whitespaces)
|
||||
let startCapture = String(description[startSubstringRange])
|
||||
let startComponents = startCapture.components(separatedBy: ":")
|
||||
guard startComponents.count <= 3 else { return nil }
|
||||
for (index, pattern) in patterns.enumerated() {
|
||||
extractChaptersGroup.enter()
|
||||
DispatchQueue.global().async {
|
||||
if let chaptersRegularExpression = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
|
||||
let chapterLines = chaptersRegularExpression.matches(in: description, range: NSRange(description.startIndex..., in: description))
|
||||
let extractedChapters = chapterLines.compactMap { line -> Chapter? in
|
||||
let titleRange = line.range(withName: "title")
|
||||
let startRange = line.range(withName: "start")
|
||||
|
||||
var hours: Double?
|
||||
var minutes: Double?
|
||||
var seconds: Double?
|
||||
guard let titleSubstringRange = Range(titleRange, in: description),
|
||||
let startSubstringRange = Range(startRange, in: description)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if startComponents.count == 3 {
|
||||
hours = Double(startComponents[0])
|
||||
minutes = Double(startComponents[1])
|
||||
seconds = Double(startComponents[2])
|
||||
} else if startComponents.count == 2 {
|
||||
minutes = Double(startComponents[0])
|
||||
seconds = Double(startComponents[1])
|
||||
let titleCapture = String(description[titleSubstringRange]).trimmingCharacters(in: .whitespaces)
|
||||
let startCapture = String(description[startSubstringRange])
|
||||
let startComponents = startCapture.components(separatedBy: ":")
|
||||
guard startComponents.count <= 3 else { return nil }
|
||||
|
||||
var hours: Double?
|
||||
var minutes: Double?
|
||||
var seconds: Double?
|
||||
|
||||
if startComponents.count == 3 {
|
||||
hours = Double(startComponents[0])
|
||||
minutes = Double(startComponents[1])
|
||||
seconds = Double(startComponents[2])
|
||||
} else if startComponents.count == 2 {
|
||||
minutes = Double(startComponents[0])
|
||||
seconds = Double(startComponents[1])
|
||||
}
|
||||
|
||||
guard var startSeconds = seconds else { return nil }
|
||||
|
||||
startSeconds += (minutes ?? 0) * 60
|
||||
startSeconds += (hours ?? 0) * 60 * 60
|
||||
|
||||
return Chapter(title: titleCapture, start: startSeconds)
|
||||
}
|
||||
|
||||
guard var startSeconds = seconds else { return nil }
|
||||
|
||||
startSeconds += (minutes ?? 0) * 60
|
||||
startSeconds += (hours ?? 0) * 60 * 60
|
||||
|
||||
return .init(title: titleCapture, start: startSeconds)
|
||||
if !extractedChapters.isEmpty {
|
||||
lock.lock()
|
||||
capturedChapters[index] = extractedChapters
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
extractChaptersGroup.leave()
|
||||
}
|
||||
}
|
||||
|
||||
extractChaptersGroup.wait()
|
||||
|
||||
// Now we sort the keys of the capturedChapters dictionary.
|
||||
// These keys correspond to the priority of each pattern.
|
||||
let sortedKeys = Array(capturedChapters.keys).sorted(by: <)
|
||||
|
||||
// Return first non-empty result in the order of patterns
|
||||
for key in sortedKeys {
|
||||
if let chapters = capturedChapters[key], !chapters.isEmpty {
|
||||
return chapters
|
||||
}
|
||||
}
|
||||
return []
|
||||
|
@@ -217,9 +217,22 @@ final class MPVBackend: PlayerBackend {
|
||||
#endif
|
||||
|
||||
var captions: Captions?
|
||||
if let captionsLanguageCode = Defaults[.captionsLanguageCode] {
|
||||
captions = video.captions.first { $0.code == captionsLanguageCode } ??
|
||||
video.captions.first { $0.code.contains(captionsLanguageCode) }
|
||||
|
||||
if Defaults[.captionsAutoShow] == true {
|
||||
let captionsDefaultLanguageCode = Defaults[.captionsDefaultLanguageCode],
|
||||
captionsFallbackLanguageCode = Defaults[.captionsFallbackLanguageCode]
|
||||
|
||||
// Try to get captions with the default language code first
|
||||
captions = video.captions.first { $0.code == captionsDefaultLanguageCode } ??
|
||||
video.captions.first { $0.code.contains(captionsDefaultLanguageCode) }
|
||||
|
||||
// If there are still no captions, try to get captions with the fallback language code
|
||||
if captions.isNil && !captionsFallbackLanguageCode.isEmpty {
|
||||
captions = video.captions.first { $0.code == captionsFallbackLanguageCode } ??
|
||||
video.captions.first { $0.code.contains(captionsFallbackLanguageCode) }
|
||||
}
|
||||
} else {
|
||||
captions = nil
|
||||
}
|
||||
|
||||
let updateCurrentStream = {
|
||||
@@ -254,9 +267,8 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
self.startClientUpdates()
|
||||
|
||||
// Captions should only be displayed when selected by the user,
|
||||
// not when the video starts. So, we remove them.
|
||||
self.client?.removeSubs()
|
||||
if Defaults[.captionsAutoShow] { self.client?.setSubToAuto() } else { self.client?.setSubToNo() }
|
||||
PlayerModel.shared.captions = self.captions
|
||||
|
||||
if !preservingTime,
|
||||
!upgrading,
|
||||
|
@@ -68,6 +68,8 @@ final class MPVClient: ObservableObject {
|
||||
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
|
||||
checkError(mpv_set_option_string(mpv, "demuxer-lavf-analyzeduration", "1"))
|
||||
checkError(mpv_set_option_string(mpv, "deinterlace", Defaults[.mpvDeinterlace] ? "yes" : "no"))
|
||||
checkError(mpv_set_option_string(mpv, "sub-scale", Defaults[.captionsFontScaleSize]))
|
||||
checkError(mpv_set_option_string(mpv, "sub-color", Defaults[.captionsFontColor]))
|
||||
checkError(mpv_set_option_string(mpv, "user-agent", UserAgentManager.shared.userAgent))
|
||||
|
||||
checkError(mpv_initialize(mpv))
|
||||
@@ -406,6 +408,22 @@ final class MPVClient: ObservableObject {
|
||||
setString("video", "no")
|
||||
}
|
||||
|
||||
func setSubToAuto() {
|
||||
setString("sub", "auto")
|
||||
}
|
||||
|
||||
func setSubToNo() {
|
||||
setString("sub", "no")
|
||||
}
|
||||
|
||||
func setSubFontSize(scaleSize: String) {
|
||||
setString("sub-scale", scaleSize)
|
||||
}
|
||||
|
||||
func setSubFontColor(color: String) {
|
||||
setString("sub-color", color)
|
||||
}
|
||||
|
||||
var tracksCount: Int {
|
||||
Int(getString("track-list/count") ?? "-1") ?? -1
|
||||
}
|
||||
|
@@ -133,23 +133,22 @@ extension PlayerBackend {
|
||||
}
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting, formatOrder: [QualityProfile.Format]) -> Stream? {
|
||||
// filter out non HLS streams
|
||||
let nonHLSStreams = streams.filter { $0.kind != .hls }
|
||||
// filter out non-HLS streams and streams with resolution more than maxResolution
|
||||
let nonHLSStreams = streams.filter {
|
||||
$0.kind != .hls && $0.resolution <= maxResolution.value
|
||||
}
|
||||
|
||||
// find max resolution from non HLS streams
|
||||
let bestResolution = nonHLSStreams
|
||||
.filter { $0.resolution <= maxResolution.value }
|
||||
.max { $0.resolution < $1.resolution }
|
||||
// find max resolution and bitrate from non-HLS streams
|
||||
let bestResolutionStream = nonHLSStreams.max { $0.resolution < $1.resolution }
|
||||
let bestBitrateStream = nonHLSStreams.max { $0.bitrate ?? 0 < $1.bitrate ?? 0 }
|
||||
|
||||
// finde max bitrate from non HLS streams
|
||||
let bestBitrate = nonHLSStreams
|
||||
.filter { $0.resolution <= maxResolution.value }
|
||||
.max { $0.bitrate ?? 0 < $1.bitrate ?? 0 }
|
||||
let bestResolution = bestResolutionStream?.resolution ?? maxResolution.value
|
||||
let bestBitrate = bestBitrateStream?.bitrate ?? bestResolutionStream?.resolution.bitrate ?? maxResolution.value.bitrate
|
||||
|
||||
return streams.map { stream in
|
||||
if stream.kind == .hls {
|
||||
stream.resolution = bestResolution?.resolution ?? maxResolution.value
|
||||
stream.bitrate = bestBitrate?.bitrate ?? (bestResolution?.resolution.bitrate ?? maxResolution.value.bitrate)
|
||||
stream.resolution = bestResolution
|
||||
stream.bitrate = bestBitrate
|
||||
stream.format = .hls
|
||||
} else if stream.kind == .stream {
|
||||
stream.format = .stream
|
||||
|
@@ -196,11 +196,13 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
func streamsSorter(_ lhs: Stream, _ rhs: Stream) -> Bool {
|
||||
if lhs.resolution.isNil || rhs.resolution.isNil {
|
||||
func streamsSorter(lhs: Stream, rhs: Stream) -> Bool {
|
||||
// Use optional chaining to simplify nil handling
|
||||
guard let lhsRes = lhs.resolution?.height, let rhsRes = rhs.resolution?.height else {
|
||||
return lhs.kind < rhs.kind
|
||||
}
|
||||
|
||||
return lhs.kind == rhs.kind ? (lhs.resolution.height > rhs.resolution.height) : (lhs.kind < rhs.kind)
|
||||
// Compare either kind or resolution based on conditions
|
||||
return lhs.kind == rhs.kind ? (lhsRes > rhsRes) : (lhs.kind < rhs.kind)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user