2022-12-09 00:15:19 +00:00
import Alamofire
import AVKit
import Defaults
import Foundation
import Siesta
import SwiftyJSON
final class PeerTubeAPI : Service , ObservableObject , VideosAPI {
static let basePath = " /api/v1 "
@ Published var account : Account !
@ Published var validInstance = true
var signedIn : Bool {
guard let account else { return false }
return ! account . anonymous && ! ( account . token ? . isEmpty ? ? true )
}
static func withAnonymousAccountForInstanceURL ( _ url : URL ) -> PeerTubeAPI {
. init ( account : Instance ( app : . peerTube , apiURLString : url . absoluteString ) . anonymousAccount )
}
init ( account : Account ? = nil ) {
super . init ( )
guard ! account . isNil else {
self . account = . init ( name : " Empty " )
return
}
setAccount ( account ! )
}
func setAccount ( _ account : Account ) {
self . account = account
validInstance = account . anonymous
configure ( )
if ! account . anonymous {
validate ( )
}
}
func validate ( ) {
validateInstance ( )
validateSID ( )
}
func validateInstance ( ) {
guard ! validInstance else {
return
}
home ?
. load ( )
. onSuccess { _ in
self . validInstance = true
}
. onFailure { _ in
self . validInstance = false
}
}
func validateSID ( ) {
guard signedIn , ! ( account . token ? . isEmpty ? ? true ) else {
return
}
2022-12-10 02:01:59 +00:00
feed ( 1 ) ?
2022-12-09 00:15:19 +00:00
. load ( )
. onFailure { _ in
self . updateToken ( force : true )
}
}
func configure ( ) {
invalidateConfiguration ( )
configure {
if let cookie = self . cookieHeader {
$0 . headers [ " Cookie " ] = cookie
}
$0 . pipeline [ . parsing ] . add ( SwiftyJSONTransformer , contentTypes : [ " */json " ] )
}
configure ( " ** " , requestMethods : [ . post ] ) {
$0 . pipeline [ . parsing ] . removeTransformers ( )
}
configureTransformer ( pathPattern ( " popular " ) , requestMethods : [ . get ] ) { ( content : Entity < JSON > ) -> [ Video ] in
content . json . arrayValue . map ( self . extractVideo )
}
configureTransformer ( pathPattern ( " videos " ) , requestMethods : [ . get ] ) { ( content : Entity < JSON > ) -> [ Video ] in
content . json . dictionaryValue [ " data " ] ? . arrayValue . map ( self . extractVideo ) ? ? [ ]
}
configureTransformer ( pathPattern ( " search/videos " ) , requestMethods : [ . get ] ) { ( content : Entity < JSON > ) -> SearchPage in
let results = content . json . dictionaryValue [ " data " ] ? . arrayValue . compactMap { json -> ContentItem in . init ( video : self . extractVideo ( from : json ) ) } ? ? [ ]
return SearchPage ( results : results , last : results . isEmpty )
}
configureTransformer ( pathPattern ( " search/suggestions " ) , requestMethods : [ . get ] ) { ( content : Entity < JSON > ) -> [ String ] in
if let suggestions = content . json . dictionaryValue [ " suggestions " ] {
return suggestions . arrayValue . map ( String . init )
}
return [ ]
}
configureTransformer ( pathPattern ( " auth/playlists " ) , requestMethods : [ . get ] ) { ( content : Entity < JSON > ) -> [ Playlist ] in
content . json . arrayValue . map ( self . extractPlaylist )
}
configureTransformer ( pathPattern ( " auth/playlists/* " ) , requestMethods : [ . get ] ) { ( content : Entity < JSON > ) -> Playlist in
self . extractPlaylist ( from : content . json )
}
configureTransformer ( pathPattern ( " auth/playlists " ) , requestMethods : [ . post , . patch ] ) { ( content : Entity < Data > ) -> Playlist in
// h a c k y , t o v e r i f y i f p o s s i b l e t o g e t i t i n e a s i e r w a y
Playlist ( JSON ( parseJSON : String ( data : content . content , encoding : . utf8 ) ! ) )
}
configureTransformer ( pathPattern ( " auth/feed " ) , requestMethods : [ . get ] ) { ( content : Entity < JSON > ) -> [ Video ] in
if let feedVideos = content . json . dictionaryValue [ " videos " ] {
return feedVideos . arrayValue . map ( self . extractVideo )
}
return [ ]
}
configureTransformer ( pathPattern ( " auth/subscriptions " ) , requestMethods : [ . get ] ) { ( content : Entity < JSON > ) -> [ Channel ] in
content . json . arrayValue . map ( self . extractChannel )
}
configureTransformer ( pathPattern ( " channels/* " ) , requestMethods : [ . get ] ) { ( content : Entity < JSON > ) -> Channel in
self . extractChannel ( from : content . json )
}
configureTransformer ( pathPattern ( " channels/*/latest " ) , requestMethods : [ . get ] ) { ( content : Entity < JSON > ) -> [ Video ] in
content . json . arrayValue . map ( self . extractVideo )
}
configureTransformer ( pathPattern ( " channels/*/playlists " ) , requestMethods : [ . get ] ) { ( content : Entity < JSON > ) -> [ ContentItem ] in
let playlists = ( content . json . dictionaryValue [ " playlists " ] ? . arrayValue ? ? [ ] ) . compactMap { self . extractChannelPlaylist ( from : $0 ) }
return ContentItem . array ( of : playlists )
}
configureTransformer ( pathPattern ( " playlists/* " ) , requestMethods : [ . get ] ) { ( content : Entity < JSON > ) -> ChannelPlaylist in
self . extractChannelPlaylist ( from : content . json )
}
configureTransformer ( pathPattern ( " videos/* " ) , requestMethods : [ . get ] ) { ( content : Entity < JSON > ) -> Video in
self . extractVideo ( from : content . json )
}
configureTransformer ( pathPattern ( " comments/* " ) ) { ( content : Entity < JSON > ) -> CommentsPage in
let details = content . json . dictionaryValue
let comments = details [ " comments " ] ? . arrayValue . compactMap { self . extractComment ( from : $0 ) } ? ? [ ]
let nextPage = details [ " continuation " ] ? . string
let disabled = ! details [ " error " ] . isNil
return CommentsPage ( comments : comments , nextPage : nextPage , disabled : disabled )
}
updateToken ( )
}
func updateToken ( force : Bool = false ) {
let ( username , password ) = AccountsModel . getCredentials ( account )
guard ! account . anonymous ,
( account . token ? . isEmpty ? ? true ) || force
else {
return
}
guard let username ,
let password ,
! username . isEmpty ,
! password . isEmpty
else {
NavigationModel . shared . presentAlert (
title : " Account Error " ,
message : " Remove and add your account again in Settings. "
)
return
}
let presentTokenUpdateFailedAlert : ( AFDataResponse < Data ? >? , String ? ) -> Void = { response , message in
NavigationModel . shared . presentAlert (
title : " Account Error " ,
message : message ? ? " \( response ? . response ? . statusCode ? ? - 1 ) - \( response ? . error ? . errorDescription ? ? " unknown " ) \n If this issue persists, try removing and adding your account again in Settings. "
)
}
AF
. request ( login . url , method : . post , parameters : [ " email " : username , " password " : password ] , encoding : URLEncoding . default )
. redirect ( using : . doNotFollow )
. response { response in
guard let headers = response . response ? . headers ,
let cookies = headers [ " Set-Cookie " ]
else {
presentTokenUpdateFailedAlert ( response , nil )
return
}
let sidRegex = # " SID=(?<sid>[^;]*); " #
guard let sidRegex = try ? NSRegularExpression ( pattern : sidRegex ) ,
let match = sidRegex . matches ( in : cookies , range : NSRange ( cookies . startIndex . . . , in : cookies ) ) . first
else {
presentTokenUpdateFailedAlert ( nil , String ( format : " Could not extract SID from received cookies: %@ " . localized ( ) , cookies ) )
return
}
let matchRange = match . range ( withName : " sid " )
if let substringRange = Range ( matchRange , in : cookies ) {
let sid = String ( cookies [ substringRange ] )
AccountsModel . setToken ( self . account , sid )
self . objectWillChange . send ( )
} else {
presentTokenUpdateFailedAlert ( nil , String ( format : " Could not extract SID from received cookies: %@ " . localized ( ) , cookies ) )
}
self . configure ( )
}
}
var login : Resource {
resource ( baseURL : account . url , path : " login " )
}
private func pathPattern ( _ path : String ) -> String {
" ** \( Self . basePath ) / \( path ) "
}
private func basePathAppending ( _ path : String ) -> String {
" \( Self . basePath ) / \( path ) "
}
private var cookieHeader : String ? {
guard let token = account ? . token , ! token . isEmpty else { return nil }
return " SID= \( token ) "
}
var popular : Resource ? {
resource ( baseURL : account . url , path : " \( Self . basePath ) /popular " )
}
func trending ( country : Country , category : TrendingCategory ? ) -> Resource {
resource ( baseURL : account . url , path : " \( Self . basePath ) /videos " )
. withParam ( " isLocal " , " true " )
// . w i t h P a r a m ( " t y p e " , c a t e g o r y ? . n a m e )
// . w i t h P a r a m ( " r e g i o n " , c o u n t r y . r a w V a l u e )
}
var home : Resource ? {
resource ( baseURL : account . url , path : " /feed/subscriptions " )
}
2022-12-10 02:01:59 +00:00
func feed ( _ page : Int ? ) -> Resource ? {
2022-12-09 00:15:19 +00:00
resource ( baseURL : account . url , path : " \( Self . basePath ) /auth/feed " )
2022-12-10 02:01:59 +00:00
. withParam ( " page " , String ( page ? ? 1 ) )
2022-12-09 00:15:19 +00:00
}
var subscriptions : Resource ? {
resource ( baseURL : account . url , path : basePathAppending ( " auth/subscriptions " ) )
}
func subscribe ( _ channelID : String , onCompletion : @ escaping ( ) -> Void = { } ) {
resource ( baseURL : account . url , path : basePathAppending ( " auth/subscriptions " ) )
. child ( channelID )
. request ( . post )
. onCompletion { _ in onCompletion ( ) }
}
func unsubscribe ( _ channelID : String , onCompletion : @ escaping ( ) -> Void ) {
resource ( baseURL : account . url , path : basePathAppending ( " auth/subscriptions " ) )
. child ( channelID )
. request ( . delete )
. onCompletion { _ in onCompletion ( ) }
}
func channel ( _ id : String , contentType : Channel . ContentType , data _ : String ? ) -> Resource {
if contentType = = . playlists {
return resource ( baseURL : account . url , path : basePathAppending ( " channels/ \( id ) /playlists " ) )
}
return resource ( baseURL : account . url , path : basePathAppending ( " channels/ \( id ) " ) )
}
func channelByName ( _ : String ) -> Resource ? {
nil
}
func channelByUsername ( _ : String ) -> Resource ? {
nil
}
func channelVideos ( _ id : String ) -> Resource {
resource ( baseURL : account . url , path : basePathAppending ( " channels/ \( id ) /latest " ) )
}
func video ( _ id : String ) -> Resource {
resource ( baseURL : account . url , path : basePathAppending ( " videos/ \( id ) " ) )
}
var playlists : Resource ? {
if account . isNil || account . anonymous {
return nil
}
return resource ( baseURL : account . url , path : basePathAppending ( " auth/playlists " ) )
}
func playlist ( _ id : String ) -> Resource ? {
resource ( baseURL : account . url , path : basePathAppending ( " auth/playlists/ \( id ) " ) )
}
func playlistVideos ( _ id : String ) -> Resource ? {
playlist ( id ) ? . child ( " videos " )
}
func playlistVideo ( _ playlistID : String , _ videoID : String ) -> Resource ? {
playlist ( playlistID ) ? . child ( " videos " ) . child ( videoID )
}
func addVideoToPlaylist (
_ videoID : String ,
_ playlistID : String ,
onFailure : @ escaping ( RequestError ) -> Void = { _ in } ,
onSuccess : @ escaping ( ) -> Void = { }
) {
let resource = playlistVideos ( playlistID )
let body = [ " videoId " : videoID ]
resource ?
. request ( . post , json : body )
. onSuccess { _ in onSuccess ( ) }
. onFailure ( onFailure )
}
func removeVideoFromPlaylist (
_ index : String ,
_ playlistID : String ,
onFailure : @ escaping ( RequestError ) -> Void ,
onSuccess : @ escaping ( ) -> Void
) {
let resource = playlistVideo ( playlistID , index )
resource ?
. request ( . delete )
. onSuccess { _ in onSuccess ( ) }
. onFailure ( onFailure )
}
func playlistForm (
_ name : String ,
_ visibility : String ,
playlist : Playlist ? ,
onFailure : @ escaping ( RequestError ) -> Void ,
onSuccess : @ escaping ( Playlist ? ) -> Void
) {
let body = [ " title " : name , " privacy " : visibility ]
let resource = ! playlist . isNil ? self . playlist ( playlist ! . id ) : playlists
resource ?
. request ( ! playlist . isNil ? . patch : . post , json : body )
. onSuccess { response in
if let modifiedPlaylist : Playlist = response . typedContent ( ) {
onSuccess ( modifiedPlaylist )
}
}
. onFailure ( onFailure )
}
func deletePlaylist (
_ playlist : Playlist ,
onFailure : @ escaping ( RequestError ) -> Void ,
onSuccess : @ escaping ( ) -> Void
) {
self . playlist ( playlist . id ) ?
. request ( . delete )
. onSuccess { _ in onSuccess ( ) }
. onFailure ( onFailure )
}
func channelPlaylist ( _ id : String ) -> Resource ? {
resource ( baseURL : account . url , path : basePathAppending ( " playlists/ \( id ) " ) )
}
func search ( _ query : SearchQuery , page : String ? ) -> Resource {
var resource = resource ( baseURL : account . url , path : basePathAppending ( " search/videos " ) )
. withParam ( " search " , query . query )
// . w i t h P a r a m ( " s o r t _ b y " , q u e r y . s o r t B y . p a r a m e t e r )
// . w i t h P a r a m ( " t y p e " , " a l l " )
//
// i f l e t d a t e = q u e r y . d a t e , d a t e ! = . a n y {
// r e s o u r c e = r e s o u r c e . w i t h P a r a m ( " d a t e " , d a t e . r a w V a l u e )
// }
//
// i f l e t d u r a t i o n = q u e r y . d u r a t i o n , d u r a t i o n ! = . a n y {
// r e s o u r c e = r e s o u r c e . w i t h P a r a m ( " d u r a t i o n " , d u r a t i o n . r a w V a l u e )
// }
//
// i f l e t p a g e {
// r e s o u r c e = r e s o u r c e . w i t h P a r a m ( " p a g e " , p a g e )
// }
return resource
}
func searchSuggestions ( query : String ) -> Resource {
resource ( baseURL : account . url , path : basePathAppending ( " search/suggestions " ) )
. withParam ( " q " , query . lowercased ( ) )
}
func comments ( _ id : Video . ID , page : String ? ) -> Resource ? {
let resource = resource ( baseURL : account . url , path : basePathAppending ( " comments/ \( id ) " ) )
guard let page else { return resource }
return resource . withParam ( " continuation " , page )
}
static func proxiedAsset ( instance : Instance , asset : AVURLAsset ) -> AVURLAsset ? {
guard let instanceURLComponents = URLComponents ( string : instance . apiURLString ) ,
var urlComponents = URLComponents ( url : asset . url , resolvingAgainstBaseURL : false ) else { return nil }
urlComponents . scheme = instanceURLComponents . scheme
urlComponents . host = instanceURLComponents . host
guard let url = urlComponents . url else {
return nil
}
return AVURLAsset ( url : url )
}
func extractVideo ( from json : JSON ) -> Video {
let id = json [ " uuid " ] . stringValue
let url = json [ " url " ] . url
let dateFormatter = ISO8601DateFormatter ( )
dateFormatter . formatOptions = [ . withInternetDateTime , . withFractionalSeconds ]
let publishedAt = dateFormatter . date ( from : json [ " publishedAt " ] . stringValue )
return Video (
instanceID : account . instanceID ,
app : . peerTube ,
instanceURL : account . instance . apiURL ,
id : id ,
videoID : id ,
videoURL : url ,
title : json [ " name " ] . stringValue ,
author : json [ " channel " ] . dictionaryValue [ " name " ] ? . stringValue ? ? " " ,
length : json [ " duration " ] . doubleValue ,
views : json [ " views " ] . intValue ,
description : json [ " description " ] . stringValue ,
channel : extractChannel ( from : json [ " channel " ] ) ,
thumbnails : extractThumbnails ( from : json ) ,
live : json [ " isLive " ] . boolValue ,
publishedAt : publishedAt ,
likes : json [ " likes " ] . int ,
dislikes : json [ " dislikes " ] . int ,
streams : extractStreams ( from : json )
// r e l a t e d : e x t r a c t R e l a t e d ( f r o m : j s o n ) ,
// c h a p t e r s : e x t r a c t C h a p t e r s ( f r o m : d e s c r i p t i o n ) ,
// c a p t i o n s : e x t r a c t C a p t i o n s ( f r o m : j s o n )
)
}
func extractChannel ( from json : JSON ) -> Channel {
Channel (
id : json [ " id " ] . stringValue ,
name : json [ " name " ] . stringValue
)
}
func extractChannelPlaylist ( from json : JSON ) -> ChannelPlaylist {
let details = json . dictionaryValue
return ChannelPlaylist (
id : details [ " playlistId " ] ? . string ? ? details [ " mixId " ] ? . string ? ? UUID ( ) . uuidString ,
title : details [ " title " ] ? . stringValue ? ? " " ,
thumbnailURL : details [ " playlistThumbnail " ] ? . url ,
channel : extractChannel ( from : json ) ,
videos : details [ " videos " ] ? . arrayValue . compactMap ( extractVideo ) ? ? [ ] ,
videosCount : details [ " videoCount " ] ? . int
)
}
private func extractThumbnails ( from details : JSON ) -> [ Thumbnail ] {
if let thumbnailPath = details [ " thumbnailPath " ] . string {
return [ Thumbnail ( url : URL ( string : thumbnailPath , relativeTo : account . url ) ! , quality : . medium ) ]
}
return [ ]
}
private func extractStreams ( from json : JSON ) -> [ Stream ] {
let hls = extractHLSStreams ( from : json )
if json [ " isLive " ] . boolValue {
return hls
}
return extractFormatStreams ( from : json ) +
extractAdaptiveFormats ( from : json ) +
hls
}
private func extractFormatStreams ( from json : JSON ) -> [ Stream ] {
var streams = [ Stream ] ( )
if let fileURL = json . dictionaryValue [ " streamingPlaylists " ] ? . arrayValue . first ?
. dictionaryValue [ " files " ] ? . arrayValue . first ?
. dictionaryValue [ " fileUrl " ] ? . url
{
streams . append ( SingleAssetStream ( instance : account . instance , avAsset : AVURLAsset ( url : fileURL ) , resolution : . hd720p30 , kind : . stream ) )
}
return streams
}
private func extractAdaptiveFormats ( from json : JSON ) -> [ Stream ] {
json . dictionaryValue [ " files " ] ? . arrayValue . compactMap { file in
if let resolution = file . dictionaryValue [ " resolution " ] ? . dictionaryValue [ " label " ] ? . stringValue , let url = file . dictionaryValue [ " fileUrl " ] ? . url {
return SingleAssetStream ( instance : account . instance , avAsset : AVURLAsset ( url : url ) , resolution : Stream . Resolution . from ( resolution : resolution ) , kind : . adaptive , videoFormat : " mp4 " )
}
return nil
} ? ? [ ]
}
private func extractHLSStreams ( from content : JSON ) -> [ Stream ] {
if let hlsURL = content . dictionaryValue [ " streamingPlaylists " ] ? . arrayValue . first ? . dictionaryValue [ " playlistUrl " ] ? . url {
return [ Stream ( instance : account . instance , hlsURL : hlsURL ) ]
}
return [ ]
}
private func extractRelated ( from content : JSON ) -> [ Video ] {
content
. dictionaryValue [ " recommendedVideos " ] ?
. arrayValue
. compactMap ( extractVideo ( from : ) ) ? ? [ ]
}
private func extractPlaylist ( from content : JSON ) -> Playlist {
let id = content [ " playlistId " ] . stringValue
return Playlist (
id : id ,
title : content [ " title " ] . stringValue ,
visibility : content [ " isListed " ] . boolValue ? . public : . private ,
editable : id . starts ( with : " IV " ) ,
updated : content [ " updated " ] . doubleValue ,
videos : content [ " videos " ] . arrayValue . map { extractVideo ( from : $0 ) }
)
}
private func extractComment ( from content : JSON ) -> Comment ? {
let details = content . dictionaryValue
let author = details [ " author " ] ? . string ? ? " "
let channelId = details [ " authorId " ] ? . string ? ? UUID ( ) . uuidString
let authorAvatarURL = details [ " authorThumbnails " ] ? . arrayValue . last ? . dictionaryValue [ " url " ] ? . string ? ? " "
return Comment (
id : UUID ( ) . uuidString ,
author : author ,
authorAvatarURL : authorAvatarURL ,
time : details [ " publishedText " ] ? . string ? ? " " ,
pinned : false ,
hearted : false ,
likeCount : details [ " likeCount " ] ? . int ? ? 0 ,
text : details [ " content " ] ? . string ? ? " " ,
repliesPage : details [ " replies " ] ? . dictionaryValue [ " continuation " ] ? . string ,
channel : Channel ( id : channelId , name : author )
)
}
private func extractCaptions ( from content : JSON ) -> [ Captions ] {
content [ " captions " ] . arrayValue . compactMap { _ in
nil
// l e t b a s e U R L = a c c o u n t . u r l
// g u a r d l e t u r l = U R L ( s t r i n g : b a s e U R L + d e t a i l s [ " u r l " ] . s t r i n g V a l u e ) e l s e { r e t u r n n i l }
//
// r e t u r n C a p t i o n s (
// l a b e l : d e t a i l s [ " l a b e l " ] . s t r i n g V a l u e ,
// c o d e : d e t a i l s [ " l a n g u a g e _ c o d e " ] . s t r i n g V a l u e ,
// u r l : u r l
// )
}
}
}