Extended Piped support

This commit is contained in:
Arkadiusz Fal
2021-10-21 00:21:50 +02:00
parent 2d075e7b3a
commit c3326a56af
46 changed files with 706 additions and 458 deletions

79
Model/Account.swift Normal file
View File

@@ -0,0 +1,79 @@
import Defaults
import Foundation
struct Account: Defaults.Serializable, Hashable, Identifiable {
struct AccountsBridge: Defaults.Bridge {
typealias Value = Account
typealias Serializable = [String: String]
func serialize(_ value: Value?) -> Serializable? {
guard let value = value else {
return nil
}
return [
"id": value.id,
"instanceID": value.instanceID,
"name": value.name ?? "",
"url": value.url,
"sid": value.sid
]
}
func deserialize(_ object: Serializable?) -> Value? {
guard
let object = object,
let id = object["id"],
let instanceID = object["instanceID"],
let url = object["url"],
let sid = object["sid"]
else {
return nil
}
let name = object["name"] ?? ""
return Account(id: id, instanceID: instanceID, name: name, url: url, sid: sid)
}
}
static var bridge = AccountsBridge()
let id: String
let instanceID: String
var name: String?
let url: String
let sid: String
let anonymous: Bool
init(id: String? = nil, instanceID: String? = nil, name: String? = nil, url: String? = nil, sid: String? = nil, anonymous: Bool = false) {
self.anonymous = anonymous
self.id = id ?? (anonymous ? "anonymous-\(instanceID!)" : UUID().uuidString)
self.instanceID = instanceID ?? UUID().uuidString
self.name = name
self.url = url ?? ""
self.sid = sid ?? ""
}
var instance: Instance {
Defaults[.instances].first { $0.id == instanceID }!
}
var anonymizedSID: String {
guard sid.count > 3 else {
return ""
}
let index = sid.index(sid.startIndex, offsetBy: 4)
return String(sid[..<index])
}
var description: String {
(name != nil && name!.isEmpty) ? "Unnamed (\(anonymizedSID))" : name!
}
func hash(into hasher: inout Hasher) {
hasher.combine(sid)
}
}

View File

