mirror of
https://github.com/yattee/yattee.git
synced 2024-12-23 05:53:41 +00:00
325 lines
11 KiB
Swift
325 lines
11 KiB
Swift
import AVFoundation
|
|
import Foundation
|
|
import Siesta
|
|
import SwiftyJSON
|
|
|
|
final class PipedAPI: Service, ObservableObject, VideosAPI {
|
|
@Published var account: Account!
|
|
|
|
var anonymousAccount: Account {
|
|
.init(instanceID: account.instance.id, name: "Anonymous", url: account.instance.url)
|
|
}
|
|
|
|
init(account: Account? = nil) {
|
|
super.init()
|
|
|
|
guard account != nil else {
|
|
return
|
|
}
|
|
|
|
setAccount(account!)
|
|
}
|
|
|
|
func setAccount(_ account: Account) {
|
|
self.account = account
|
|
|
|
configure()
|
|
}
|
|
|
|
func configure() {
|
|
configure {
|
|
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
|
}
|
|
|
|
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
|
|
PipedAPI.extractChannel(from: content.json)
|
|
}
|
|
|
|
configureTransformer(pathPattern("playlists/*")) { (content: Entity<JSON>) -> ChannelPlaylist? in
|
|
PipedAPI.extractChannelPlaylist(from: content.json)
|
|
}
|
|
|
|
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
|
|
PipedAPI.extractVideo(from: content.json)
|
|
}
|
|
|
|
configureTransformer(pathPattern("trending")) { (content: Entity<JSON>) -> [Video] in
|
|
PipedAPI.extractVideos(from: content.json)
|
|
}
|
|
|
|
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> [ContentItem] in
|
|
PipedAPI.extractContentItems(from: content.json.dictionaryValue["items"]!)
|
|
}
|
|
|
|
configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in
|
|
content.json.arrayValue.map(String.init)
|
|
}
|
|
}
|
|
|
|
func channel(_ id: String) -> Resource {
|
|
resource(baseURL: account.url, path: "channel/\(id)")
|
|
}
|
|
|
|
func channelPlaylist(_ id: String) -> Resource? {
|
|
resource(baseURL: account.url, path: "playlists/\(id)")
|
|
}
|
|
|
|
func trending(country: Country, category _: TrendingCategory? = nil) -> Resource {
|
|
resource(baseURL: account.instance.url, path: "trending")
|
|
.withParam("region", country.rawValue)
|
|
}
|
|
|
|
func search(_ query: SearchQuery) -> Resource {
|
|
resource(baseURL: account.instance.url, path: "search")
|
|
.withParam("q", query.query)
|
|
.withParam("filter", "")
|
|
}
|
|
|
|
func searchSuggestions(query: String) -> Resource {
|
|
resource(baseURL: account.instance.url, path: "suggestions")
|
|
.withParam("query", query.lowercased())
|
|
}
|
|
|
|
func video(_ id: Video.ID) -> Resource {
|
|
resource(baseURL: account.instance.url, path: "streams/\(id)")
|
|
}
|
|
|
|
var signedIn: Bool { false }
|
|
|
|
var subscriptions: Resource? { nil }
|
|
var feed: Resource? { nil }
|
|
var home: Resource? { nil }
|
|
var popular: Resource? { nil }
|
|
var playlists: Resource? { nil }
|
|
|
|
func channelSubscription(_: String) -> Resource? { nil }
|
|
|
|
func playlistVideo(_: String, _: String) -> Resource? { nil }
|
|
func playlistVideos(_: String) -> Resource? { nil }
|
|
|
|
private func pathPattern(_ path: String) -> String {
|
|
"**\(path)"
|
|
}
|
|
|
|
private static func extractContentItem(from content: JSON) -> ContentItem? {
|
|
let details = content.dictionaryValue
|
|
let url: String! = details["url"]?.string
|
|
|
|
let contentType: ContentItem.ContentType
|
|
|
|
if !url.isNil {
|
|
if url.contains("/playlist") {
|
|
contentType = .playlist
|
|
} else if url.contains("/channel") {
|
|
contentType = .channel
|
|
} else {
|
|
contentType = .video
|
|
}
|
|
} else {
|
|
contentType = .video
|
|
}
|
|
|
|
switch contentType {
|
|
case .video:
|
|
if let video = PipedAPI.extractVideo(from: content) {
|
|
return ContentItem(video: video)
|
|
}
|
|
|
|
case .playlist:
|
|
if let playlist = PipedAPI.extractChannelPlaylist(from: content) {
|
|
return ContentItem(playlist: playlist)
|
|
}
|
|
|
|
case .channel:
|
|
if let channel = PipedAPI.extractChannel(from: content) {
|
|
return ContentItem(channel: channel)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func extractContentItems(from content: JSON) -> [ContentItem] {
|
|
content.arrayValue.compactMap { PipedAPI.extractContentItem(from: $0) }
|
|
}
|
|
|
|
private static func extractChannel(from content: JSON) -> Channel? {
|
|
let attributes = content.dictionaryValue
|
|
guard let id = attributes["id"]?.stringValue ??
|
|
(attributes["url"] ?? attributes["uploaderUrl"])?.stringValue.components(separatedBy: "/").last
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
let subscriptionsCount = attributes["subscriberCount"]?.intValue ?? attributes["subscribers"]?.intValue
|
|
|
|
var videos = [Video]()
|
|
if let relatedStreams = attributes["relatedStreams"] {
|
|
videos = PipedAPI.extractVideos(from: relatedStreams)
|
|
}
|
|
|
|
return Channel(
|
|
id: id,
|
|
name: attributes["name"]!.stringValue,
|
|
thumbnailURL: attributes["thumbnail"]?.url,
|
|
subscriptionsCount: subscriptionsCount,
|
|
videos: videos
|
|
)
|
|
}
|
|
|
|
static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
|
|
let details = json.dictionaryValue
|
|
let id = details["url"]?.stringValue.components(separatedBy: "?list=").last ?? UUID().uuidString
|
|
let thumbnailURL = details["thumbnail"]?.url ?? details["thumbnailUrl"]?.url
|
|
var videos = [Video]()
|
|
if let relatedStreams = details["relatedStreams"] {
|
|
videos = PipedAPI.extractVideos(from: relatedStreams)
|
|
}
|
|
return ChannelPlaylist(
|
|
id: id,
|
|
title: details["name"]!.stringValue,
|
|
thumbnailURL: thumbnailURL,
|
|
channel: extractChannel(from: json)!,
|
|
videos: videos,
|
|
videosCount: details["videos"]?.int
|
|
)
|
|
}
|
|
|
|
private static func extractVideo(from content: JSON) -> Video? {
|
|
let details = content.dictionaryValue
|
|
let url = details["url"]?.string
|
|
|
|
if !url.isNil {
|
|
guard url!.contains("/watch") else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
let channelId = details["uploaderUrl"]!.stringValue.components(separatedBy: "/").last!
|
|
|
|
let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap {
|
|
if let url = PipedAPI.buildThumbnailURL(from: content, quality: $0) {
|
|
return Thumbnail(url: url, quality: $0)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
|
|
|
|
return Video(
|
|
videoID: PipedAPI.extractID(from: content),
|
|
title: details["title"]!.stringValue,
|
|
author: author,
|
|
length: details["duration"]!.doubleValue,
|
|
published: details["uploadedDate"]?.stringValue ?? details["uploadDate"]!.stringValue,
|
|
views: details["views"]!.intValue,
|
|
description: PipedAPI.extractDescription(from: content),
|
|
channel: Channel(id: channelId, name: author),
|
|
thumbnails: thumbnails,
|
|
likes: details["likes"]?.int,
|
|
dislikes: details["dislikes"]?.int,
|
|
streams: extractStreams(from: content)
|
|
)
|
|
}
|
|
|
|
private static func extractID(from content: JSON) -> Video.ID {
|
|
content.dictionaryValue["url"]?.stringValue.components(separatedBy: "?v=").last ??
|
|
extractThumbnailURL(from: content)!.relativeString.components(separatedBy: "/")[4]
|
|
}
|
|
|
|
private static func extractThumbnailURL(from content: JSON) -> URL? {
|
|
content.dictionaryValue["thumbnail"]?.url! ?? content.dictionaryValue["thumbnailUrl"]!.url!
|
|
}
|
|
|
|
private static func buildThumbnailURL(from content: JSON, quality: Thumbnail.Quality) -> URL? {
|
|
let thumbnailURL = extractThumbnailURL(from: content)
|
|
guard !thumbnailURL.isNil else {
|
|
return nil
|
|
}
|
|
|
|
return URL(string: thumbnailURL!
|
|
.absoluteString
|
|
.replacingOccurrences(of: "hqdefault", with: quality.filename)
|
|
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
|
|
)!
|
|
}
|
|
|
|
private static func extractDescription(from content: JSON) -> String? {
|
|
guard var description = content.dictionaryValue["description"]?.string else {
|
|
return nil
|
|
}
|
|
|
|
description = description.replacingOccurrences(
|
|
of: "<br/>|<br />|<br>",
|
|
with: "\n",
|
|
options: .regularExpression,
|
|
range: nil
|
|
)
|
|
|
|
description = description.replacingOccurrences(
|
|
of: "<[^>]+>",
|
|
with: "",
|
|
options: .regularExpression,
|
|
range: nil
|
|
)
|
|
|
|
return description
|
|
}
|
|
|
|
private static func extractVideos(from content: JSON) -> [Video] {
|
|
content.arrayValue.compactMap(extractVideo(from:))
|
|
}
|
|
|
|
private static func extractStreams(from content: JSON) -> [Stream] {
|
|
var streams = [Stream]()
|
|
|
|
if let hlsURL = content.dictionaryValue["hls"]?.url {
|
|
streams.append(Stream(hlsURL: hlsURL))
|
|
}
|
|
|
|
guard let audioStream = PipedAPI.compatibleAudioStreams(from: content).first else {
|
|
return streams
|
|
}
|
|
|
|
let videoStreams = PipedAPI.compatibleVideoStream(from: content)
|
|
|
|
videoStreams.forEach { videoStream in
|
|
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
|
|
let videoAsset = AVURLAsset(url: videoStream.dictionaryValue["url"]!.url!)
|
|
|
|
let videoOnly = videoStream.dictionaryValue["videoOnly"]?.boolValue ?? true
|
|
let resolution = Stream.Resolution.from(resolution: videoStream.dictionaryValue["quality"]!.stringValue)
|
|
|
|
if videoOnly {
|
|
streams.append(
|
|
Stream(audioAsset: audioAsset, videoAsset: videoAsset, resolution: resolution, kind: .adaptive)
|
|
)
|
|
} else {
|
|
streams.append(
|
|
SingleAssetStream(avAsset: videoAsset, resolution: resolution, kind: .stream)
|
|
)
|
|
}
|
|
}
|
|
|
|
return streams
|
|
}
|
|
|
|
private static func compatibleAudioStreams(from content: JSON) -> [JSON] {
|
|
content
|
|
.dictionaryValue["audioStreams"]?
|
|
.arrayValue
|
|
.filter { $0.dictionaryValue["format"]?.stringValue == "M4A" }
|
|
.sorted {
|
|
$0.dictionaryValue["bitrate"]?.intValue ?? 0 > $1.dictionaryValue["bitrate"]?.intValue ?? 0
|
|
} ?? []
|
|
}
|
|
|
|
private static func compatibleVideoStream(from content: JSON) -> [JSON] {
|
|
content
|
|
.dictionaryValue["videoStreams"]?
|
|
.arrayValue
|
|
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
|
|
}
|
|
}
|