diff --git a/Model/Applications/InvidiousAPI.swift b/Model/Applications/InvidiousAPI.swift index cdb339bd..6ab26b4b 100644 --- a/Model/Applications/InvidiousAPI.swift +++ b/Model/Applications/InvidiousAPI.swift @@ -109,17 +109,22 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { content.json.arrayValue.map(self.extractChannel) } - configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity) -> Channel in - self.extractChannel(from: content.json) + configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity) -> ChannelPage in + self.extractChannelPage(from: content.json, forceNotLast: true) + } + + configureTransformer(pathPattern("channels/*/videos"), requestMethods: [.get]) { (content: Entity) -> ChannelPage in + self.extractChannelPage(from: content.json) } configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity) -> [Video] in content.json.dictionaryValue["videos"]?.arrayValue.map(self.extractVideo) ?? [] } - configureTransformer(pathPattern("channels/*/playlists"), requestMethods: [.get]) { (content: Entity) -> [ContentItem] in - let playlists = (content.json.dictionaryValue["playlists"]?.arrayValue ?? []).compactMap { self.extractChannelPlaylist(from: $0) } - return ContentItem.array(of: playlists) + ["latest", "playlists", "streams", "shorts", "channels", "videos"].forEach { type in + configureTransformer(pathPattern("channels/*/\(type)"), requestMethods: [.get]) { (content: Entity) -> ChannelPage in + self.extractChannelPage(from: content.json) + } } configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity) -> ChannelPlaylist in @@ -266,11 +271,18 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { .onCompletion { _ in onCompletion() } } - func channel(_ id: String, contentType: Channel.ContentType, data _: String?) -> Resource { - if contentType == .playlists { - return resource(baseURL: account.url, path: basePathAppending("channels/\(id)/playlists")) + func channel(_ id: String, contentType: Channel.ContentType, data _: String?, page: String?) -> Resource { + if page.isNil, contentType == .videos { + return resource(baseURL: account.url, path: basePathAppending("channels/\(id)")) } - return resource(baseURL: account.url, path: basePathAppending("channels/\(id)")) + + var resource = resource(baseURL: account.url, path: basePathAppending("channels/\(id)/\(contentType.invidiousID)")) + + if let page, !page.isEmpty { + resource = resource.withParam("continuation", page) + } + + return resource } func channelByName(_: String) -> Resource? { @@ -504,6 +516,14 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { thumbnailURL = "\(accountUrlComponents.scheme ?? "https"):\(thumbnailURL)" } + let tabs = json["tabs"].arrayValue.compactMap { name in + if let name = name.string, let type = Channel.ContentType.from(name) { + return Channel.Tab(contentType: type, data: "") + } + + return nil + } + return Channel( app: .invidious, id: json["authorId"].stringValue, @@ -514,7 +534,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { subscriptionsCount: json["subCount"].int, subscriptionsText: json["subCountText"].string, totalViews: json["totalViews"].int, - videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(extractVideo) ?? [] + videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(extractVideo) ?? [], + tabs: tabs ) } @@ -552,6 +573,33 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { } } + private static var contentItemsKeys = ["items", "videos", "latestVideos", "playlists", "relatedChannels"] + + private func extractChannelPage(from json: JSON, forceNotLast: Bool = false) -> ChannelPage { + let nextPage = json.dictionaryValue["continuation"]?.string + var contentItems = [ContentItem]() + + var items = [ContentItem]() + + if let key = Self.contentItemsKeys.first(where: { json.dictionaryValue.keys.contains($0) }), + let items = json.dictionaryValue[key] + { + contentItems = extractContentItems(from: items) + } + + var last = false + if !forceNotLast { + last = nextPage?.isEmpty ?? true + } + + return ChannelPage( + results: contentItems, + channel: extractChannel(from: json), + nextPage: nextPage, + last: last + ) + } + private func extractStreams(from json: JSON) -> [Stream] { let hls = extractHLSStreams(from: json) if json["liveNow"].boolValue { @@ -668,4 +716,33 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { ) } } + + private func extractContentItems(from json: JSON) -> [ContentItem] { + json.arrayValue.compactMap { extractContentItem(from: $0) } + } + + private func extractContentItem(from json: JSON) -> ContentItem? { + let type = json.dictionaryValue["type"]?.string + + if type == "channel" { + return ContentItem(channel: extractChannel(from: json)) + } else if type == "playlist" { + return ContentItem(playlist: extractChannelPlaylist(from: json)) + } else if type == "video" { + return ContentItem(video: extractVideo(from: json)) + } + + return nil + } +} + +extension Channel.ContentType { + var invidiousID: String { + switch self { + case .livestreams: + return "streams" + default: + return rawValue + } + } } diff --git a/Model/Applications/PeerTubeAPI.swift b/Model/Applications/PeerTubeAPI.swift index 41766447..385c5de6 100644 --- a/Model/Applications/PeerTubeAPI.swift +++ b/Model/Applications/PeerTubeAPI.swift @@ -284,7 +284,7 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI { .onCompletion { _ in onCompletion() } } - func channel(_ id: String, contentType: Channel.ContentType, data _: String?) -> Resource { + func channel(_ id: String, contentType: Channel.ContentType, data _: String?, page _: String?) -> Resource { if contentType == .playlists { return resource(baseURL: account.url, path: basePathAppending("channels/\(id)/playlists")) } diff --git a/Model/Applications/PipedAPI.swift b/Model/Applications/PipedAPI.swift index 061f35cb..524ee3ad 100644 --- a/Model/Applications/PipedAPI.swift +++ b/Model/Applications/PipedAPI.swift @@ -6,6 +6,7 @@ import SwiftyJSON final class PipedAPI: Service, ObservableObject, VideosAPI { static var disallowedVideoCodecs = ["av01"] static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe", "user/playlists"] + static var contentItemsKeys = ["items", "content", "relatedStreams"] @Published var account: Account! @@ -40,8 +41,25 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { $0.headers["Authorization"] = self.account.token } - configureTransformer(pathPattern("channel/*")) { (content: Entity) -> Channel? in - self.extractChannel(from: content.json) + configureTransformer(pathPattern("channel/*")) { (content: Entity) -> ChannelPage in + let nextPage = content.json.dictionaryValue["nextpage"]?.string + let channel = self.extractChannel(from: content.json) + return ChannelPage( + results: self.extractContentItems(from: self.contentItemsDictionary(from: content.json)), + channel: channel, + nextPage: nextPage, + last: nextPage.isNil + ) + } + + configureTransformer(pathPattern("/nextpage/channel/*")) { (content: Entity) -> ChannelPage in + let nextPage = content.json.dictionaryValue["nextpage"]?.string + return ChannelPage( + results: self.extractContentItems(from: self.contentItemsDictionary(from: content.json)), + channel: self.extractChannel(from: content.json), + nextPage: nextPage, + last: nextPage.isNil + ) } configureTransformer(pathPattern("channels/tabs*")) { (content: Entity) -> [ContentItem] in @@ -159,13 +177,23 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { resource(baseURL: account.url, path: "login") } - func channel(_ id: String, contentType: Channel.ContentType, data: String?) -> Resource { - if contentType == .videos { - return resource(baseURL: account.url, path: "channel/\(id)") + func channel(_ id: String, contentType: Channel.ContentType, data: String?, page: String?) -> Resource { + let path = page.isNil ? "channel" : "nextpage/channel" + + var channel: Siesta.Resource + + if contentType == .videos || data.isNil { + channel = resource(baseURL: account.url, path: "\(path)/\(id)") + } else { + channel = resource(baseURL: account.url, path: "channels/tabs") + .withParam("data", data) } - return resource(baseURL: account.url, path: "channels/tabs") - .withParam("data", data) + if let page, !page.isEmpty { + channel = channel.withParam("nextpage", page) + } + + return channel } func channelByName(_ name: String) -> Resource? { @@ -700,4 +728,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { return Chapter(title: title, image: image, start: start) } } + + private func contentItemsDictionary(from content: JSON) -> JSON { + if let key = Self.contentItemsKeys.first(where: { content.dictionaryValue.keys.contains($0) }), + let items = content.dictionaryValue[key] + { + return items + } + + return .null + } } diff --git a/Model/Applications/VideosAPI.swift b/Model/Applications/VideosAPI.swift index 061ec840..44e5aaa5 100644 --- a/Model/Applications/VideosAPI.swift +++ b/Model/Applications/VideosAPI.swift @@ -8,7 +8,7 @@ protocol VideosAPI { static func withAnonymousAccountForInstanceURL(_ url: URL) -> Self - func channel(_ id: String, contentType: Channel.ContentType, data: String?) -> Resource + func channel(_ id: String, contentType: Channel.ContentType, data: String?, page: String?) -> Resource func channelByName(_ name: String) -> Resource? func channelByUsername(_ username: String) -> Resource? func channelVideos(_ id: String) -> Resource @@ -72,8 +72,8 @@ protocol VideosAPI { } extension VideosAPI { - func channel(_ id: String, contentType: Channel.ContentType, data: String? = nil) -> Resource { - channel(id, contentType: contentType, data: data) + func channel(_ id: String, contentType: Channel.ContentType, data: String? = nil, page: String? = nil) -> Resource { + channel(id, contentType: contentType, data: data, page: page) } func loadDetails( diff --git a/Model/Cache/ChannelsCacheModel.swift b/Model/Cache/ChannelsCacheModel.swift index 8a445588..05b466a0 100644 --- a/Model/Cache/ChannelsCacheModel.swift +++ b/Model/Cache/ChannelsCacheModel.swift @@ -34,11 +34,11 @@ struct ChannelsCacheModel: CacheModel { store(channel) } - func retrieve(_ cacheKey: String) -> Channel? { + func retrieve(_ cacheKey: String) -> ChannelPage? { logger.debug("retrieving cache for \(cacheKey)") if let json = try? storage?.object(forKey: cacheKey) { - return Channel.from(json) + return ChannelPage(channel: Channel.from(json)) } return nil diff --git a/Model/Cache/SubscribedChannelsModel.swift b/Model/Cache/SubscribedChannelsModel.swift index 024173a1..193d5dde 100644 --- a/Model/Cache/SubscribedChannelsModel.swift +++ b/Model/Cache/SubscribedChannelsModel.swift @@ -110,12 +110,12 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel { if let json = try? storage?.object(forKey: channelsCacheKey(account)), let channels = json.dictionaryValue["channels"] { - return channels.arrayValue.map { json in + return channels.arrayValue.compactMap { json in let channel = Channel.from(json) if !channel.hasExtendedDetails, let cache = ChannelsCacheModel.shared.retrieve(channel.cacheKey) { - return cache + return cache.channel } return channel diff --git a/Model/Channel.swift b/Model/Channel.swift index b146964e..fcfb6d3f 100644 --- a/Model/Channel.swift +++ b/Model/Channel.swift @@ -11,6 +11,15 @@ struct Channel: Identifiable, Hashable { case shorts case channels + static func from(_ name: String) -> Self? { + let rawValueMatch = allCases.first { $0.rawValue == name } + guard rawValueMatch.isNil else { return rawValueMatch! } + + if name == "streams" { return .livestreams } + + return nil + } + var id: String { rawValue } @@ -110,7 +119,6 @@ struct Channel: Identifiable, Hashable { } func hasData(for contentType: ContentType) -> Bool { - guard contentType != .videos, contentType != .playlists else { return true } return tabs.contains { $0.contentType == contentType } } @@ -132,7 +140,7 @@ struct Channel: Identifiable, Hashable { } var thumbnailURLOrCached: URL? { - thumbnailURL ?? ChannelsCacheModel.shared.retrieve(cacheKey)?.thumbnailURL + thumbnailURL ?? ChannelsCacheModel.shared.retrieve(cacheKey)?.channel?.thumbnailURL } var json: JSON { diff --git a/Model/ChannelPage.swift b/Model/ChannelPage.swift new file mode 100644 index 00000000..e4fd32e2 --- /dev/null +++ b/Model/ChannelPage.swift @@ -0,0 +1,8 @@ +import Foundation + +struct ChannelPage { + var results = [ContentItem]() + var channel: Channel? + var nextPage: String? + var last = false +} diff --git a/Shared/Channels/ChannelVideosView.swift b/Shared/Channels/ChannelVideosView.swift index 6232fea5..ed207f5b 100644 --- a/Shared/Channels/ChannelVideosView.swift +++ b/Shared/Channels/ChannelVideosView.swift @@ -11,11 +11,12 @@ struct ChannelVideosView: View { @State private var shareURL: URL? @State private var subscriptionToggleButtonDisabled = false + @State private var page: ChannelPage? @State private var contentType = Channel.ContentType.videos @StateObject private var contentTypeItems = Store<[ContentItem]>() @State private var descriptionExpanded = false - @StateObject private var store = Store() + @StateObject private var store = Store() @Environment(\.colorScheme) private var colorScheme @@ -35,14 +36,10 @@ struct ChannelVideosView: View { @Default(.hideShorts) private var hideShorts var presentedChannel: Channel? { - store.item ?? channel ?? recents.presentedChannel + store.item?.channel ?? channel ?? recents.presentedChannel } var contentItems: [ContentItem] { - guard contentType != .videos else { - return ContentItem.array(of: presentedChannel?.videos ?? []) - } - return contentTypeItems.collection } @@ -101,6 +98,7 @@ struct ChannelVideosView: View { banner } } + .environment(\.loadMoreContentHandler) { loadNextPage() } .environment(\.inChannelView, true) .environment(\.listingStyle, channelPlaylistListingStyle) .environment(\.hideShorts, hideShorts) @@ -180,14 +178,10 @@ struct ChannelVideosView: View { store.replace(cache) } - resource?.loadIfNeeded()?.onSuccess { response in - if let channel: Channel = response.typedContent() { - ChannelsCacheModel.shared.store(channel) - } - } + load() } .onChange(of: contentType) { _ in - resource?.load() + load() } #if os(iOS) .navigationBarTitleDisplayMode(.inline) @@ -218,7 +212,7 @@ struct ChannelVideosView: View { } var thumbnail: some View { - ChannelAvatarView(channel: store.item) + ChannelAvatarView(channel: store.item?.channel) #if os(tvOS) .frame(width: 80, height: 80, alignment: .trailing) #else @@ -238,7 +232,7 @@ struct ChannelVideosView: View { var subscriptionsLabel: some View { Group { - if let subscribers = store.item?.subscriptionsString { + if let subscribers = store.item?.channel?.subscriptionsString { HStack(spacing: 0) { Text(subscribers) Image(systemName: "person.2.fill") @@ -257,7 +251,7 @@ struct ChannelVideosView: View { var viewsLabel: some View { HStack(spacing: 0) { - if let views = store.item?.totalViewsString { + if let views = store.item?.channel?.totalViewsString { Text(views) Image(systemName: "eye.fill") @@ -328,7 +322,7 @@ struct ChannelVideosView: View { Picker("Content type", selection: $contentType) { if let channel = presentedChannel { ForEach(Channel.ContentType.allCases, id: \.self) { type in - if channel.hasData(for: type) { + if type == .videos || type == .playlists || channel.hasData(for: type) { Label(type.description, systemImage: type.systemImage).tag(type) } } @@ -341,11 +335,11 @@ struct ChannelVideosView: View { let data = contentType != .videos ? channel.tabs.first(where: { $0.contentType == contentType })?.data : nil let resource = accounts.api.channel(channel.id, contentType: contentType, data: data) + if contentType == .videos { resource.addObserver(store) - } else { - resource.addObserver(contentTypeItems) } + resource.addObserver(contentTypeItems) return resource } @@ -424,6 +418,42 @@ struct ChannelVideosView: View { Label("Mark channel feed as unwatched", systemImage: "checkmark.circle") } } + + func load() { + resource?.load().onSuccess { response in + if let page: ChannelPage = response.typedContent() { + if let channel = page.channel { + ChannelsCacheModel.shared.store(channel) + } + self.page = page + self.contentTypeItems.replace(page.results) + } + } + .onFailure { error in + navigation.presentAlert(title: "Could not load channel data", message: error.userMessage) + } + } + + func loadNextPage() { + guard let channel = presentedChannel, let pageToLoad = page, !pageToLoad.last else { + return + } + + var next = pageToLoad.nextPage + if contentType == .videos, !pageToLoad.last { + next = next ?? "" + } + + let data = contentType != .videos ? channel.tabs.first(where: { $0.contentType == contentType })?.data : nil + accounts.api.channel(channel.id, contentType: contentType, data: data, page: next).load().onSuccess { response in + if let page: ChannelPage = response.typedContent() { + self.page = page + let keys = self.contentTypeItems.collection.map(\.cacheKey) + let items = self.contentTypeItems.collection + page.results.filter { !keys.contains($0.cacheKey) } + self.contentTypeItems.replace(items) + } + } + } } struct ChannelVideosView_Previews: PreviewProvider { diff --git a/Shared/Home/FavoriteItemView.swift b/Shared/Home/FavoriteItemView.swift index cf832095..49e6f480 100644 --- a/Shared/Home/FavoriteItemView.swift +++ b/Shared/Home/FavoriteItemView.swift @@ -74,9 +74,10 @@ struct FavoriteItemView: View { case let .channel(_, id, name): var channel = Channel(app: .invidious, id: id, name: name) if let cache = ChannelsCacheModel.shared.retrieve(channel.cacheKey), - !cache.videos.isEmpty + let cacheChannel = cache.channel, + !cacheChannel.videos.isEmpty { - contentItems = ContentItem.array(of: cache.videos) + contentItems = ContentItem.array(of: cacheChannel.videos) } onSuccess = { response in diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index c118e037..25fb765c 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -830,6 +830,9 @@ 37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */; }; 37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */; }; 37C7A1DA267CACF50010EAD6 /* TrendingCountry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3705B17F267B4DFB00704544 /* TrendingCountry.swift */; }; + 37C7B21429ABD9F20013C196 /* ChannelPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7B21329ABD9F20013C196 /* ChannelPage.swift */; }; + 37C7B21529ABD9F20013C196 /* ChannelPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7B21329ABD9F20013C196 /* ChannelPage.swift */; }; + 37C7B21629ABD9F20013C196 /* ChannelPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7B21329ABD9F20013C196 /* ChannelPage.swift */; }; 37C89322294532220032AFD3 /* PlayerOverlayModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C89321294532220032AFD3 /* PlayerOverlayModifier.swift */; }; 37C89323294532220032AFD3 /* PlayerOverlayModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C89321294532220032AFD3 /* PlayerOverlayModifier.swift */; }; 37C89324294532220032AFD3 /* PlayerOverlayModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C89321294532220032AFD3 /* PlayerOverlayModifier.swift */; }; @@ -1432,6 +1435,7 @@ 37C3A24C272360470087A57A /* ChannelPlaylist+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChannelPlaylist+Fixtures.swift"; sourceTree = ""; }; 37C3A250272366440087A57A /* ChannelPlaylistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelPlaylistView.swift; sourceTree = ""; }; 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockSegment.swift; sourceTree = ""; }; + 37C7B21329ABD9F20013C196 /* ChannelPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelPage.swift; sourceTree = ""; }; 37C89321294532220032AFD3 /* PlayerOverlayModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerOverlayModifier.swift; sourceTree = ""; }; 37C8E700294FC97D00EEAB14 /* QueueView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QueueView.swift; sourceTree = ""; }; 37CC3F44270CE30600608308 /* PlayerQueueItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueItem.swift; sourceTree = ""; }; @@ -2380,6 +2384,7 @@ 377F9F79294403DC0043F856 /* Cache */, 3776ADD5287381240078EBC4 /* Captions.swift */, 37AAF28F26740715007FC770 /* Channel.swift */, + 37C7B21329ABD9F20013C196 /* ChannelPage.swift */, 37C3A24427235DA70087A57A /* ChannelPlaylist.swift */, 37520698285E8DD300CA655F /* Chapter.swift */, 371B7E5B27596B8400D21217 /* Comment.swift */, @@ -3237,6 +3242,7 @@ 37484C3126FCB8F900287258 /* AccountValidator.swift in Sources */, 377ABC48286E5887009C986F /* Sequence+Unique.swift in Sources */, 37520699285E8DD300CA655F /* Chapter.swift in Sources */, + 37C7B21429ABD9F20013C196 /* ChannelPage.swift in Sources */, 37319F0527103F94004ECCD0 /* PlayerQueue.swift in Sources */, 376B2E0726F920D600B1D64D /* SignInRequiredView.swift in Sources */, 37C0697E2725C8D400F7F6CB /* CMTime+DefaultTimescale.swift in Sources */, @@ -3462,6 +3468,7 @@ 37A362BB2953707F00BDF328 /* ClearQueueButton.swift in Sources */, 3752069E285E910600CA655F /* ChapterView.swift in Sources */, 37030FF827B0347C00ECDDAA /* MPVPlayerView.swift in Sources */, + 37C7B21529ABD9F20013C196 /* ChannelPage.swift in Sources */, 378E50FC26FE8B9F00F49626 /* Instance.swift in Sources */, 37169AA32729D98A0011DE61 /* InstancesBridge.swift in Sources */, 37B044B826F7AB9000E1419D /* SettingsView.swift in Sources */, @@ -3882,6 +3889,7 @@ 378E50FD26FE8B9F00F49626 /* Instance.swift in Sources */, 37169AA82729E2CC0011DE61 /* AccountsBridge.swift in Sources */, 37C8E703294FC97D00EEAB14 /* QueueView.swift in Sources */, + 37C7B21629ABD9F20013C196 /* ChannelPage.swift in Sources */, 3754B01728B7F84D009717C8 /* Constants.swift in Sources */, 37BC50AE2778BCBA00510953 /* HistoryModel.swift in Sources */, 37D526E02720AC4400ED2F5E /* VideosAPI.swift in Sources */,