mirror of
https://github.com/yattee/yattee.git
synced 2024-11-10 08:18:19 +00:00
ecba91f35d
This should reduce the number of falsely matched chapters, e.g., when time-code-like numbers appear in the middle of the text, like 16:9 or sports results. It also checks for chapters that have an end time and omits the end time code from the title. Track lists in music videos are now also properly displayed as chapters.
207 lines
7.4 KiB
Swift
207 lines
7.4 KiB
Swift
import AVFoundation
|
|
import Foundation
|
|
import Siesta
|
|
|
|
protocol VideosAPI {
|
|
var account: Account! { get }
|
|
var signedIn: Bool { get }
|
|
|
|
static func withAnonymousAccountForInstanceURL(_ url: URL) -> Self
|
|
|
|
func channel(_ id: String, contentType: Channel.ContentType, data: String?, page: String?) -> Resource
|
|
func channelByName(_ name: String) -> Resource?
|
|
func channelByUsername(_ username: String) -> Resource?
|
|
func channelVideos(_ id: String) -> Resource
|
|
func trending(country: Country, category: TrendingCategory?) -> Resource
|
|
func search(_ query: SearchQuery, page: String?) -> Resource
|
|
func searchSuggestions(query: String) -> Resource
|
|
|
|
func video(_ id: Video.ID) -> Resource
|
|
|
|
func feed(_ page: Int?) -> Resource?
|
|
var subscriptions: Resource? { get }
|
|
var home: Resource? { get }
|
|
var popular: Resource? { get }
|
|
var playlists: Resource? { get }
|
|
|
|
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void)
|
|
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void)
|
|
|
|
func playlist(_ id: String) -> Resource?
|
|
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
|
|
func playlistVideos(_ id: String) -> Resource?
|
|
|
|
func addVideoToPlaylist(
|
|
_ videoID: String,
|
|
_ playlistID: String,
|
|
onFailure: @escaping (RequestError) -> Void,
|
|
onSuccess: @escaping () -> Void
|
|
)
|
|
|
|
func removeVideoFromPlaylist(
|
|
_ index: String,
|
|
_ playlistID: String,
|
|
onFailure: @escaping (RequestError) -> Void,
|
|
onSuccess: @escaping () -> Void
|
|
)
|
|
|
|
func playlistForm(
|
|
_ name: String,
|
|
_ visibility: String,
|
|
playlist: Playlist?,
|
|
onFailure: @escaping (RequestError) -> Void,
|
|
onSuccess: @escaping (Playlist?) -> Void
|
|
)
|
|
|
|
func deletePlaylist(
|
|
_ playlist: Playlist,
|
|
onFailure: @escaping (RequestError) -> Void,
|
|
onSuccess: @escaping () -> Void
|
|
)
|
|
|
|
func channelPlaylist(_ id: String) -> Resource?
|
|
|
|
func loadDetails(
|
|
_ item: PlayerQueueItem,
|
|
failureHandler: ((RequestError) -> Void)?,
|
|
completionHandler: @escaping (PlayerQueueItem) -> Void
|
|
)
|
|
func shareURL(_ item: ContentItem, frontendHost: String?, time: CMTime?) -> URL?
|
|
|
|
func comments(_ id: Video.ID, page: String?) -> Resource?
|
|
}
|
|
|
|
extension VideosAPI {
|
|
func channel(_ id: String, contentType: Channel.ContentType, data: String? = nil, page: String? = nil) -> Resource {
|
|
channel(id, contentType: contentType, data: data, page: page)
|
|
}
|
|
|
|
func loadDetails(
|
|
_ item: PlayerQueueItem,
|
|
failureHandler: ((RequestError) -> Void)? = nil,
|
|
completionHandler: @escaping (PlayerQueueItem) -> Void = { _ in }
|
|
) {
|
|
guard (item.video?.streams ?? []).isEmpty else {
|
|
completionHandler(item)
|
|
return
|
|
}
|
|
|
|
if let video = item.video, video.isLocal {
|
|
completionHandler(item)
|
|
return
|
|
}
|
|
|
|
video(item.videoID).load()
|
|
.onSuccess { response in
|
|
guard let video: Video = response.typedContent() else {
|
|
return
|
|
}
|
|
|
|
VideosCacheModel.shared.storeVideo(video)
|
|
|
|
var newItem = item
|
|
newItem.id = UUID()
|
|
newItem.video = video
|
|
|
|
completionHandler(newItem)
|
|
}
|
|
.onFailure { failureHandler?($0) }
|
|
}
|
|
|
|
func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
|
|
guard let frontendHost = frontendHost ?? account?.instance?.frontendHost,
|
|
var urlComponents = account?.instance?.urlComponents
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
urlComponents.host = frontendHost
|
|
|
|
var queryItems = [URLQueryItem]()
|
|
|
|
switch item.contentType {
|
|
case .video:
|
|
urlComponents.path = "/watch"
|
|
queryItems.append(.init(name: "v", value: item.video.videoID))
|
|
case .channel:
|
|
urlComponents.path = "/channel/\(item.channel.id)"
|
|
case .playlist:
|
|
urlComponents.path = "/playlist"
|
|
queryItems.append(.init(name: "list", value: item.playlist.id))
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
if !time.isNil, time!.seconds.isFinite {
|
|
queryItems.append(.init(name: "t", value: "\(Int(time!.seconds))s"))
|
|
}
|
|
|
|
if !queryItems.isEmpty {
|
|
urlComponents.queryItems = queryItems
|
|
}
|
|
|
|
return urlComponents.url
|
|
}
|
|
|
|
func extractChapters(from description: String) -> [Chapter] {
|
|
/*
|
|
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)
|
|
|
|
The order is important!
|
|
*/
|
|
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|$)"
|
|
]
|
|
|
|
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))
|
|
|
|
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 }
|
|
|
|
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 .init(title: titleCapture, start: startSeconds)
|
|
}
|
|
}
|
|
}
|
|
return []
|
|
}
|
|
}
|