2022-12-12 09:21:46 +00:00
|
|
|
import Cache
|
2022-12-12 23:39:50 +00:00
|
|
|
import CoreData
|
2023-02-25 15:42:18 +00:00
|
|
|
import Defaults
|
2022-12-10 02:01:59 +00:00
|
|
|
import Foundation
|
|
|
|
import Siesta
|
2022-12-12 09:21:46 +00:00
|
|
|
import SwiftyJSON
|
2022-12-10 02:01:59 +00:00
|
|
|
|
2022-12-12 09:21:46 +00:00
|
|
|
final class FeedModel: ObservableObject, CacheModel {
|
2022-12-11 11:38:57 +00:00
|
|
|
static let shared = FeedModel()
|
2022-12-10 02:01:59 +00:00
|
|
|
|
|
|
|
@Published var isLoading = false
|
|
|
|
@Published var videos = [Video]()
|
|
|
|
@Published private var page = 1
|
|
|
|
|
2022-12-16 21:26:14 +00:00
|
|
|
private var feedCount = UnwatchedFeedCountModel.shared
|
2022-12-12 23:39:50 +00:00
|
|
|
private var cacheModel = FeedCacheModel.shared
|
2022-12-10 02:01:59 +00:00
|
|
|
private var accounts = AccountsModel.shared
|
|
|
|
|
2022-12-12 09:21:46 +00:00
|
|
|
var storage: Storage<String, JSON>?
|
|
|
|
|
2022-12-16 08:35:10 +00:00
|
|
|
@Published var error: RequestError?
|
|
|
|
|
2022-12-12 23:39:50 +00:00
|
|
|
private var backgroundContext = PersistenceController.shared.container.newBackgroundContext()
|
|
|
|
|
2022-12-10 02:01:59 +00:00
|
|
|
var feed: Resource? {
|
|
|
|
accounts.api.feed(page)
|
|
|
|
}
|
|
|
|
|
|
|
|
func loadResources(force: Bool = false, onCompletion: @escaping () -> Void = {}) {
|
|
|
|
DispatchQueue.global(qos: .background).async { [weak self] in
|
|
|
|
guard let self else { return }
|
|
|
|
|
2022-12-11 15:15:42 +00:00
|
|
|
if force || self.videos.isEmpty {
|
2022-12-10 02:01:59 +00:00
|
|
|
self.loadCachedFeed()
|
|
|
|
}
|
|
|
|
|
|
|
|
if self.accounts.app == .invidious {
|
|
|
|
// Invidious for some reason won't refresh feed until homepage is loaded
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
|
|
guard let self, let home = self.accounts.api.home else { return }
|
|
|
|
self.request(home, force: force)?
|
|
|
|
.onCompletion { _ in
|
|
|
|
self.loadFeed(force: force, onCompletion: onCompletion)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
self.loadFeed(force: force, onCompletion: onCompletion)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func loadFeed(force: Bool = false, paginating: Bool = false, onCompletion: @escaping () -> Void = {}) {
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
|
|
guard let self,
|
|
|
|
!self.isLoading,
|
|
|
|
let account = self.accounts.current
|
|
|
|
else {
|
|
|
|
self?.isLoading = false
|
|
|
|
onCompletion()
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if paginating {
|
|
|
|
self.page += 1
|
|
|
|
} else {
|
|
|
|
self.page = 1
|
|
|
|
}
|
|
|
|
|
|
|
|
let feedBeforeLoad = self.feed
|
|
|
|
var request: Request?
|
|
|
|
if let feedBeforeLoad {
|
|
|
|
request = self.request(feedBeforeLoad, force: force)
|
|
|
|
}
|
|
|
|
if request != nil {
|
|
|
|
self.isLoading = true
|
|
|
|
}
|
|
|
|
|
|
|
|
request?
|
|
|
|
.onCompletion { _ in
|
|
|
|
self.isLoading = false
|
|
|
|
onCompletion()
|
|
|
|
}
|
|
|
|
.onSuccess { response in
|
2022-12-16 08:35:10 +00:00
|
|
|
self.error = nil
|
2022-12-10 02:01:59 +00:00
|
|
|
if let videos: [Video] = response.typedContent() {
|
|
|
|
if paginating {
|
|
|
|
self.videos.append(contentsOf: videos)
|
|
|
|
} else {
|
|
|
|
self.videos = videos
|
2022-12-12 23:39:50 +00:00
|
|
|
self.cacheModel.storeFeed(account: account, videos: self.videos)
|
|
|
|
self.calculateUnwatchedFeed()
|
2022-12-10 02:01:59 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2022-12-16 08:35:10 +00:00
|
|
|
.onFailure { self.error = $0 }
|
2022-12-10 02:01:59 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func reset() {
|
|
|
|
videos.removeAll()
|
|
|
|
page = 1
|
|
|
|
}
|
|
|
|
|
|
|
|
func loadNextPage() {
|
|
|
|
guard accounts.app.paginatesSubscriptions, !isLoading else { return }
|
|
|
|
|
|
|
|
loadFeed(force: true, paginating: true)
|
|
|
|
}
|
|
|
|
|
2022-12-14 16:23:04 +00:00
|
|
|
func onAccountChange() {
|
|
|
|
reset()
|
2022-12-20 22:51:04 +00:00
|
|
|
error = nil
|
2022-12-14 16:23:04 +00:00
|
|
|
loadResources(force: true)
|
|
|
|
calculateUnwatchedFeed()
|
|
|
|
}
|
|
|
|
|
2022-12-12 23:39:50 +00:00
|
|
|
func calculateUnwatchedFeed() {
|
2022-12-13 19:15:54 +00:00
|
|
|
guard let account = accounts.current, accounts.signedIn else { return }
|
2022-12-12 23:39:50 +00:00
|
|
|
let feed = cacheModel.retrieveFeed(account: account)
|
|
|
|
backgroundContext.perform { [weak self] in
|
|
|
|
guard let self else { return }
|
|
|
|
|
2022-12-13 12:14:20 +00:00
|
|
|
let watched = self.watchFetchRequestResult(feed, context: self.backgroundContext).filter { $0.finished }
|
|
|
|
let unwatched = feed.filter { video in !watched.contains { $0.videoID == video.videoID } }
|
2022-12-14 12:05:36 +00:00
|
|
|
let unwatchedCount = max(0, feed.count - watched.count)
|
2022-12-12 23:39:50 +00:00
|
|
|
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
|
|
guard let self else { return }
|
2022-12-16 21:26:14 +00:00
|
|
|
if unwatchedCount != self.feedCount.unwatched[account] {
|
|
|
|
self.feedCount.unwatched[account] = unwatchedCount
|
2022-12-12 23:39:50 +00:00
|
|
|
}
|
2022-12-13 12:14:20 +00:00
|
|
|
|
|
|
|
let byChannel = Dictionary(grouping: unwatched) { $0.channel.id }.mapValues(\.count)
|
2022-12-16 21:26:14 +00:00
|
|
|
self.feedCount.unwatchedByChannel[account] = byChannel
|
2022-12-12 23:39:50 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func markAllFeedAsWatched() {
|
2022-12-13 19:15:54 +00:00
|
|
|
guard let account = accounts.current, accounts.signedIn else { return }
|
2022-12-12 23:39:50 +00:00
|
|
|
|
2022-12-13 12:14:20 +00:00
|
|
|
let mark = { [weak self] in
|
|
|
|
self?.backgroundContext.perform { [weak self] in
|
|
|
|
guard let self else { return }
|
|
|
|
self.videos.forEach { Watch.markAsWatched(videoID: $0.videoID, account: account, duration: $0.length, context: self.backgroundContext) }
|
2022-12-12 23:39:50 +00:00
|
|
|
|
2022-12-13 12:14:20 +00:00
|
|
|
self.calculateUnwatchedFeed()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if videos.isEmpty {
|
|
|
|
loadCachedFeed { mark() }
|
|
|
|
} else {
|
|
|
|
mark()
|
2022-12-12 23:39:50 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-12-13 12:14:20 +00:00
|
|
|
var canMarkAllFeedAsWatched: Bool {
|
2022-12-13 19:15:54 +00:00
|
|
|
guard let account = accounts.current, accounts.signedIn else { return false }
|
2022-12-16 21:26:14 +00:00
|
|
|
return (feedCount.unwatched[account] ?? 0) > 0
|
2022-12-13 12:14:20 +00:00
|
|
|
}
|
|
|
|
|
2022-12-14 17:10:01 +00:00
|
|
|
func canMarkChannelAsWatched(_ channelID: Channel.ID) -> Bool {
|
|
|
|
guard let account = accounts.current, accounts.signedIn else { return false }
|
|
|
|
|
2022-12-16 21:26:14 +00:00
|
|
|
return feedCount.unwatchedByChannel[account]?.keys.contains(channelID) ?? false
|
2022-12-14 17:10:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func markChannelAsWatched(_ channelID: Channel.ID) {
|
|
|
|
guard accounts.signedIn else { return }
|
2022-12-12 23:39:50 +00:00
|
|
|
|
2022-12-13 12:14:20 +00:00
|
|
|
let mark = { [weak self] in
|
2022-12-14 17:10:01 +00:00
|
|
|
guard let self else { return }
|
|
|
|
self.markVideos(self.videos.filter { $0.channel.id == channelID }, watched: true)
|
|
|
|
}
|
2022-12-12 23:39:50 +00:00
|
|
|
|
2022-12-14 17:10:01 +00:00
|
|
|
if videos.isEmpty {
|
|
|
|
loadCachedFeed { mark() }
|
|
|
|
} else {
|
|
|
|
mark()
|
|
|
|
}
|
|
|
|
}
|
2022-12-12 23:39:50 +00:00
|
|
|
|
2022-12-14 17:10:01 +00:00
|
|
|
func markChannelAsUnwatched(_ channelID: Channel.ID) {
|
|
|
|
guard accounts.signedIn else { return }
|
2022-12-12 23:39:50 +00:00
|
|
|
|
2022-12-14 17:10:01 +00:00
|
|
|
let mark = { [weak self] in
|
|
|
|
guard let self else { return }
|
|
|
|
self.markVideos(self.videos.filter { $0.channel.id == channelID }, watched: false)
|
2022-12-13 12:14:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if videos.isEmpty {
|
|
|
|
loadCachedFeed { mark() }
|
|
|
|
} else {
|
|
|
|
mark()
|
2022-12-12 23:39:50 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-12-14 17:10:01 +00:00
|
|
|
func markAllFeedAsUnwatched() {
|
|
|
|
guard accounts.current != nil else { return }
|
|
|
|
|
|
|
|
let mark = { [weak self] in
|
|
|
|
guard let self else { return }
|
|
|
|
self.markVideos(self.videos, watched: false)
|
|
|
|
}
|
|
|
|
|
|
|
|
if videos.isEmpty {
|
|
|
|
loadCachedFeed { mark() }
|
|
|
|
} else {
|
|
|
|
mark()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func markVideos(_ videos: [Video], watched: Bool) {
|
|
|
|
guard accounts.signedIn, let account = accounts.current else { return }
|
|
|
|
|
|
|
|
backgroundContext.perform { [weak self] in
|
|
|
|
guard let self else { return }
|
|
|
|
|
|
|
|
if watched {
|
|
|
|
videos.forEach { Watch.markAsWatched(videoID: $0.videoID, account: account, duration: $0.length, context: self.backgroundContext) }
|
|
|
|
} else {
|
|
|
|
let watches = self.watchFetchRequestResult(videos, context: self.backgroundContext)
|
|
|
|
watches.forEach { self.backgroundContext.delete($0) }
|
|
|
|
}
|
|
|
|
|
|
|
|
try? self.backgroundContext.save()
|
|
|
|
|
|
|
|
self.calculateUnwatchedFeed()
|
|
|
|
}
|
2022-12-12 23:39:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func playUnwatchedFeed() {
|
2022-12-13 19:15:54 +00:00
|
|
|
guard let account = accounts.current, accounts.signedIn else { return }
|
2022-12-12 23:39:50 +00:00
|
|
|
let videos = cacheModel.retrieveFeed(account: account)
|
|
|
|
guard !videos.isEmpty else { return }
|
|
|
|
|
|
|
|
let watches = watchFetchRequestResult(videos, context: backgroundContext)
|
|
|
|
let watchesIDs = watches.map(\.videoID)
|
|
|
|
let unwatched = videos.filter { video in
|
2023-02-25 15:42:18 +00:00
|
|
|
if Defaults[.hideShorts], video.short {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
2022-12-12 23:39:50 +00:00
|
|
|
if !watchesIDs.contains(video.videoID) {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
if let watch = watches.first(where: { $0.videoID == video.videoID }),
|
|
|
|
watch.finished
|
|
|
|
{
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
guard !unwatched.isEmpty else { return }
|
|
|
|
PlayerModel.shared.play(unwatched)
|
|
|
|
}
|
|
|
|
|
2022-12-13 12:14:20 +00:00
|
|
|
var canPlayUnwatchedFeed: Bool {
|
2022-12-13 19:15:54 +00:00
|
|
|
guard let account = accounts.current, accounts.signedIn else { return false }
|
2022-12-16 21:26:14 +00:00
|
|
|
return (feedCount.unwatched[account] ?? 0) > 0
|
2022-12-13 12:14:20 +00:00
|
|
|
}
|
|
|
|
|
2022-12-10 02:01:59 +00:00
|
|
|
var feedTime: Date? {
|
|
|
|
if let account = accounts.current {
|
2022-12-12 23:39:50 +00:00
|
|
|
return cacheModel.getFeedTime(account: account)
|
2022-12-10 02:01:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
var formattedFeedTime: String {
|
2022-12-12 09:21:46 +00:00
|
|
|
getFormattedDate(feedTime)
|
2022-12-10 02:01:59 +00:00
|
|
|
}
|
|
|
|
|
2022-12-13 12:14:20 +00:00
|
|
|
private func loadCachedFeed(_ onCompletion: @escaping () -> Void = {}) {
|
2022-12-13 19:15:54 +00:00
|
|
|
guard let account = accounts.current, accounts.signedIn else { return }
|
2022-12-12 23:39:50 +00:00
|
|
|
let cache = cacheModel.retrieveFeed(account: account)
|
2022-12-10 02:01:59 +00:00
|
|
|
if !cache.isEmpty {
|
2022-12-11 15:15:42 +00:00
|
|
|
DispatchQueue.main.async(qos: .userInteractive) { [weak self] in
|
2022-12-10 02:01:59 +00:00
|
|
|
self?.videos = cache
|
2022-12-13 12:14:20 +00:00
|
|
|
onCompletion()
|
2022-12-10 02:01:59 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func request(_ resource: Resource, force: Bool = false) -> Request? {
|
|
|
|
if force {
|
|
|
|
return resource.load()
|
|
|
|
}
|
|
|
|
|
|
|
|
return resource.loadIfNeeded()
|
|
|
|
}
|
2022-12-14 17:10:01 +00:00
|
|
|
|
|
|
|
private func watchFetchRequestResult(_ videos: [Video], context: NSManagedObjectContext) -> [Watch] {
|
|
|
|
let watchFetchRequest = Watch.fetchRequest()
|
|
|
|
watchFetchRequest.predicate = NSPredicate(format: "videoID IN %@", videos.map(\.videoID) as [String])
|
|
|
|
return (try? context.fetch(watchFetchRequest)) ?? []
|
|
|
|
}
|
2022-12-10 02:01:59 +00:00
|
|
|
}
|