mirror of
				https://github.com/yattee/yattee.git
				synced 2025-10-31 12:41:57 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			304 lines
		
	
	
		
			9.6 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			304 lines
		
	
	
		
			9.6 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
| import Cache
 | |
| import CoreData
 | |
| import Defaults
 | |
| import Foundation
 | |
| import Siesta
 | |
| import SwiftyJSON
 | |
| 
 | |
| final class FeedModel: ObservableObject, CacheModel {
 | |
|     static let shared = FeedModel()
 | |
| 
 | |
|     @Published var isLoading = false
 | |
|     @Published var videos = [Video]()
 | |
|     @Published private var page = 1
 | |
| 
 | |
|     private var feedCount = UnwatchedFeedCountModel.shared
 | |
|     private var cacheModel = FeedCacheModel.shared
 | |
|     private var accounts = AccountsModel.shared
 | |
| 
 | |
|     var storage: Storage<String, JSON>?
 | |
| 
 | |
|     @Published var error: RequestError?
 | |
| 
 | |
|     private var backgroundContext = PersistenceController.shared.container.newBackgroundContext()
 | |
| 
 | |
|     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 }
 | |
| 
 | |
|             if force || self.videos.isEmpty {
 | |
|                 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
 | |
|                     self.error = nil
 | |
|                     if let videos: [Video] = response.typedContent() {
 | |
|                         if paginating {
 | |
|                             self.videos.append(contentsOf: videos)
 | |
|                         } else {
 | |
|                             self.videos = videos
 | |
|                             self.cacheModel.storeFeed(account: account, videos: self.videos)
 | |
|                             self.calculateUnwatchedFeed()
 | |
|                         }
 | |
|                     }
 | |
|                 }
 | |
|                 .onFailure { self.error = $0 }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func reset() {
 | |
|         videos.removeAll()
 | |
|         page = 1
 | |
|     }
 | |
| 
 | |
|     func loadNextPage() {
 | |
|         guard accounts.app.paginatesSubscriptions, !isLoading else { return }
 | |
| 
 | |
|         loadFeed(force: true, paginating: true)
 | |
|     }
 | |
| 
 | |
|     func onAccountChange() {
 | |
|         reset()
 | |
|         error = nil
 | |
|         loadResources(force: true)
 | |
|         calculateUnwatchedFeed()
 | |
|     }
 | |
| 
 | |
|     func calculateUnwatchedFeed() {
 | |
|         guard let account = accounts.current, accounts.signedIn else { return }
 | |
|         let feed = cacheModel.retrieveFeed(account: account)
 | |
|         backgroundContext.perform { [weak self] in
 | |
|             guard let self else { return }
 | |
| 
 | |
|             let watched = self.watchFetchRequestResult(feed, context: self.backgroundContext).filter { $0.finished }
 | |
|             let unwatched = feed.filter { video in !watched.contains { $0.videoID == video.videoID } }
 | |
|             let unwatchedCount = max(0, feed.count - watched.count)
 | |
| 
 | |
|             DispatchQueue.main.async { [weak self] in
 | |
|                 guard let self else { return }
 | |
|                 if unwatchedCount != self.feedCount.unwatched[account] {
 | |
|                     self.feedCount.unwatched[account] = unwatchedCount
 | |
|                 }
 | |
| 
 | |
|                 let byChannel = Dictionary(grouping: unwatched) { $0.channel.id }.mapValues(\.count)
 | |
|                 self.feedCount.unwatchedByChannel[account] = byChannel
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func markAllFeedAsWatched() {
 | |
|         guard let account = accounts.current, accounts.signedIn else { return }
 | |
| 
 | |
|         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) }
 | |
| 
 | |
|                 self.calculateUnwatchedFeed()
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         if videos.isEmpty {
 | |
|             loadCachedFeed { mark() }
 | |
|         } else {
 | |
|             mark()
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     var canMarkAllFeedAsWatched: Bool {
 | |
|         guard let account = accounts.current, accounts.signedIn else { return false }
 | |
|         return (feedCount.unwatched[account] ?? 0) > 0
 | |
|     }
 | |
| 
 | |
|     func canMarkChannelAsWatched(_ channelID: Channel.ID) -> Bool {
 | |
|         guard let account = accounts.current, accounts.signedIn else { return false }
 | |
| 
 | |
|         return feedCount.unwatchedByChannel[account]?.keys.contains(channelID) ?? false
 | |
|     }
 | |
| 
 | |
|     func markChannelAsWatched(_ channelID: Channel.ID) {
 | |
|         guard accounts.signedIn else { return }
 | |
| 
 | |
|         let mark = { [weak self] in
 | |
|             guard let self else { return }
 | |
|             self.markVideos(self.videos.filter { $0.channel.id == channelID }, watched: true)
 | |
|         }
 | |
| 
 | |
|         if videos.isEmpty {
 | |
|             loadCachedFeed { mark() }
 | |
|         } else {
 | |
|             mark()
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func markChannelAsUnwatched(_ channelID: Channel.ID) {
 | |
|         guard accounts.signedIn else { return }
 | |
| 
 | |
|         let mark = { [weak self] in
 | |
|             guard let self else { return }
 | |
|             self.markVideos(self.videos.filter { $0.channel.id == channelID }, watched: false)
 | |
|         }
 | |
| 
 | |
|         if videos.isEmpty {
 | |
|             loadCachedFeed { mark() }
 | |
|         } else {
 | |
|             mark()
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     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()
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func playUnwatchedFeed() {
 | |
|         guard let account = accounts.current, accounts.signedIn else { return }
 | |
|         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
 | |
|             if Defaults[.hideShorts], video.short {
 | |
|                 return false
 | |
|             }
 | |
| 
 | |
|             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)
 | |
|     }
 | |
| 
 | |
|     var canPlayUnwatchedFeed: Bool {
 | |
|         guard let account = accounts.current, accounts.signedIn else { return false }
 | |
|         return (feedCount.unwatched[account] ?? 0) > 0
 | |
|     }
 | |
| 
 | |
|     var feedTime: Date? {
 | |
|         if let account = accounts.current {
 | |
|             return cacheModel.getFeedTime(account: account)
 | |
|         }
 | |
| 
 | |
|         return nil
 | |
|     }
 | |
| 
 | |
|     var formattedFeedTime: String {
 | |
|         getFormattedDate(feedTime)
 | |
|     }
 | |
| 
 | |
|     private func loadCachedFeed(_ onCompletion: @escaping () -> Void = {}) {
 | |
|         guard let account = accounts.current, accounts.signedIn else { return }
 | |
|         let cache = cacheModel.retrieveFeed(account: account)
 | |
|         if !cache.isEmpty {
 | |
|             DispatchQueue.main.async(qos: .userInteractive) { [weak self] in
 | |
|                 self?.videos = cache
 | |
|                 onCompletion()
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private func request(_ resource: Resource, force: Bool = false) -> Request? {
 | |
|         if force {
 | |
|             return resource.load()
 | |
|         }
 | |
| 
 | |
|         return resource.loadIfNeeded()
 | |
|     }
 | |
| 
 | |
|     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)) ?? []
 | |
|     }
 | |
| }
 | 
