mirror of
https://github.com/yattee/yattee.git
synced 2026-08-05 23:01:28 +00:00
Persistence models (local playlists, watch history, bookmarks, downloads) store only the best advertised thumbnail URL - usually maxresdefault.jpg, which doesn't exist for many older videos and 404s. Live API results survive this because views fall back through the full quality chain, but toVideo() rebuilt videos with that single dead URL, leaving placeholder covers. Reconstruct the quality fallback chain from the stored YouTube-style URL at read time (Thumbnail.fallbackChain), and rewrite playlist list covers to the always-available hqdefault variant (Thumbnail.reliableURL), since those views render a single URL without fallback. Also expand the fabricated maxres URL in PipedAPI into a full chain.
293 lines
9.2 KiB
Swift
293 lines
9.2 KiB
Swift
//
|
|
// WatchEntry.swift
|
|
// Yattee
|
|
//
|
|
// SwiftData model for tracking video watch history.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
|
|
/// Represents a watched video entry in the user's history.
|
|
@Model
|
|
final class WatchEntry {
|
|
// MARK: - Video Identity
|
|
|
|
/// The video ID string (YouTube ID or PeerTube UUID).
|
|
var videoID: String = ""
|
|
|
|
/// The content source raw value for encoding ("global", "federated", "extracted").
|
|
var sourceRawValue: String = "global"
|
|
|
|
/// For global sources: the provider name (e.g., "youtube", "dailymotion").
|
|
var globalProvider: String?
|
|
|
|
/// For PeerTube: the instance URL string.
|
|
var instanceURLString: String?
|
|
|
|
/// For PeerTube: the UUID.
|
|
var peertubeUUID: String?
|
|
|
|
/// For external sources: the extractor name (e.g., "vimeo", "twitter").
|
|
var externalExtractor: String?
|
|
|
|
/// For external sources: the original URL for re-extraction.
|
|
var externalURLString: String?
|
|
|
|
// MARK: - Video Metadata (cached for offline display)
|
|
|
|
/// The video title at time of watching.
|
|
var title: String = ""
|
|
|
|
/// The channel/author name.
|
|
var authorName: String = ""
|
|
|
|
/// The channel/author ID.
|
|
var authorID: String = ""
|
|
|
|
/// Video duration in seconds.
|
|
var duration: TimeInterval = 0
|
|
|
|
/// Thumbnail URL string.
|
|
var thumbnailURLString: String?
|
|
|
|
/// Whether this entry was recorded for a live stream.
|
|
/// Live entries never carry a resume position - see `recordLiveWatch()`.
|
|
var isLive: Bool = false
|
|
|
|
// MARK: - Watch Progress
|
|
|
|
/// Last watched position in seconds.
|
|
var watchedSeconds: TimeInterval = 0
|
|
|
|
/// Whether the video has been fully watched (>90% or manually marked).
|
|
var isFinished: Bool = false
|
|
|
|
/// When the video was marked as finished.
|
|
var finishedAt: Date?
|
|
|
|
// MARK: - Timestamps
|
|
|
|
/// When this entry was first created.
|
|
var createdAt: Date = Date()
|
|
|
|
/// When this entry was last updated.
|
|
var updatedAt: Date = Date()
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(
|
|
videoID: String,
|
|
sourceRawValue: String,
|
|
globalProvider: String? = nil,
|
|
instanceURLString: String? = nil,
|
|
peertubeUUID: String? = nil,
|
|
externalExtractor: String? = nil,
|
|
externalURLString: String? = nil,
|
|
title: String,
|
|
authorName: String,
|
|
authorID: String,
|
|
duration: TimeInterval,
|
|
thumbnailURLString: String? = nil,
|
|
watchedSeconds: TimeInterval = 0,
|
|
isFinished: Bool = false,
|
|
isLive: Bool = false
|
|
) {
|
|
self.videoID = videoID
|
|
self.sourceRawValue = sourceRawValue
|
|
self.globalProvider = globalProvider
|
|
self.instanceURLString = instanceURLString
|
|
self.peertubeUUID = peertubeUUID
|
|
self.externalExtractor = externalExtractor
|
|
self.externalURLString = externalURLString
|
|
self.title = title
|
|
self.authorName = authorName
|
|
self.authorID = authorID
|
|
self.duration = duration
|
|
self.thumbnailURLString = thumbnailURLString
|
|
self.watchedSeconds = watchedSeconds
|
|
self.isFinished = isFinished
|
|
self.isLive = isLive
|
|
self.createdAt = Date()
|
|
self.updatedAt = Date()
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
/// The content source for this entry.
|
|
var contentSource: ContentSource {
|
|
if sourceRawValue == "global" {
|
|
return .global(provider: globalProvider ?? ContentSource.youtubeProvider)
|
|
} else if sourceRawValue == "federated",
|
|
let urlString = instanceURLString,
|
|
let url = URL(string: urlString) {
|
|
return .federated(provider: ContentSource.peertubeProvider, instance: url)
|
|
} else if sourceRawValue == "extracted",
|
|
let extractor = externalExtractor,
|
|
let urlString = externalURLString,
|
|
let url = URL(string: urlString) {
|
|
return .extracted(extractor: extractor, originalURL: url)
|
|
}
|
|
return .global(provider: globalProvider ?? ContentSource.youtubeProvider)
|
|
}
|
|
|
|
/// The full VideoID for this entry, matching what VideoRowView uses for zoom transitions.
|
|
var videoIdentifier: VideoID {
|
|
VideoID(source: contentSource, videoID: videoID, uuid: peertubeUUID)
|
|
}
|
|
|
|
/// The thumbnail URL if available.
|
|
var thumbnailURL: URL? {
|
|
thumbnailURLString.flatMap { URL(string: $0) }
|
|
}
|
|
|
|
/// Watch progress as a percentage (0.0 to 1.0).
|
|
/// Always 0 for live streams - their playback position is relative to the
|
|
/// live edge, so it does not describe progress through a fixed timeline.
|
|
var progress: Double {
|
|
guard !isLive, duration > 0 else { return 0 }
|
|
return min(watchedSeconds / duration, 1.0)
|
|
}
|
|
|
|
/// Formatted total duration.
|
|
var formattedDuration: String {
|
|
guard duration > 0 else { return "" }
|
|
let hours = Int(duration) / 3600
|
|
let minutes = (Int(duration) % 3600) / 60
|
|
let seconds = Int(duration) % 60
|
|
|
|
if hours > 0 {
|
|
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
|
} else {
|
|
return String(format: "%d:%02d", minutes, seconds)
|
|
}
|
|
}
|
|
|
|
/// Formatted remaining time.
|
|
var remainingTime: String {
|
|
let remaining = max(0, duration - watchedSeconds)
|
|
let hours = Int(remaining) / 3600
|
|
let minutes = (Int(remaining) % 3600) / 60
|
|
let seconds = Int(remaining) % 60
|
|
|
|
if hours > 0 {
|
|
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
|
} else {
|
|
return String(format: "%d:%02d", minutes, seconds)
|
|
}
|
|
}
|
|
|
|
// MARK: - Methods
|
|
|
|
/// Updates the watch progress.
|
|
func updateProgress(seconds: TimeInterval, duration: TimeInterval? = nil) {
|
|
watchedSeconds = seconds
|
|
updatedAt = Date()
|
|
|
|
// Update duration if it was 0 and a valid duration is now known
|
|
if self.duration == 0, let newDuration = duration, newDuration > 0 {
|
|
self.duration = newDuration
|
|
}
|
|
|
|
// Mark as finished if watched more than 90%
|
|
if progress >= 0.9 && !isFinished {
|
|
isFinished = true
|
|
finishedAt = Date()
|
|
}
|
|
}
|
|
|
|
/// Records a live-stream view without storing a resume position.
|
|
/// Live playback positions are relative to the live edge, so keeping them
|
|
/// would make the app offer to "continue" from a meaningless timestamp.
|
|
/// Also clears state a previous non-live save may have left behind, which
|
|
/// heals entries wrongly marked finished by a drifting HLS duration.
|
|
func recordLiveWatch() {
|
|
isLive = true
|
|
watchedSeconds = 0
|
|
duration = 0
|
|
isFinished = false
|
|
finishedAt = nil
|
|
updatedAt = Date()
|
|
}
|
|
|
|
/// Marks the video as finished.
|
|
func markAsFinished() {
|
|
isFinished = true
|
|
finishedAt = Date()
|
|
updatedAt = Date()
|
|
}
|
|
|
|
/// Resets the watch progress.
|
|
func resetProgress() {
|
|
watchedSeconds = 0
|
|
isFinished = false
|
|
finishedAt = nil
|
|
updatedAt = Date()
|
|
}
|
|
}
|
|
|
|
// MARK: - Conversion Methods
|
|
|
|
extension WatchEntry {
|
|
/// Converts this WatchEntry back to a Video model for playback or display.
|
|
func toVideo() -> Video {
|
|
Video(
|
|
id: VideoID(source: contentSource, videoID: videoID),
|
|
title: title,
|
|
description: nil,
|
|
author: Author(id: authorID, name: authorName),
|
|
duration: duration,
|
|
publishedAt: nil,
|
|
publishedText: nil,
|
|
viewCount: nil,
|
|
likeCount: nil,
|
|
thumbnails: Thumbnail.fallbackChain(for: thumbnailURL),
|
|
isLive: isLive,
|
|
isUpcoming: false,
|
|
scheduledStartTime: nil
|
|
)
|
|
}
|
|
|
|
/// Creates a WatchEntry from a Video model.
|
|
static func from(video: Video) -> WatchEntry {
|
|
let sourceRaw: String
|
|
var provider: String?
|
|
var instanceURL: String?
|
|
var uuid: String?
|
|
var extractor: String?
|
|
var externalURL: String?
|
|
|
|
switch video.id.source {
|
|
case .global(let prov):
|
|
sourceRaw = "global"
|
|
provider = prov
|
|
case .federated(_, let instance):
|
|
sourceRaw = "federated"
|
|
instanceURL = instance.absoluteString
|
|
uuid = video.id.uuid
|
|
case .extracted(let ext, let originalURL):
|
|
sourceRaw = "extracted"
|
|
extractor = ext
|
|
externalURL = originalURL.absoluteString
|
|
}
|
|
|
|
return WatchEntry(
|
|
videoID: video.id.videoID,
|
|
sourceRawValue: sourceRaw,
|
|
globalProvider: provider,
|
|
instanceURLString: instanceURL,
|
|
peertubeUUID: uuid,
|
|
externalExtractor: extractor,
|
|
externalURLString: externalURL,
|
|
title: video.title,
|
|
authorName: video.author.name,
|
|
authorID: video.author.id,
|
|
// Live videos report no usable duration - Invidious sends 0 and Piped
|
|
// uses -1 as its live sentinel, which must never reach storage.
|
|
duration: video.isLive ? 0 : max(0, video.duration),
|
|
thumbnailURLString: video.bestThumbnail?.url.absoluteString,
|
|
isLive: video.isLive
|
|
)
|
|
}
|
|
}
|