@@ -3,9 +3,9 @@ import Siesta
import SwiftUI
final class AccountValidator: Service {
let app: Binding<Instance.App>
let app: Binding<VideosApp>
let url: String
let account: Instance.Account?
let account: Account?
var formObjectID: Binding<String>
var isValid: Binding<Bool>
@@ -14,9 +14,9 @@ final class AccountValidator: Service {
var error: Binding<String?>?
init(
app: Binding<Instance.App>,
app: Binding<VideosApp>,
url: String,
account: Instance.Account? = nil,
account: Account? = nil,
id: Binding<String>,
isValid: Binding<Bool>,
isValidated: Binding<Bool>,

View File

@@ -3,18 +3,18 @@ import Defaults
import Foundation
final class AccountsModel: ObservableObject {
@Published private(set) var current: Instance.Account!
@Published private(set) var current: Account!
@Published private(set) var invidious = InvidiousAPI()
@Published private(set) var piped = PipedAPI()
@Published private var invidious = InvidiousAPI()
@Published private var piped = PipedAPI()
private var cancellables = [AnyCancellable]()
var all: [Instance.Account] {
var all: [Account] {
Defaults[.accounts]
}
var lastUsed: Instance.Account? {
var lastUsed: Account? {
guard let id = Defaults[.lastAccountID] else {
return nil
}
@@ -22,6 +22,14 @@ final class AccountsModel: ObservableObject {
return AccountsModel.find(id)
}
var app: VideosApp {
current?.instance.app ?? .invidious
}
var api: VideosAPI {
app == .piped ? piped : invidious
}
var isEmpty: Bool {
current.isNil
}
@@ -40,7 +48,7 @@ final class AccountsModel: ObservableObject {
)
}
func setCurrent(_ account: Instance.Account! = nil) {
func setCurrent(_ account: Account! = nil) {
guard account != current else {
return
}
@@ -62,18 +70,18 @@ final class AccountsModel: ObservableObject {
Defaults[.lastInstanceID] = account.instanceID
}
static func find(_ id: Instance.Account.ID) -> Instance.Account? {
static func find(_ id: Account.ID) -> Account? {
Defaults[.accounts].first { $0.id == id }
}
static func add(instance: Instance, name: String, sid: String) -> Instance.Account {
let account = Instance.Account(instanceID: instance.id, name: name, url: instance.url, sid: sid)
static func add(instance: Instance, name: String, sid: String) -> Account {
let account = Account(instanceID: instance.id, name: name, url: instance.url, sid: sid)
Defaults[.accounts].append(account)
return account
}
static func remove(_ account: Instance.Account) {
static func remove(_ account: Account) {
if let accountIndex = Defaults[.accounts].firstIndex(where: { $0.id == account.id }) {
Defaults[.accounts].remove(at: accountIndex)
}

View File

@@ -2,125 +2,6 @@ import Defaults
import Foundation
struct Instance: Defaults.Serializable, Hashable, Identifiable {
enum App: String, CaseIterable {
case invidious, piped
var name: String {
rawValue.capitalized
}
}
struct Account: Defaults.Serializable, Hashable, Identifiable {
static var bridge = AccountsBridge()
let id: String
let instanceID: String
var name: String?
let url: String
let sid: String
let anonymous: Bool
init(id: String? = nil, instanceID: String? = nil, name: String? = nil, url: String? = nil, sid: String? = nil, anonymous: Bool = false) {
self.anonymous = anonymous
self.id = id ?? (anonymous ? "anonymous-\(instanceID!)" : UUID().uuidString)
self.instanceID = instanceID ?? UUID().uuidString
self.name = name
self.url = url ?? ""
self.sid = sid ?? ""
}
var instance: Instance {
Defaults[.instances].first { $0.id == instanceID }!
}
var anonymizedSID: String {
guard sid.count > 3 else {
return ""
}
let index = sid.index(sid.startIndex, offsetBy: 4)
return String(sid[..<index])
}
var description: String {
(name != nil && name!.isEmpty) ? "Unnamed (\(anonymizedSID))" : name!
}
func hash(into hasher: inout Hasher) {
hasher.combine(sid)
}
struct AccountsBridge: Defaults.Bridge {
typealias Value = Account
typealias Serializable = [String: String]
func serialize(_ value: Value?) -> Serializable? {
guard let value = value else {
return nil
}
return [
"id": value.id,
"instanceID": value.instanceID,
"name": value.name ?? "",
"url": value.url,
"sid": value.sid
]
}
func deserialize(_ object: Serializable?) -> Value? {
guard
let object = object,
let id = object["id"],
let instanceID = object["instanceID"],
let url = object["url"],
let sid = object["sid"]
else {
return nil
}
let name = object["name"] ?? ""
return Account(id: id, instanceID: instanceID, name: name, url: url, sid: sid)
}
}
}
static var bridge = InstancesBridge()
let app: App
let id: String
let name: String
let url: String
init(app: App, id: String? = nil, name: String, url: String) {
self.app = app
self.id = id ?? UUID().uuidString
self.name = name
self.url = url
}
var description: String {
"\(app.name) - \(shortDescription)"
}
var longDescription: String {
name.isEmpty ? "\(app.name) - \(url)" : "\(app.name) - \(name) (\(url))"
}
var shortDescription: String {
name.isEmpty ? url : name
}
var supportsAccounts: Bool {
app == .invidious
}
var anonymousAccount: Account {
Account(instanceID: id, name: "Anonymous", url: url, sid: "", anonymous: true)
}
struct InstancesBridge: Defaults.Bridge {
typealias Value = Instance
typealias Serializable = [String: String]
@@ -141,7 +22,7 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
func deserialize(_ object: Serializable?) -> Value? {
guard
let object = object,
let app = App(rawValue: object["app"] ?? ""),
let app = VideosApp(rawValue: object["app"] ?? ""),
let id = object["id"],
let url = object["url"]
else {
@@ -154,6 +35,45 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
}
}
static var bridge = InstancesBridge()
let app: VideosApp
let id: String
let name: String
let url: String
init(app: VideosApp, id: String? = nil, name: String, url: String) {
self.app = app
self.id = id ?? UUID().uuidString
self.name = name
self.url = url
}
var anonymous: VideosAPI {
switch app {
case .invidious:
return InvidiousAPI(account: anonymousAccount)
case .piped:
return PipedAPI(account: anonymousAccount)
}
}
var description: String {
"\(app.name) - \(shortDescription)"
}
var longDescription: String {
name.isEmpty ? "\(app.name) - \(url)" : "\(app.name) - \(name) (\(url))"
}
var shortDescription: String {
name.isEmpty ? url : name
}
var anonymousAccount: Account {
Account(instanceID: id, name: "Anonymous", url: url, anonymous: true)
}
func hash(into hasher: inout Hasher) {
hasher.combine(url)
}

View File

@@ -22,11 +22,11 @@ final class InstancesModel: ObservableObject {
return Defaults[.instances].first { $0.id == id }
}
static func accounts(_ id: Instance.ID?) -> [Instance.Account] {
static func accounts(_ id: Instance.ID?) -> [Account] {
Defaults[.accounts].filter { $0.instanceID == id }
}
static func add(app: Instance.App, name: String, url: String) -> Instance {
static func add(app: VideosApp, name: String, url: String) -> Instance {
let instance = Instance(app: app, id: UUID().uuidString, name: name, url: url)
Defaults[.instances].append(instance)
@@ -41,7 +41,7 @@ final class InstancesModel: ObservableObject {
}
}
static func setLastAccount(_ account: Instance.Account?) {
static func setLastAccount(_ account: Account?) {
Defaults[.lastAccountID] = account?.id
}
}

View File

@@ -3,15 +3,15 @@ import Foundation
import Siesta
import SwiftyJSON
final class InvidiousAPI: Service, ObservableObject {
final class InvidiousAPI: Service, ObservableObject, VideosAPI {
static let basePath = "/api/v1"
@Published var account: Instance.Account!
@Published var account: Account!
@Published var validInstance = true
@Published var signedIn = false
init(account: Instance.Account? = nil) {
init(account: Account? = nil) {
super.init()
guard !account.isNil else {
@@ -22,7 +22,7 @@ final class InvidiousAPI: Service, ObservableObject {
setAccount(account!)
}
func setAccount(_ account: Instance.Account) {
func setAccount(_ account: Account) {
self.account = account
validInstance = false
@@ -42,7 +42,7 @@ final class InvidiousAPI: Service, ObservableObject {
return
}
home
home?
.load()
.onSuccess { _ in
self.validInstance = true
@@ -57,7 +57,7 @@ final class InvidiousAPI: Service, ObservableObject {
return
}
feed
feed?
.load()
.onSuccess { _ in
self.signedIn = true
@@ -149,29 +149,29 @@ final class InvidiousAPI: Service, ObservableObject {
"SID=\(account.sid)"
}
var popular: Resource {
var popular: Resource? {
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/popular")
}
func trending(category: TrendingCategory, country: Country) -> Resource {
func trending(country: Country, category: TrendingCategory?) -> Resource {
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/trending")
.withParam("type", category.name)
.withParam("type", category!.name)
.withParam("region", country.rawValue)
}
var home: Resource {
var home: Resource? {
resource(baseURL: account.url, path: "/feed/subscriptions")
}
var feed: Resource {
var feed: Resource? {
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/auth/feed")
}
var subscriptions: Resource {
var subscriptions: Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
}
func channelSubscription(_ id: String) -> Resource {
func channelSubscription(_ id: String) -> Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions")).child(id)
}
@@ -187,20 +187,20 @@ final class InvidiousAPI: Service, ObservableObject {
resource(baseURL: account.url, path: basePathAppending("videos/\(id)"))
}
var playlists: Resource {
var playlists: Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
}
func playlist(_ id: String) -> Resource {
func playlist(_ id: String) -> Resource? {
resource(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
}
func playlistVideos(_ id: String) -> Resource {
playlist(id).child("videos")
func playlistVideos(_ id: String) -> Resource? {
playlist(id)?.child("videos")
}
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource {
playlist(playlistID).child("videos").child(videoID)
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource? {
playlist(playlistID)?.child("videos").child(videoID)
}
func search(_ query: SearchQuery) -> Resource {

View File

@@ -3,14 +3,14 @@ import Foundation
import Siesta
import SwiftyJSON
final class PipedAPI: Service, ObservableObject {
@Published var account: Instance.Account!
final class PipedAPI: Service, ObservableObject, VideosAPI {
@Published var account: Account!
var anonymousAccount: Instance.Account {
var anonymousAccount: Account {
.init(instanceID: account.instance.id, name: "Anonymous", url: account.instance.url)
}
init(account: Instance.Account? = nil) {
init(account: Account? = nil) {
super.init()
guard account != nil else {
@@ -20,7 +20,7 @@ final class PipedAPI: Service, ObservableObject {
setAccount(account!)
}
func setAccount(_ account: Instance.Account) {
func setAccount(_ account: Account) {
self.account = account
configure()
@@ -31,15 +31,128 @@ final class PipedAPI: Service, ObservableObject {
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
}
configureTransformer(pathPattern("streams/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Stream] in
self.extractStreams(content)
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
self.extractChannel(content.json)
}
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
self.extractVideo(content.json)
}
configureTransformer(pathPattern("trending")) { (content: Entity<JSON>) -> [Video] in
self.extractVideos(content.json)
}
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> [Video] in
self.extractVideos(content.json.dictionaryValue["items"]!)
}
configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in
content.json.arrayValue.map(String.init)
}
}
private func extractStreams(_ content: Entity<JSON>) -> [Stream] {
private func extractChannel(_ content: JSON) -> Channel? {
Channel(
id: content.dictionaryValue["id"]!.stringValue,
name: content.dictionaryValue["name"]!.stringValue,
subscriptionsCount: content.dictionaryValue["subscriberCount"]!.intValue,
videos: extractVideos(content.dictionaryValue["relatedStreams"]!)
)
}
private func extractVideo(_ 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 = buildThumbnailURL(content, quality: $0) {
return Thumbnail(url: url, quality: $0)
}
return nil
}
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
return Video(
videoID: extractID(content),
title: details["title"]!.stringValue,
author: author,
length: details["duration"]!.doubleValue,
published: details["uploadedDate"]?.stringValue ?? details["uploadDate"]!.stringValue,
views: details["views"]!.intValue,
description: extractDescription(content),
channel: Channel(id: channelId, name: author),
thumbnails: thumbnails,
likes: details["likes"]?.int,
dislikes: details["dislikes"]?.int,
streams: extractStreams(content)
)
}
private func extractID(_ content: JSON) -> Video.ID {
content.dictionaryValue["url"]?.stringValue.components(separatedBy: "?v=").last ??
extractThumbnailURL(content)!.relativeString.components(separatedBy: "/")[4]
}
private func extractThumbnailURL(_ content: JSON) -> URL? {
content.dictionaryValue["thumbnail"]?.url! ?? content.dictionaryValue["thumbnailUrl"]!.url!
}
private func buildThumbnailURL(_ content: JSON, quality: Thumbnail.Quality) -> URL? {
let thumbnailURL = extractThumbnailURL(content)
guard !thumbnailURL.isNil else {
return nil
}
return URL(string: thumbnailURL!
.absoluteString
.replacingOccurrences(of: "_webp", with: "")
.replacingOccurrences(of: ".webp", with: ".jpg")
.replacingOccurrences(of: "hqdefault", with: quality.filename)
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
)!
}
private func extractDescription(_ 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 func extractVideos(_ content: JSON) -> [Video] {
content.arrayValue.compactMap(extractVideo(_:))
}
private func extractStreams(_ content: JSON) -> [Stream] {
var streams = [Stream]()
if let hlsURL = content.json.dictionaryValue["hls"]?.url {
if let hlsURL = content.dictionaryValue["hls"]?.url {
streams.append(Stream(hlsURL: hlsURL))
}
@@ -70,9 +183,8 @@ final class PipedAPI: Service, ObservableObject {
return streams
}
private func compatibleAudioStreams(_ content: Entity<JSON>) -> [JSON] {
private func compatibleAudioStreams(_ content: JSON) -> [JSON] {
content
.json
.dictionaryValue["audioStreams"]?
.arrayValue
.filter { $0.dictionaryValue["format"]?.stringValue == "M4A" }
@@ -81,19 +193,51 @@ final class PipedAPI: Service, ObservableObject {
} ?? []
}
private func compatibleVideoStream(_ content: Entity<JSON>) -> [JSON] {
private func compatibleVideoStream(_ content: JSON) -> [JSON] {
content
.json
.dictionaryValue["videoStreams"]?
.arrayValue
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
}
func channel(_ id: String) -> Resource {
resource(baseURL: account.url, path: "channel/\(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)"
}
func streams(id: Video.ID) -> Resource {
resource(baseURL: account.instance.url, path: "streams/\(id)")
}
}

View File

@@ -226,8 +226,8 @@ final class PlayerModel: ObservableObject {
#if !os(macOS)
var externalMetadata = [
makeMetadataItem(.commonIdentifierTitle, value: video.title),
makeMetadataItem(.quickTimeMetadataGenre, value: video.genre),
makeMetadataItem(.commonIdentifierDescription, value: video.description)
makeMetadataItem(.quickTimeMetadataGenre, value: video.genre ?? ""),
makeMetadataItem(.commonIdentifierDescription, value: video.description ?? "")
]
if let thumbnailData = try? Data(contentsOf: video.thumbnailURL(quality: .medium)!),
let image = UIImage(data: thumbnailData),

View File

@@ -104,22 +104,12 @@ extension PlayerModel {
return item
}
func videoResource(_ id: Video.ID) -> Resource {
accounts.invidious.video(id)
}
private func loadDetails(_ video: Video?, onSuccess: @escaping (Video) -> Void) {
guard video != nil else {
return
}
if !video!.streams.isEmpty {
logger.critical("not loading video details again")
onSuccess(video!)
return
}
videoResource(video!.videoID).load().onSuccess { response in
accounts.api.video(video!.videoID).load().onSuccess { response in
if let video: Video = response.typedContent() {
onSuccess(video)
}

View File

@@ -23,57 +23,28 @@ extension PlayerModel {
var instancesWithLoadedStreams = [Instance]()
instances.all.forEach { instance in
switch instance.app {
case .piped:
fetchPipedStreams(instance, video: video) { _ in
self.completeIfAllInstancesLoaded(
instance: instance,
streams: self.availableStreams,
instancesWithLoadedStreams: &instancesWithLoadedStreams,
completionHandler: completionHandler
)
}
case .invidious:
fetchInvidiousStreams(instance, video: video) { _ in
self.completeIfAllInstancesLoaded(
instance: instance,
streams: self.availableStreams,
instancesWithLoadedStreams: &instancesWithLoadedStreams,
completionHandler: completionHandler
)
}
fetchStreams(instance.anonymous.video(video.videoID), instance: instance, video: video) { _ in
self.completeIfAllInstancesLoaded(
instance: instance,
streams: self.availableStreams,
instancesWithLoadedStreams: &instancesWithLoadedStreams,
completionHandler: completionHandler
)
}
}
}
private func fetchInvidiousStreams(
_ instance: Instance,
private func fetchStreams(
_ resource: Resource,
instance: Instance,
video: Video,
onCompletion: @escaping (ResponseInfo) -> Void = { _ in }
) {
invidious(instance)
.video(video.videoID)
resource
.load()
.onSuccess { response in
if let video: Video = response.typedContent() {
self.availableStreams += self.streamsWithAssetsFromInstance(instance: instance, streams: video.streams)
}
}
.onCompletion(onCompletion)
}
private func fetchPipedStreams(
_ instance: Instance,
video: Video,
onCompletion: @escaping (ResponseInfo) -> Void = { _ in }
) {
piped(instance)
.streams(id: video.videoID)
.load()
.onSuccess { response in
if let pipedStreams: [Stream] = response.typedContent() {
self.availableStreams += self.streamsWithInstance(instance: instance, streams: pipedStreams)
self.availableStreams += self.streamsWithInstance(instance: instance, streams: video.streams)
}
}
.onCompletion(onCompletion)

View File

@@ -9,10 +9,6 @@ final class PlaylistsModel: ObservableObject {
var accounts = AccountsModel()
var api: InvidiousAPI {
accounts.invidious
}
init(_ playlists: [Playlist] = [Playlist]()) {
self.playlists = playlists
}
@@ -48,19 +44,19 @@ final class PlaylistsModel: ObservableObject {
}
func addVideoToCurrentPlaylist(videoID: Video.ID, onSuccess: @escaping () -> Void = {}) {
let resource = api.playlistVideos(currentPlaylist!.id)
let resource = accounts.api.playlistVideos(currentPlaylist!.id)
let body = ["videoId": videoID]
resource.request(.post, json: body).onSuccess { _ in
resource?.request(.post, json: body).onSuccess { _ in
self.load(force: true)
onSuccess()
}
}
func removeVideoFromPlaylist(videoIndexID: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
let resource = api.playlistVideo(playlistID, videoIndexID)
let resource = accounts.api.playlistVideo(playlistID, videoIndexID)
resource.request(.delete).onSuccess { _ in
resource?.request(.delete).onSuccess { _ in
self.load(force: true)
onSuccess()
}
@@ -71,7 +67,7 @@ final class PlaylistsModel: ObservableObject {
}
private var resource: Resource {
api.playlists
accounts.api.playlists!
}
private var selectedPlaylist: Playlist? {

View File

@@ -17,14 +17,10 @@ final class SearchModel: ObservableObject {
resource?.isLoading ?? false
}
var api: InvidiousAPI {
accounts.invidious
}
func changeQuery(_ changeHandler: @escaping (SearchQuery) -> Void = { _ in }) {
changeHandler(query)
let newResource = api.search(query)
let newResource = accounts.api.search(query)
guard newResource != previousResource else {
return
}
@@ -43,7 +39,7 @@ final class SearchModel: ObservableObject {
func resetQuery(_ query: SearchQuery = SearchQuery()) {
self.query = query
let newResource = api.search(query)
let newResource = accounts.api.search(query)
guard newResource != previousResource else {
return
}
@@ -87,7 +83,7 @@ final class SearchModel: ObservableObject {
suggestionsDebounceTimer?.invalidate()
suggestionsDebounceTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { _ in
let resource = self.api.searchSuggestions(query: query)
let resource = self.accounts.api.searchSuggestions(query: query)
resource.addObserver(self.querySuggestions)
resource.loadIfNeeded()

View File

@@ -6,12 +6,8 @@ final class SubscriptionsModel: ObservableObject {
@Published var channels = [Channel]()
var accounts: AccountsModel
var api: InvidiousAPI {
accounts.invidious
}
var resource: Resource {
api.subscriptions
var resource: Resource? {
accounts.api.subscriptions
}
init(accounts: AccountsModel? = nil) {
@@ -35,7 +31,7 @@ final class SubscriptionsModel: ObservableObject {
}
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
let request = force ? resource.load() : resource.loadIfNeeded()
let request = force ? resource?.load() : resource?.loadIfNeeded()
request?
.onSuccess { resource in
@@ -50,7 +46,7 @@ final class SubscriptionsModel: ObservableObject {
}
fileprivate func performRequest(_ channelID: String, method: RequestMethod, onSuccess: @escaping () -> Void = {}) {
api.channelSubscription(channelID).request(method).onCompletion { _ in
accounts.api.channelSubscription(channelID)?.request(method).onCompletion { _ in
self.load(force: true, onSuccess: onSuccess)
}
}

View File

@@ -4,6 +4,29 @@ import SwiftyJSON
struct Thumbnail {
enum Quality: String, CaseIterable {
case maxres, maxresdefault, sddefault, high, medium, `default`, start, middle, end
var filename: String {
switch self {
case .maxres:
return "maxres"
case .maxresdefault:
return "maxresdefault"
case .sddefault:
return "sddefault"
case .high:
return "hqdefault"
case .medium:
return "mqdefault"
case .default:
return "default"
case .start:
return "1"
case .middle:
return "2"
case .end:
return "3"
}
}
}
var url: URL

View File

@@ -12,8 +12,8 @@ struct Video: Identifiable, Equatable, Hashable {
var length: TimeInterval
var published: String
var views: Int
var description: String
var genre: String
var description: String?
var genre: String?
// index used when in the Playlist
let indexID: String?
@@ -38,8 +38,8 @@ struct Video: Identifiable, Equatable, Hashable {
length: TimeInterval,
published: String,
views: Int,
description: String,
genre: String,
description: String? = nil,
genre: String? = nil,
channel: Channel,
thumbnails: [Thumbnail] = [],
indexID: String? = nil,
@@ -48,7 +48,8 @@ struct Video: Identifiable, Equatable, Hashable {
publishedAt: Date? = nil,
likes: Int? = nil,
dislikes: Int? = nil,
keywords: [String] = []
keywords: [String] = [],
streams: [Stream] = []
) {
self.id = id ?? UUID().uuidString
self.videoID = videoID
@@ -68,6 +69,7 @@ struct Video: Identifiable, Equatable, Hashable {
self.likes = likes
self.dislikes = dislikes
self.keywords = keywords
self.streams = streams
}
init(_ json: JSON) {
@@ -169,7 +171,11 @@ struct Video: Identifiable, Equatable, Hashable {
}
func thumbnailURL(quality: Thumbnail.Quality) -> URL? {
thumbnails.first { $0.quality == quality }?.url
if let url = thumbnails.first(where: { $0.quality == quality })?.url.absoluteString {
return URL(string: url.replacingOccurrences(of: "hqdefault", with: quality.filename))
}
return nil
}
private static func extractThumbnails(from details: JSON) -> [Thumbnail] {

24
Model/VideosAPI.swift Normal file
View File

@@ -0,0 +1,24 @@
import Foundation
import Siesta
protocol VideosAPI {
var signedIn: Bool { get }
func channel(_ id: String) -> Resource
func trending(country: Country, category: TrendingCategory?) -> Resource
func search(_ query: SearchQuery) -> Resource
func searchSuggestions(query: String) -> Resource
func video(_ id: Video.ID) -> Resource
var subscriptions: Resource? { get }
var feed: Resource? { get }
var home: Resource? { get }
var popular: Resource? { get }
var playlists: Resource? { get }
func channelSubscription(_ id: String) -> Resource?
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
func playlistVideos(_ id: String) -> Resource?
}

33
Model/VideosApp.swift Normal file
View File

@@ -0,0 +1,33 @@
import Foundation
enum VideosApp: String, CaseIterable {
case invidious, piped
var name: String {
rawValue.capitalized
}
var supportsAccounts: Bool {
self == .invidious
}
var supportsPopular: Bool {
self == .invidious
}
var supportsSearchFilters: Bool {
self == .invidious
}
var supportsSubscriptions: Bool {
supportsAccounts
}
var supportsTrendingCategories: Bool {
self == .invidious
}
var supportsUserPlaylists: Bool {
self == .invidious
}
}