Files
yattee/Yattee/Models/Channel.swift
Arkadiusz Fal e51ebd7ab2 Fix feed channel filter avatars showing placeholders instead of images
The filter strip was passing the Invidious instance URL as serverURL to
AvatarURLBuilder, which built a Yattee Server-style /avatar/ path that
doesn't exist on Invidious. Now passes the actual Yattee Server URL
(matching SubscriptionsView pattern) and enriches channels from
CachedChannelData as a fallback when the API doesn't return thumbnails.
2026-02-13 07:04:40 +01:00

130 lines
3.8 KiB
Swift

//
// Channel.swift
// Yattee
//
// Represents a video channel/author.
//
@preconcurrency import Foundation
/// Represents a channel from any content source.
struct Channel: Identifiable, Codable, Hashable, Sendable {
/// Unique identifier for this channel.
let id: ChannelID
/// The channel name.
let name: String
/// Channel description/about text.
let description: String?
/// Subscriber count if available.
let subscriberCount: Int?
/// Total video count if available.
let videoCount: Int?
/// Channel thumbnail/avatar URL.
let thumbnailURL: URL?
/// Channel banner image URL.
let bannerURL: URL?
/// Whether the channel is verified.
let isVerified: Bool
// MARK: - Computed Properties
var formattedSubscriberCount: String? {
guard let subscriberCount else { return nil }
return CountFormatter.compact(subscriberCount)
}
// MARK: - Initialization
init(
id: ChannelID,
name: String,
description: String? = nil,
subscriberCount: Int? = nil,
videoCount: Int? = nil,
thumbnailURL: URL? = nil,
bannerURL: URL? = nil,
isVerified: Bool = false
) {
self.id = id
self.name = name
self.description = description
self.subscriberCount = subscriberCount
self.videoCount = videoCount
self.thumbnailURL = thumbnailURL
self.bannerURL = bannerURL
self.isVerified = isVerified
}
}
// MARK: - Channel ID
/// Unique identifier for a channel, combining source and channel ID.
struct ChannelID: Codable, Hashable, Sendable {
/// The content source.
let source: ContentSource
/// The channel ID within that source.
let channelID: String
init(source: ContentSource, channelID: String) {
self.source = source
self.channelID = channelID
}
/// Creates a global channel ID (e.g., YouTube).
static func global(_ channelID: String, provider: String = ContentSource.youtubeProvider) -> ChannelID {
ChannelID(source: .global(provider: provider), channelID: channelID)
}
/// Creates a federated channel ID (e.g., PeerTube).
static func federated(_ channelID: String, provider: String = ContentSource.peertubeProvider, instance: URL) -> ChannelID {
ChannelID(source: .federated(provider: provider, instance: instance), channelID: channelID)
}
/// Creates an extracted channel ID for sites supported by yt-dlp.
static func extracted(_ channelID: String, extractor: String, originalURL: URL) -> ChannelID {
ChannelID(source: .extracted(extractor: extractor, originalURL: originalURL), channelID: channelID)
}
}
extension Channel {
/// Returns a copy with `thumbnailURL` filled from cached channel data if currently nil.
@MainActor
func enrichedThumbnail(using dataManager: DataManager) -> Channel {
guard thumbnailURL == nil else { return self }
guard let cached = CachedChannelData.load(for: id.channelID, using: dataManager) else {
return self
}
return Channel(
id: id,
name: name,
description: description,
subscriberCount: subscriberCount ?? cached.subscriberCount,
videoCount: videoCount,
thumbnailURL: cached.thumbnailURL,
bannerURL: bannerURL ?? cached.bannerURL,
isVerified: isVerified
)
}
}
extension ChannelID: Identifiable {
var id: String {
switch source {
case .global(let provider):
return "global:\(provider):\(channelID)"
case .federated(let provider, let instance):
return "federated:\(provider):\(instance.host ?? ""):\(channelID)"
case .extracted(let extractor, _):
return "extracted:\(extractor):\(channelID)"
}
}
}