Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,5 @@ Thumbs.db
.env
.env.local
*.pem

TEMP_*
21 changes: 20 additions & 1 deletion Core/Models/FavoriteItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ struct FavoriteItem: Identifiable, Codable, Sendable {
case album(Album)
case playlist(Playlist)
case artist(Artist)
case podcastShow(PodcastShow)
}

/// Creates a new FavoriteItem with current timestamp.
Expand Down Expand Up @@ -52,6 +53,11 @@ struct FavoriteItem: Identifiable, Codable, Sendable {
FavoriteItem(itemType: .artist(artist))
}

/// Creates a FavoriteItem from a PodcastShow.
static func from(_ podcastShow: PodcastShow) -> FavoriteItem {
FavoriteItem(itemType: .podcastShow(podcastShow))
}

// MARK: - Display Properties

/// Display title for the item.
Expand All @@ -65,6 +71,8 @@ struct FavoriteItem: Identifiable, Codable, Sendable {
playlist.title
case let .artist(artist):
artist.name
case let .podcastShow(show):
show.title
}
}

Expand All @@ -79,6 +87,8 @@ struct FavoriteItem: Identifiable, Codable, Sendable {
playlist.author ?? playlist.trackCountDisplay
case .artist:
"Artist"
case let .podcastShow(show):
show.author ?? "Podcast"
}
}

Expand All @@ -93,6 +103,8 @@ struct FavoriteItem: Identifiable, Codable, Sendable {
playlist.thumbnailURL
case let .artist(artist):
artist.thumbnailURL
case let .podcastShow(show):
show.thumbnailURL
}
}

Expand All @@ -108,6 +120,8 @@ struct FavoriteItem: Identifiable, Codable, Sendable {
playlist.id
case let .artist(artist):
artist.id
case let .podcastShow(show):
show.id
}
}

Expand All @@ -122,6 +136,8 @@ struct FavoriteItem: Identifiable, Codable, Sendable {
"Playlist"
case .artist:
"Artist"
case .podcastShow:
"Podcast"
}
}
}
Expand All @@ -142,7 +158,8 @@ extension FavoriteItem: Hashable {

extension FavoriteItem {
/// Converts the FavoriteItem to a HomeSectionItem for display.
var asHomeSectionItem: HomeSectionItem {
/// Returns nil for podcast shows since HomeSectionItem doesn't support podcasts.
var asHomeSectionItem: HomeSectionItem? {
switch self.itemType {
case let .song(song):
.song(song)
Expand All @@ -152,6 +169,8 @@ extension FavoriteItem {
.playlist(playlist)
case let .artist(artist):
.artist(artist)
case .podcastShow:
nil
}
}
}
116 changes: 116 additions & 0 deletions Core/Models/Podcast.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import Foundation

// MARK: - PodcastShow

/// Represents a podcast show from YouTube Music.
struct PodcastShow: Identifiable, Hashable, Codable, Sendable {
let id: String // browseId (MPSPP...)
let title: String
let author: String?
let description: String?
let thumbnailURL: URL?
let episodeCount: Int?

/// Whether the show has a valid browse ID for navigation.
/// Podcast show IDs start with "MPSPP" prefix.
var hasNavigableId: Bool {
self.id.hasPrefix("MPSPP")
}
}

// MARK: - PodcastEpisode

/// Represents a podcast episode from YouTube Music.
struct PodcastEpisode: Identifiable, Hashable, Sendable {
let id: String // videoId
let title: String
let showTitle: String? // secondTitle - the podcast show name
let showBrowseId: String? // for navigation back to show
let description: String?
let thumbnailURL: URL?
let publishedDate: String? // "3d ago", "Dec 28, 2025"
let duration: String? // "36 min", "1:11:19"
let durationSeconds: Int? // for progress calculation
let playbackProgress: Double // 0.0-1.0
let isPlayed: Bool

/// Formats duration as HH:MM:SS or MM:SS based on durationSeconds.
/// Falls back to the API-provided duration string if seconds unavailable.
var formattedDuration: String? {
if let seconds = durationSeconds {
let hours = seconds / 3600
let minutes = (seconds % 3600) / 60
let secs = seconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, secs)
} else {
return String(format: "%d:%02d", minutes, secs)
}
}
return self.duration
}
}

// MARK: - PodcastSection

/// Represents a section of podcast content on the discovery page.
struct PodcastSection: Identifiable, Sendable {
let id: String
let title: String
let items: [PodcastSectionItem]
}

// MARK: - PodcastSectionItem

/// An item within a podcast section - either a show or an episode.
enum PodcastSectionItem: Sendable, Identifiable {
case show(PodcastShow)
case episode(PodcastEpisode)

var id: String {
switch self {
case let .show(show):
show.id
case let .episode(episode):
episode.id
}
}
}

// MARK: Hashable

extension PodcastSectionItem: Hashable {
static func == (lhs: PodcastSectionItem, rhs: PodcastSectionItem) -> Bool {
lhs.id == rhs.id
}

func hash(into hasher: inout Hasher) {
hasher.combine(self.id)
}
}

// MARK: - PodcastShowDetail

/// Detailed information about a podcast show including its episodes.
struct PodcastShowDetail: Sendable {
let show: PodcastShow
let episodes: [PodcastEpisode]
let continuationToken: String?
let isSubscribed: Bool

var hasMore: Bool {
self.continuationToken != nil
}
}

// MARK: - PodcastEpisodesContinuation

/// Response from fetching more podcast episodes via continuation.
struct PodcastEpisodesContinuation: Sendable {
let episodes: [PodcastEpisode]
let continuationToken: String?

var hasMore: Bool {
self.continuationToken != nil
}
}
38 changes: 35 additions & 3 deletions Core/Models/SearchResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ struct SearchResponse: Sendable {
let albums: [Album]
let artists: [Artist]
let playlists: [Playlist]
let podcastShows: [PodcastShow]
/// Continuation token for loading more results (only present for filtered searches).
let continuationToken: String?

Expand All @@ -18,36 +19,56 @@ struct SearchResponse: Sendable {
items.append(contentsOf: self.albums.map { .album($0) })
items.append(contentsOf: self.artists.map { .artist($0) })
items.append(contentsOf: self.playlists.map { .playlist($0) })
items.append(contentsOf: self.podcastShows.map { .podcastShow($0) })
return items
}

/// Whether the search returned any results.
var isEmpty: Bool {
self.songs.isEmpty && self.albums.isEmpty && self.artists.isEmpty && self.playlists.isEmpty
self.songs.isEmpty && self.albums.isEmpty && self.artists.isEmpty && self.playlists.isEmpty && self.podcastShows.isEmpty
}

/// Whether more results are available to load.
var hasMore: Bool {
self.continuationToken != nil
}

static let empty = SearchResponse(songs: [], albums: [], artists: [], playlists: [], continuationToken: nil)
static let empty = SearchResponse(songs: [], albums: [], artists: [], playlists: [], podcastShows: [], continuationToken: nil)

/// Creates a SearchResponse without continuation token (backward compatibility).
init(songs: [Song], albums: [Album], artists: [Artist], playlists: [Playlist]) {
self.songs = songs
self.albums = albums
self.artists = artists
self.playlists = playlists
self.podcastShows = []
self.continuationToken = nil
}

/// Creates a SearchResponse with optional continuation token.
/// Creates a SearchResponse with optional continuation token (backward compatibility).
init(songs: [Song], albums: [Album], artists: [Artist], playlists: [Playlist], continuationToken: String?) {
self.songs = songs
self.albums = albums
self.artists = artists
self.playlists = playlists
self.podcastShows = []
self.continuationToken = continuationToken
}

/// Creates a SearchResponse with podcast shows and optional continuation token.
init(
songs: [Song],
albums: [Album],
artists: [Artist],
playlists: [Playlist],
podcastShows: [PodcastShow],
continuationToken: String?
) {
self.songs = songs
self.albums = albums
self.artists = artists
self.playlists = playlists
self.podcastShows = podcastShows
self.continuationToken = continuationToken
}
}
Expand All @@ -60,6 +81,7 @@ enum SearchResultItem: Identifiable, Sendable {
case album(Album)
case artist(Artist)
case playlist(Playlist)
case podcastShow(PodcastShow)

var id: String {
switch self {
Expand All @@ -71,6 +93,8 @@ enum SearchResultItem: Identifiable, Sendable {
"artist-\(artist.id)"
case let .playlist(playlist):
"playlist-\(playlist.id)"
case let .podcastShow(show):
"podcast-\(show.id)"
}
}

Expand All @@ -84,6 +108,8 @@ enum SearchResultItem: Identifiable, Sendable {
artist.name
case let .playlist(playlist):
playlist.title
case let .podcastShow(show):
show.title
}
}

Expand All @@ -105,6 +131,8 @@ enum SearchResultItem: Identifiable, Sendable {
.replacingOccurrences(of: "Playlist • ", with: "")
.trimmingCharacters(in: .whitespaces)
return stripped.isEmpty ? nil : stripped
case let .podcastShow(show):
return show.author
}
}

Expand All @@ -118,6 +146,8 @@ enum SearchResultItem: Identifiable, Sendable {
artist.thumbnailURL
case let .playlist(playlist):
playlist.thumbnailURL
case let .podcastShow(show):
show.thumbnailURL
}
}

