Opening videos by URL and local files

This commit is contained in:
Arkadiusz Fal
2022-11-10 18:11:28 +01:00
parent 34f7621f36
commit 402d1a2f79
40 changed files with 1158 additions and 126 deletions

View File

@@ -80,6 +80,11 @@ extension VideosAPI {
return
}
if let video = item.video, video.isLocal {
completionHandler(item)
return
}
video(item.videoID).load()
.onSuccess { response in
guard let video: Video = response.typedContent() else {
@@ -87,6 +92,7 @@ extension VideosAPI {
}
var newItem = item
newItem.id = UUID()
newItem.video = video
completionHandler(newItem)

View File

@@ -63,4 +63,8 @@ enum VideosApp: String, CaseIterable {
var allowsDisablingVidoesProxying: Bool {
self == .invidious
}
var supportsOpeningVideosByID: Bool {
self != .demoApp
}
}

25
Model/CacheModel.swift Normal file
View File

@@ -0,0 +1,25 @@
import Cache
import Foundation
import SwiftyJSON
struct CacheModel {
static var shared = CacheModel()
var urlBookmarksStorage: Storage<String, Data>?
var videoStorage: Storage<Video.ID, JSON>?
init() {
let urlBookmarksStorageConfig = DiskConfig(name: "URLBookmarks", expiry: .never)
let urlBookmarksMemoryConfig = MemoryConfig(expiry: .never, countLimit: 100, totalCostLimit: 100)
urlBookmarksStorage = try? Storage(diskConfig: urlBookmarksStorageConfig, memoryConfig: urlBookmarksMemoryConfig, transformer: TransformerFactory.forData())
let videoStorageConfig = DiskConfig(name: "VideoStorage", expiry: .never)
let videoStorageMemoryConfig = MemoryConfig(expiry: .never, countLimit: 100, totalCostLimit: 100)
let toData: (JSON) throws -> Data = { try $0.rawData() }
let fromData: (Data) throws -> JSON = { try JSON(data: $0) }
let jsonTransformer = Transformer<JSON>(toData: toData, fromData: fromData)
videoStorage = try? Storage<Video.ID, JSON>(diskConfig: videoStorageConfig, memoryConfig: videoStorageMemoryConfig, transformer: jsonTransformer)
}
}

View File

@@ -2,6 +2,7 @@ import CoreData
import CoreMedia
import Defaults
import Foundation
import SwiftyJSON
extension PlayerModel {
func historyVideo(_ id: String) -> Video? {
@@ -13,12 +14,37 @@ extension PlayerModel {
return
}
if !Video.VideoID.isValid(id), let url = URL(string: id) {
historyVideos.append(.local(url))
return
}
if historyItemBeingLoaded == nil {
logger.info("loading history details: \(id)")
historyItemBeingLoaded = id
} else {
logger.info("POSTPONING history load: \(id)")
historyItemsToLoad.append(id)
return
}
playerAPI.video(id).load().onSuccess { [weak self] response in
guard let video: Video = response.typedContent() else {
return
guard let self else { return }
if let video: Video = response.typedContent() {
self.historyVideos.append(video)
}
}.onCompletion { _ in
self.logger.info("LOADED history details: \(id)")
if self.historyItemBeingLoaded == id {
self.logger.info("setting no history loaded")
self.historyItemBeingLoaded = nil
}
self?.historyVideos.append(video)
if let id = self.historyItemsToLoad.popLast() {
self.loadHistoryVideoDetails(id)
}
}
}

View File

@@ -72,6 +72,7 @@ final class NavigationModel: ObservableObject {
@Published var presentingPlaylist = false
@Published var sidebarSectionChanged = false
@Published var presentingOpenVideos = false
@Published var presentingSettings = false
@Published var presentingWelcomeScreen = false

109
Model/OpenVideosModel.swift Normal file
View File

@@ -0,0 +1,109 @@
import Foundation
import Logging
struct OpenVideosModel {
enum PlaybackMode: String, CaseIterable {
case playNow
case shuffleAll
case playNext
case playLast
var description: String {
switch self {
case .playNow:
return "Play Now".localized()
case .shuffleAll:
return "Shuffle All".localized()
case .playNext:
return "Play Next".localized()
case .playLast:
return "Play Last".localized()
}
}
var allowsRemovingQueueItems: Bool {
self == .playNow || self == .shuffleAll
}
var allowedWhenQueueIsEmpty: Bool {
self == .playNow || self == .shuffleAll
}
}
static let shared = OpenVideosModel()
var player: PlayerModel! = .shared
var logger = Logger(label: "stream.yattee.open-videos")
func open(_ url: URL) {
if url.startAccessingSecurityScopedResource() {
let video = Video.local(url)
player.play([video], shuffling: false)
}
}
func openURLs(_ urls: [URL], removeQueueItems: Bool, playbackMode: OpenVideosModel.PlaybackMode) {
logger.info("opening \(urls.count) urls")
urls.forEach { logger.info("\($0.absoluteString)") }
if removeQueueItems, playbackMode.allowsRemovingQueueItems {
player.removeQueueItems()
logger.info("removing queue items")
}
switch playbackMode {
case .playNow:
player.playbackMode = .queue
case .shuffleAll:
player.playbackMode = .shuffle
case .playNext:
player.playbackMode = .queue
case .playLast:
player.playbackMode = .queue
}
enqueue(
urls,
prepending: playbackMode == .playNow || playbackMode == .playNext
)
if playbackMode == .playNow || playbackMode == .shuffleAll {
player.show()
player.advanceToNextItem()
}
}
func enqueue(_ urls: [URL], prepending: Bool = false) {
var videos = urls.compactMap { url in
var video: Video!
if canOpenVideosByID {
let parser = URLParser(url: url)
if parser.destination == .video, let id = parser.videoID {
video = Video(videoID: id)
logger.info("identified remote video: \(id)")
} else {
video = .local(url)
logger.info("identified local video: \(url.absoluteString)")
}
} else {
video = .local(url)
logger.info("identified local video: \(url.absoluteString)")
}
return video
}
if prepending {
videos.reverse()
}
videos.forEach { video in
player.enqueueVideo(video, play: false, prepending: prepending, loadDetails: false)
}
}
var canOpenVideosByID: Bool {
guard let app = player.accounts.current?.app else { return false }
return !player.accounts.isEmpty && app.supportsOpeningVideosByID
}
}

View File

@@ -37,9 +37,24 @@ final class AVPlayerBackend: PlayerBackend {
avPlayer.timeControlStatus == .playing
}
var videoWidth: Double? {
if let width = avPlayer.currentItem?.presentationSize.width {
return Double(width)
}
return nil
}
var videoHeight: Double? {
if let height = avPlayer.currentItem?.presentationSize.height {
return Double(height)
}
return nil
}
var aspectRatio: Double {
#if os(iOS)
playerLayer.videoRect.width / playerLayer.videoRect.height
videoWidth! / videoHeight!
#else
VideoPlayerView.defaultAspectRatio
#endif
@@ -104,8 +119,17 @@ final class AVPlayerBackend: PlayerBackend {
preservingTime: Bool,
upgrading _: Bool
) {
if let url = stream.singleAssetURL {
if var url = stream.singleAssetURL {
model.logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
if video.isLocal, video.localStreamIsFile, let localURL = video.localStream?.localURL {
guard localURL.startAccessingSecurityScopedResource() else {
model.navigation.presentAlert(title: "Could not open file")
return
}
url = localURL
}
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime)
} else {
model.logger.info("playing stream with many assets:")
@@ -317,6 +341,7 @@ final class AVPlayerBackend: PlayerBackend {
guard video == self.model.currentVideo else {
return
}
self.avPlayer.replaceCurrentItem(with: self.model.playerItem)
self.seekToPreservedTime { finished in
guard finished else {
@@ -373,7 +398,8 @@ final class AVPlayerBackend: PlayerBackend {
#if !os(macOS)
var externalMetadata = [
makeMetadataItem(.commonIdentifierTitle, value: video.title),
makeMetadataItem(.commonIdentifierTitle, value: video.displayTitle),
makeMetadataItem(.commonIdentifierArtist, value: video.displayAuthor),
makeMetadataItem(.quickTimeMetadataGenre, value: video.genre ?? ""),
makeMetadataItem(.commonIdentifierDescription, value: video.description ?? "")
]

View File

@@ -108,6 +108,10 @@ final class MPVBackend: PlayerBackend {
client?.outputFps ?? 0
}
var formattedOutputFps: String {
String(format: "%.2ffps", outputFps)
}
var hwDecoder: String {
client?.hwDecoder ?? "unknown"
}
@@ -120,6 +124,54 @@ final class MPVBackend: PlayerBackend {
client?.cacheDuration ?? 0
}
var videoFormat: String {
client?.videoFormat ?? "unknown"
}
var videoCodec: String {
client?.videoCodec ?? "unknown"
}
var currentVo: String {
client?.currentVo ?? "unknown"
}
var videoWidth: Double? {
if let width = client?.width, width != "unknown" {
return Double(width)
}
return nil
}
var videoHeight: Double? {
if let height = client?.height, height != "unknown" {
return Double(height)
}
return nil
}
var audioFormat: String {
client?.audioFormat ?? "unknown"
}
var audioCodec: String {
client?.audioCodec ?? "unknown"
}
var currentAo: String {
client?.currentAo ?? "unknown"
}
var audioChannels: String {
client?.audioChannels ?? "unknown"
}
var audioSampleRate: String {
client?.audioSampleRate ?? "unknown"
}
init() {
clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in
self?.getTimeUpdates()
@@ -230,6 +282,13 @@ final class MPVBackend: PlayerBackend {
startPlaying()
}
if video.isLocal, video.localStreamIsFile, let localStream = video.localStream {
guard localStream.localURL.startAccessingSecurityScopedResource() else {
self.model.navigation.presentAlert(title: "Could not open file")
return
}
}
self.client.loadFile(url, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
self?.isLoadingVideo = true
}

View File

@@ -198,6 +198,50 @@ final class MPVClient: ObservableObject {
mpv.isNil ? 0.0 : getDouble("demuxer-cache-duration")
}
var videoFormat: String {
stringOrUnknown("video-format")
}
var videoCodec: String {
stringOrUnknown("video-codec")
}
var currentVo: String {
stringOrUnknown("current-vo")
}
var width: String {
stringOrUnknown("width")
}
var height: String {
stringOrUnknown("height")
}
var videoBitrate: Double {
mpv.isNil ? 0.0 : getDouble("video-bitrate")
}
var audioFormat: String {
stringOrUnknown("audio-params/format")
}
var audioCodec: String {
stringOrUnknown("audio-codec")
}
var currentAo: String {
stringOrUnknown("current-ao")
}
var audioChannels: String {
stringOrUnknown("audio-params/channels")
}
var audioSampleRate: String {
stringOrUnknown("audio-params/samplerate")
}
var aspectRatio: Double {
guard !mpv.isNil else { return VideoPlayerView.defaultAspectRatio }
let aspect = getDouble("video-params/aspect")
@@ -407,6 +451,10 @@ final class MPVClient: ObservableObject {
}
}
private func stringOrUnknown(_ name: String) -> String {
mpv.isNil ? "unknown" : (getString(name) ?? "unknown")
}
private var machine: String {
var systeminfo = utsname()
uname(&systeminfo)

View File

@@ -25,6 +25,9 @@ protocol PlayerBackend {
var aspectRatio: Double { get }
var controlsUpdates: Bool { get }
var videoWidth: Double? { get }
var videoHeight: Double? { get }
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream?
func canPlay(_ stream: Stream) -> Bool

View File

@@ -97,6 +97,10 @@ final class PlayerModel: ObservableObject {
@Published var currentItem: PlayerQueueItem! { didSet { handleCurrentItemChange() } }
@Published var videoBeingOpened: Video? { didSet { seek.reset() } }
@Published var historyVideos = [Video]()
@Published var queueItemBeingLoaded: PlayerQueueItem?
@Published var queueItemsToLoad = [PlayerQueueItem]()
@Published var historyItemBeingLoaded: Video.ID?
@Published var historyItemsToLoad = [Video.ID]()
@Published var preservedTime: CMTime?
@@ -373,7 +377,7 @@ final class PlayerModel: ObservableObject {
withBackend: PlayerBackend? = nil
) {
playerError = nil
if !upgrading {
if !upgrading, !video.isLocal {
resetSegments()
DispatchQueue.main.async { [weak self] in
@@ -440,7 +444,7 @@ final class PlayerModel: ObservableObject {
changeActiveBackend(from: activeBackend, to: backend)
}
guard let stream = streamByQualityProfile else {
guard let stream = ((availableStreams.count == 1 && availableStreams.first!.isLocal) ? availableStreams.first : nil) ?? streamByQualityProfile else {
return
}
@@ -842,8 +846,8 @@ final class PlayerModel: ObservableObject {
let currentTime = (backend.currentTime?.seconds.isFinite ?? false) ? backend.currentTime!.seconds : 0
var nowPlayingInfo: [String: AnyObject] = [
MPMediaItemPropertyTitle: video.title as AnyObject,
MPMediaItemPropertyArtist: video.author as AnyObject,
MPMediaItemPropertyTitle: video.displayTitle as AnyObject,
MPMediaItemPropertyArtist: video.displayAuthor as AnyObject,
MPNowPlayingInfoPropertyIsLiveStream: live as AnyObject,
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime as AnyObject,
MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject,
@@ -952,4 +956,9 @@ final class PlayerModel: ObservableObject {
}
#endif
}
var formattedSize: String {
guard let videoWidth = backend?.videoWidth, let videoHeight = backend?.videoHeight else { return "unknown" }
return "\(String(format: "%.2f", videoWidth))\u{d7}\(String(format: "%.2f", videoHeight))"
}
}

View File

@@ -68,7 +68,7 @@ extension PlayerModel {
guard let playerInstance = self.playerInstance else { return }
let streamsInstance = video.streams.compactMap(\.instance).first
if video.streams.isEmpty || streamsInstance != playerInstance {
if !video.isLocal, video.streams.isEmpty || streamsInstance != playerInstance {
self.loadAvailableStreams(video)
} else {
self.availableStreams = self.streamsWithInstance(instance: playerInstance, streams: video.streams)
@@ -203,6 +203,7 @@ extension PlayerModel {
}
}
} else {
videoDetailsLoadHandler(video, item)
queue.insert(item, at: prepending ? 0 : queue.endIndex)
}
@@ -210,11 +211,22 @@ extension PlayerModel {
}
func prepareCurrentItemForHistory(finished: Bool = false) {
if !currentItem.isNil, Defaults[.saveHistory] {
if let video = currentVideo, !historyVideos.contains(where: { $0 == video }) {
historyVideos.append(video)
if let currentItem {
if Defaults[.saveHistory] {
if let video = currentVideo, !historyVideos.contains(where: { $0 == video }) {
historyVideos.append(video)
}
updateWatch(finished: finished)
}
if let video = currentItem.video,
video.isLocal,
video.localStreamIsFile,
let localURL = video.localStream?.localURL
{
logger.info("stopping security scoped resource access for \(localURL)")
localURL.stopAccessingSecurityScopedResource()
}
updateWatch(finished: finished)
}
}
@@ -253,9 +265,35 @@ extension PlayerModel {
func loadQueueVideoDetails(_ item: PlayerQueueItem) {
guard !accounts.current.isNil, !item.hasDetailsLoaded else { return }
playerAPI.loadDetails(item, completionHandler: { newItem in
if let index = self.queue.firstIndex(where: { $0.id == item.id }) {
self.queue[index] = newItem
let videoID = item.video?.videoID ?? item.videoID
if queueItemBeingLoaded == nil {
logger.info("loading queue details: \(videoID)")
queueItemBeingLoaded = item
} else {
logger.info("POSTPONING details load: \(videoID)")
queueItemsToLoad.append(item)
return
}
playerAPI.loadDetails(item, completionHandler: { [weak self] newItem in
guard let self else { return }
self.queue.filter { $0.videoID == item.videoID }.forEach { item in
if let index = self.queue.firstIndex(of: item) {
self.queue[index] = newItem
}
}
self.logger.info("LOADED queue details: \(videoID)")
if self.queueItemBeingLoaded == item {
self.logger.info("setting nothing loaded")
self.queueItemBeingLoaded = nil
}
if let item = self.queueItemsToLoad.popLast() {
self.loadQueueVideoDetails(item)
}
})
}

View File

@@ -42,7 +42,8 @@ struct PlayerQueueItem: Hashable, Identifiable, Defaults.Serializable {
}
var hasDetailsLoaded: Bool {
!video.isNil
guard let video else { return false }
return !video.streams.isEmpty
}
func hash(into hasher: inout Hasher) {

View File

@@ -25,7 +25,13 @@ struct PlayerQueueItemBridge: Defaults.Bridge {
}
}
var localURL = ""
if let video = value.video, video.isLocal {
localURL = video.localStream?.localURL.absoluteString ?? ""
}
return [
"localURL": localURL,
"videoID": value.videoID,
"playbackTime": playbackTime,
"videoDuration": videoDuration
@@ -33,12 +39,7 @@ struct PlayerQueueItemBridge: Defaults.Bridge {
}
func deserialize(_ object: Serializable?) -> Value? {
guard
let object,
let videoID = object["videoID"]
else {
return nil
}
guard let object else { return nil }
var playbackTime: CMTime?
var videoDuration: TimeInterval?
@@ -56,6 +57,19 @@ struct PlayerQueueItemBridge: Defaults.Bridge {
videoDuration = TimeInterval(duration)
}
if let localUrlString = object["localURL"],
!localUrlString.isEmpty,
let localURL = URL(string: localUrlString)
{
return PlayerQueueItem(
.local(localURL),
playbackTime: playbackTime,
videoDuration: videoDuration
)
}
guard let videoID = object["videoID"] else { return nil }
return PlayerQueueItem(
videoID: videoID,
playbackTime: playbackTime,

View File

@@ -141,6 +141,7 @@ class Stream: Equatable, Hashable, Identifiable {
var audioAsset: AVURLAsset!
var videoAsset: AVURLAsset!
var hlsURL: URL!
var localURL: URL!
var resolution: Resolution!
var kind: Kind!
@@ -154,6 +155,7 @@ class Stream: Equatable, Hashable, Identifiable {
audioAsset: AVURLAsset? = nil,
videoAsset: AVURLAsset? = nil,
hlsURL: URL? = nil,
localURL: URL? = nil,
resolution: Resolution? = nil,
kind: Kind = .hls,
encoding: String? = nil,
@@ -163,17 +165,25 @@ class Stream: Equatable, Hashable, Identifiable {
self.audioAsset = audioAsset
self.videoAsset = videoAsset
self.hlsURL = hlsURL
self.localURL = localURL
self.resolution = resolution
self.kind = kind
self.encoding = encoding
format = .from(videoFormat ?? "")
}
var isLocal: Bool {
localURL != nil
}
var quality: String {
kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
guard localURL.isNil else { return "Opened File" }
return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
}
var shortQuality: String {
guard localURL.isNil else { return "File" }
if kind == .hls {
return "HLS"
} else {
@@ -182,6 +192,7 @@ class Stream: Equatable, Hashable, Identifiable {
}
var description: String {
guard localURL.isNil else { return resolutionAndFormat }
let instanceString = instance.isNil ? "" : " - (\(instance!.description))"
return "\(resolutionAndFormat)\(instanceString)"
}
@@ -200,6 +211,10 @@ class Stream: Equatable, Hashable, Identifiable {
}
var singleAssetURL: URL? {
guard localURL.isNil else {
return URLBookmarkModel.shared.loadBookmark(localURL) ?? localURL
}
if kind == .hls {
return hlsURL
} else if videoAssetContainsAudio {

View File

@@ -0,0 +1,59 @@
import Cache
import Foundation
import Logging
struct URLBookmarkModel {
static var shared = URLBookmarkModel()
var logger = Logger(label: "stream.yattee.url-bookmark")
func saveBookmark(_ url: URL) {
if let bookmarkData = try? url.bookmarkData(options: bookmarkCreationOptions, includingResourceValuesForKeys: nil, relativeTo: nil) {
try? CacheModel.shared.urlBookmarksStorage?.setObject(bookmarkData, forKey: url.absoluteString)
logger.info("saved bookmark for \(url.absoluteString)")
}
}
func loadBookmark(_ url: URL) -> URL? {
logger.info("loading bookmark for \(url.absoluteString)")
guard let data = try? CacheModel.shared.urlBookmarksStorage?.object(forKey: url.absoluteString) else {
logger.info("bookmark for \(url.absoluteString) not found")
return nil
}
do {
var isStale = false
let url = try URL(
resolvingBookmarkData: data,
options: bookmarkResolutionOptions,
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
if isStale {
saveBookmark(url)
}
logger.info("loaded bookmark for \(url.absoluteString)")
return url
} catch {
print("Error resolving bookmark:", error)
return nil
}
}
var bookmarkCreationOptions: URL.BookmarkCreationOptions {
#if os(macOS)
return [.withSecurityScope, .securityScopeAllowOnlyReadAccess]
#else
return []
#endif
}
var bookmarkResolutionOptions: URL.BookmarkResolutionOptions {
#if os(macOS)
return [.withSecurityScope]
#else
return []
#endif
}
}

View File

@@ -5,6 +5,12 @@ import SwiftUI
import SwiftyJSON
struct Video: Identifiable, Equatable, Hashable {
enum VideoID {
static func isValid(_ id: Video.ID) -> Bool {
id.count == 11
}
}
let id: String
let videoID: String
var title: String
@@ -84,6 +90,33 @@ struct Video: Identifiable, Equatable, Hashable {
self.captions = captions
}
static func local(_ url: URL) -> Video {
Video(
videoID: url.absoluteString,
streams: [.init(localURL: url)]
)
}
var isLocal: Bool {
!VideoID.isValid(videoID)
}
var displayTitle: String {
if isLocal {
return localStreamFileName ?? localStream?.description ?? title
}
return title
}
var displayAuthor: String {
if isLocal, localStreamIsRemoteURL {
return remoteUrlHost ?? "Unknown"
}
return author
}
var publishedDate: String? {
(published.isEmpty || published == "0 seconds ago") ? nil : published
}
@@ -133,4 +166,42 @@ struct Video: Identifiable, Equatable, Hashable {
predicate: NSPredicate(format: "videoID = %@", videoID)
)
}
var localStream: Stream? {
guard isLocal else { return nil }
return streams.first
}
var localStreamIsFile: Bool {
guard let localStream else { return false }
return localStream.localURL.isFileURL
}
var localStreamIsRemoteURL: Bool {
guard let localStream else { return false }
return !localStream.localURL.isFileURL
}
var remoteUrlHost: String? {
localStreamURLComponents?.host
}
var localStreamFileName: String? {
guard let path = localStream?.localURL?.lastPathComponent else { return nil }
if let localStreamFileExtension {
return String(path.dropLast(localStreamFileExtension.count + 1))
}
return String(path)
}
var localStreamFileExtension: String? {
guard let path = localStreamURLComponents?.path else { return nil }
return path.contains(".") ? path.components(separatedBy: ".").last?.uppercased() : nil
}
private var localStreamURLComponents: URLComponents? {
guard let localStream else { return nil }
return URLComponents(url: localStream.localURL, resolvingAgainstBaseURL: false)
}
}

View File

@@ -0,0 +1,23 @@
import Foundation
import Logging
import SwiftyJSON
struct VideoCacheModel {
static let shared = VideoCacheModel()
var logger = Logger(label: "stream.yattee.video-cache")
func saveVideo(id: Video.ID, app: VideosApp, json: JSON) {
guard !json.isEmpty else { return }
var jsonWithApp = json
jsonWithApp["app"].string = app.rawValue
try! CacheModel.shared.videoStorage!.setObject(jsonWithApp, forKey: id)
logger.info("saving video \(id)")
}
func loadVideo(id: Video.ID) -> JSON? {
logger.info("loading video \(id)")
let json = try? CacheModel.shared.videoStorage?.object(forKey: id)
return json
}
}

View File

@@ -79,9 +79,10 @@ extension Watch {
}
var video: Video {
Video(
videoID: videoID, title: "", author: "",
length: 0, published: "", views: -1, channel: Channel(id: "", name: "")
)
if !Video.VideoID.isValid(videoID), let url = URL(string: videoID) {
return .local(url)
}
return Video(videoID: videoID)
}
}