Expand All @@ -131,6 +161,8 @@ enum SearchResultItem: Identifiable, Sendable {
"Artist"
case .playlist:
"Playlist"
case .podcastShow:
"Podcast"
}
}

Expand Down
13 changes: 13 additions & 0 deletions Core/Models/YTMusicError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ enum YTMusicError: LocalizedError, Sendable {
case apiError(message: String, code: Int?)
/// Playback error.
case playbackError(message: String)
/// Invalid input provided to an operation.
case invalidInput(String)
/// Unknown error.
case unknown(message: String)

Expand All @@ -36,6 +38,8 @@ enum YTMusicError: LocalizedError, Sendable {
return "API error: \(message)"
case let .playbackError(message):
return "Playback error: \(message)"
case let .invalidInput(message):
return "Invalid input: \(message)"
case let .unknown(message):
return message
}
Expand All @@ -51,6 +55,8 @@ enum YTMusicError: LocalizedError, Sendable {
"Try again. If the problem persists, the service may be temporarily unavailable."
case .playbackError:
"Try playing a different track."
case .invalidInput:
"Please check the input and try again."
case .unknown:
"Try again later."
}
Expand Down Expand Up @@ -89,6 +95,9 @@ enum YTMusicError: LocalizedError, Sendable {
case .playbackError:
// Playback errors might be transient
return true
case .invalidInput:
// Invalid input won't be fixed by retrying
return false
case .unknown:
// Unknown errors might be transient
return true
Expand All @@ -108,6 +117,8 @@ enum YTMusicError: LocalizedError, Sendable {
"Data Error"
case .playbackError:
"Playback Error"
case .invalidInput:
"Invalid Input"
case .unknown:
"Error"
}
Expand All @@ -132,6 +143,8 @@ enum YTMusicError: LocalizedError, Sendable {
"Unable to load content. Please try again."
case .playbackError:
"Unable to play this track. Try a different one."
case let .invalidInput(message):
message
case let .unknown(message):
message
}
Expand Down
Loading
Loading