mirror of
https://github.com/yattee/yattee.git
synced 2026-08-31 19:23:48 +00:00
Compare commits
10 Commits
2.0.0-beta
...
2.0.0-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9306bafbea | ||
|
|
0a7c056595 | ||
|
|
3ebae6d216 | ||
|
|
d46002e99a | ||
|
|
c41016185c | ||
|
|
3d2544b22d | ||
|
|
6c5c9915fe | ||
|
|
11d370b3b8 | ||
|
|
63b625bcde | ||
|
|
f4cf9e8b8d |
35
CHANGELOG.md
35
CHANGELOG.md
@@ -1,29 +1,14 @@
|
|||||||
|
|
||||||
|
|
||||||
## What's Changed
|
## What's Changed
|
||||||
|
|
||||||
### New Features
|
|
||||||
|
|
||||||
* Add embedded audio/subtitle track selection for multi-track files
|
|
||||||
* Add loading external subtitle files for media-source playback
|
|
||||||
* Add view options to playlists list view
|
|
||||||
* Add search to playlists list view
|
|
||||||
* Show and label WebDAV/SMB video streams in the quality selector
|
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
* Fix timed links resuming at watch position instead of URL timestamp
|
* Fix no streams reported from Piped (#974) - thanks @raycheung
|
||||||
* Fix autoplay countdown showing in repeat one queue mode
|
* Fix description timestamp links seeking to wrong position for out-of-range values (#966) - thanks @YuriNachos
|
||||||
* Fix double-tap fullscreen gesture rotating on portrait videos
|
* Fix deep link timestamp parsing accepting infinity as seek position (#965) - thanks @YuriNachos
|
||||||
* Fix sending extracted videos (Twitch streams) to other devices
|
* Fix audio sample rate label dropping the kHz decimal (#964) - thanks @YuriNachos
|
||||||
* Fix missing thumbnails for videos saved to library
|
* Fix playback rate display dropping meaningful digits (#963) - thanks @YuriNachos
|
||||||
* Fix #955: make subtitle appearance settings adjustable on tvOS
|
* Fix www.duckduckgo.com misrouting to yt-dlp external video extraction (#962) - thanks @YuriNachos
|
||||||
* Fix #960: scope subscription counts, import/export to active account
|
|
||||||
* Fix #958: switch MPVKit to yattee fork with AV1 VideoToolbox session recovery
|
### Other
|
||||||
* Fix startup crash in sideloaded builds without iCloud entitlements
|
|
||||||
* Allow screen sleep during audio-only playback
|
* Chunk stateless feed requests to support more than 500 subscriptions (#967) - thanks @pehbehbeh
|
||||||
* Apply thumbnail fallback everywhere a single URL was rendered
|
|
||||||
* Stop tracking resume progress for live streams
|
|
||||||
* Label local-file video quality from mpv track info instead of Unknown
|
|
||||||
* Keep macOS player fullscreen when queue advances to different-aspect video
|
|
||||||
* Guard PiP bridge geometry writes against non-finite rects
|
|
||||||
|
|||||||
@@ -570,7 +570,7 @@
|
|||||||
AUTOMATION_APPLE_EVENTS = NO;
|
AUTOMATION_APPLE_EVENTS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 269;
|
CURRENT_PROJECT_VERSION = 270;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
@@ -656,7 +656,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
|
||||||
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS.entitlements";
|
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 269;
|
CURRENT_PROJECT_VERSION = 270;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
@@ -800,7 +800,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
|
||||||
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS-DeveloperID.entitlements";
|
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS-DeveloperID.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 269;
|
CURRENT_PROJECT_VERSION = 270;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
@@ -881,7 +881,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 269;
|
CURRENT_PROJECT_VERSION = 270;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -910,7 +910,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 269;
|
CURRENT_PROJECT_VERSION = 270;
|
||||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
|
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
|
||||||
@@ -942,7 +942,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
|
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 269;
|
CURRENT_PROJECT_VERSION = 270;
|
||||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
|
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
|
||||||
@@ -977,7 +977,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 269;
|
CURRENT_PROJECT_VERSION = 270;
|
||||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
|
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
|
||||||
@@ -1008,7 +1008,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 269;
|
CURRENT_PROJECT_VERSION = 270;
|
||||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
|
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
|
||||||
@@ -1040,7 +1040,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
|
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 269;
|
CURRENT_PROJECT_VERSION = 270;
|
||||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
|
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
|
||||||
@@ -1074,7 +1074,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
|
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 269;
|
CURRENT_PROJECT_VERSION = 270;
|
||||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
|
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
|
||||||
@@ -1109,7 +1109,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 269;
|
CURRENT_PROJECT_VERSION = 270;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -1138,7 +1138,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 269;
|
CURRENT_PROJECT_VERSION = 270;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
|||||||
17
Yattee/Extensions/Array+Chunked.swift
Normal file
17
Yattee/Extensions/Array+Chunked.swift
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// Array+Chunked.swift
|
||||||
|
// Yattee
|
||||||
|
//
|
||||||
|
// Splitting arrays into fixed-size chunks.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Array {
|
||||||
|
/// Splits the array into chunks of the specified size.
|
||||||
|
func chunked(into size: Int) -> [[Element]] {
|
||||||
|
stride(from: 0, to: count, by: size).map {
|
||||||
|
Array(self[$0..<Swift.min($0 + size, count)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,7 +148,24 @@ struct MPVTrack: Equatable, Sendable, Identifiable, Decodable {
|
|||||||
return preferred == baseLanguageCode
|
return preferred == baseLanguageCode
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Secondary detail line for advanced mode, e.g. "eac3 · 6ch · 48 kHz".
|
/// Format a sample rate in Hz as the conventional kHz label.
|
||||||
|
/// 48000 → "48 kHz", 44100 → "44.1 kHz", 88200 → "88.2 kHz",
|
||||||
|
/// 22050 → "22.05 kHz", 176400 → "176.4 kHz".
|
||||||
|
/// Whole-kHz rates drop the decimal; fractional rates keep their
|
||||||
|
/// meaningful digits (1–2 decimals, trailing zero stripped).
|
||||||
|
static func formatSampleRate(_ hz: Int) -> String {
|
||||||
|
let kHz = Double(hz) / 1000.0
|
||||||
|
if kHz == kHz.rounded() {
|
||||||
|
return "\(Int(kHz)) kHz"
|
||||||
|
}
|
||||||
|
var label = String(format: "%.2f", kHz)
|
||||||
|
if label.hasSuffix("0") {
|
||||||
|
label.removeLast()
|
||||||
|
}
|
||||||
|
return "\(label) kHz"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secondary detail line for advanced mode, e.g. "aac · 2ch · 44.1 kHz".
|
||||||
var detailText: String? {
|
var detailText: String? {
|
||||||
var parts: [String] = []
|
var parts: [String] = []
|
||||||
if let codec, !codec.isEmpty {
|
if let codec, !codec.isEmpty {
|
||||||
@@ -158,7 +175,7 @@ struct MPVTrack: Equatable, Sendable, Identifiable, Decodable {
|
|||||||
parts.append("\(channelCount)ch")
|
parts.append("\(channelCount)ch")
|
||||||
}
|
}
|
||||||
if let sampleRate {
|
if let sampleRate {
|
||||||
parts.append("\(sampleRate / 1000) kHz")
|
parts.append(Self.formatSampleRate(sampleRate))
|
||||||
}
|
}
|
||||||
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -672,12 +672,14 @@ private struct PipedVideoStream: Decodable, Sendable {
|
|||||||
resolution = nil
|
resolution = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let audioCodec: String? = (videoOnly ?? true) ? nil : "aac"
|
||||||
|
|
||||||
return Stream(
|
return Stream(
|
||||||
url: streamUrl,
|
url: streamUrl,
|
||||||
resolution: resolution,
|
resolution: resolution,
|
||||||
format: format ?? "unknown",
|
format: format ?? "unknown",
|
||||||
videoCodec: codec,
|
videoCodec: codec,
|
||||||
audioCodec: nil,
|
audioCodec: audioCodec,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
fileSize: contentLength,
|
fileSize: contentLength,
|
||||||
isAudioOnly: false,
|
isAudioOnly: false,
|
||||||
|
|||||||
@@ -349,18 +349,32 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
|
|
||||||
// MARK: - Stateless Feed Endpoints
|
// MARK: - Stateless Feed Endpoints
|
||||||
|
|
||||||
/// Fetches feed using stateless POST endpoint with channel list.
|
/// Maximum number of channels per stateless feed request. The server rejects larger channel
|
||||||
|
/// lists with HTTP 422 (`max_length=500` on its feed and feed-status request models).
|
||||||
|
static let maxChannelsPerFeedRequest = 500
|
||||||
|
|
||||||
|
/// Fetches feed using stateless POST endpoint with channel list. Channel lists above the
|
||||||
|
/// server's per-request limit are sent in chunks and the responses merged into one.
|
||||||
func postFeed(channels: [StatelessChannelRequest], limit: Int, offset: Int, instance: Instance) async throws -> StatelessFeedResponse {
|
func postFeed(channels: [StatelessChannelRequest], limit: Int, offset: Int, instance: Instance) async throws -> StatelessFeedResponse {
|
||||||
let body = StatelessFeedRequest(channels: channels, limit: limit, offset: offset)
|
var responses: [StatelessFeedResponse] = []
|
||||||
let endpoint = GenericEndpoint.post("/api/v1/feed", body: body)
|
for chunk in channels.chunked(into: Self.maxChannelsPerFeedRequest) {
|
||||||
return try await httpClient.fetch(endpoint, baseURL: instance.url)
|
let body = StatelessFeedRequest(channels: chunk, limit: limit, offset: offset)
|
||||||
|
let endpoint = GenericEndpoint.post("/api/v1/feed", body: body)
|
||||||
|
responses.append(try await httpClient.fetch(endpoint, baseURL: instance.url))
|
||||||
|
}
|
||||||
|
return StatelessFeedResponse.merged(responses, limit: limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks feed status for given channels (lightweight polling).
|
/// Checks feed status for given channels (lightweight polling).
|
||||||
|
/// Chunked like `postFeed(channels:limit:offset:instance:)`.
|
||||||
func postFeedStatus(channels: [StatelessChannelStatusRequest], instance: Instance) async throws -> StatelessFeedStatusResponse {
|
func postFeedStatus(channels: [StatelessChannelStatusRequest], instance: Instance) async throws -> StatelessFeedStatusResponse {
|
||||||
let body = StatelessFeedStatusRequest(channels: channels)
|
var responses: [StatelessFeedStatusResponse] = []
|
||||||
let endpoint = GenericEndpoint.post("/api/v1/feed/status", body: body)
|
for chunk in channels.chunked(into: Self.maxChannelsPerFeedRequest) {
|
||||||
return try await httpClient.fetch(endpoint, baseURL: instance.url)
|
let body = StatelessFeedStatusRequest(channels: chunk)
|
||||||
|
let endpoint = GenericEndpoint.post("/api/v1/feed/status", body: body)
|
||||||
|
responses.append(try await httpClient.fetch(endpoint, baseURL: instance.url))
|
||||||
|
}
|
||||||
|
return StatelessFeedStatusResponse.merged(responses)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Channel Metadata
|
// MARK: - Channel Metadata
|
||||||
@@ -497,6 +511,34 @@ struct StatelessFeedResponse: Decodable, Sendable {
|
|||||||
func toVideos() -> [Video] {
|
func toVideos() -> [Video] {
|
||||||
videos.compactMap { $0.toVideo() }
|
videos.compactMap { $0.toVideo() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merges chunked feed responses into a single response. A single response is returned
|
||||||
|
/// unchanged so requests within the server's per-request channel limit behave as before.
|
||||||
|
static func merged(_ responses: [StatelessFeedResponse], limit: Int) -> StatelessFeedResponse {
|
||||||
|
guard responses.count != 1 else { return responses[0] }
|
||||||
|
|
||||||
|
let videos = responses.flatMap(\.videos)
|
||||||
|
.sorted { ($0.published ?? 0) > ($1.published ?? 0) }
|
||||||
|
let limited = Array(videos.prefix(limit))
|
||||||
|
|
||||||
|
return StatelessFeedResponse(
|
||||||
|
status: responses.first(where: { !$0.isReady })?.status ?? "ready",
|
||||||
|
videos: limited,
|
||||||
|
total: responses.reduce(0) { $0 + $1.total },
|
||||||
|
hasMore: responses.contains(where: \.hasMore) || videos.count > limited.count,
|
||||||
|
readyCount: sumIfAnyPresent(responses.map(\.readyCount)),
|
||||||
|
pendingCount: sumIfAnyPresent(responses.map(\.pendingCount)),
|
||||||
|
errorCount: sumIfAnyPresent(responses.map(\.errorCount)),
|
||||||
|
etaSeconds: responses.compactMap(\.etaSeconds).max()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sums optional counts, staying `nil` when no chunk reported a value (older server
|
||||||
|
/// versions omit the count fields entirely).
|
||||||
|
private static func sumIfAnyPresent(_ values: [Int?]) -> Int? {
|
||||||
|
let present = values.compactMap { $0 }
|
||||||
|
return present.isEmpty ? nil : present.reduce(0, +)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response from stateless feed status endpoint.
|
/// Response from stateless feed status endpoint.
|
||||||
@@ -523,6 +565,25 @@ struct StatelessFeedStatusResponse: Decodable, Sendable {
|
|||||||
case pendingCount
|
case pendingCount
|
||||||
case errorCount
|
case errorCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(status: String, readyCount: Int, pendingCount: Int, errorCount: Int) {
|
||||||
|
self.status = status
|
||||||
|
self.readyCount = readyCount
|
||||||
|
self.pendingCount = pendingCount
|
||||||
|
self.errorCount = errorCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges chunked status responses into a single response. A single response is returned unchanged.
|
||||||
|
static func merged(_ responses: [StatelessFeedStatusResponse]) -> StatelessFeedStatusResponse {
|
||||||
|
guard responses.count != 1 else { return responses[0] }
|
||||||
|
|
||||||
|
return StatelessFeedStatusResponse(
|
||||||
|
status: responses.first(where: { !$0.isReady })?.status ?? "ready",
|
||||||
|
readyCount: responses.reduce(0) { $0 + $1.readyCount },
|
||||||
|
pendingCount: responses.reduce(0) { $0 + $1.pendingCount },
|
||||||
|
errorCount: responses.reduce(0) { $0 + $1.errorCount }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Channel Metadata Models
|
// MARK: - Channel Metadata Models
|
||||||
|
|||||||
@@ -225,14 +225,3 @@ final class DeArrowBrandingProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Array Extension
|
|
||||||
|
|
||||||
private extension Array {
|
|
||||||
/// Splits the array into chunks of the specified size.
|
|
||||||
func chunked(into size: Int) -> [[Element]] {
|
|
||||||
stride(from: 0, to: count, by: size).map {
|
|
||||||
Array(self[$0..<Swift.min($0 + size, count)])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ struct URLRouter: Sendable {
|
|||||||
let excludedHosts = [
|
let excludedHosts = [
|
||||||
"google.com", "www.google.com",
|
"google.com", "www.google.com",
|
||||||
"bing.com", "www.bing.com",
|
"bing.com", "www.bing.com",
|
||||||
"duckduckgo.com",
|
"duckduckgo.com", "www.duckduckgo.com",
|
||||||
"apple.com", "www.apple.com",
|
"apple.com", "www.apple.com",
|
||||||
"github.com", "www.github.com"
|
"github.com", "www.github.com"
|
||||||
]
|
]
|
||||||
@@ -145,9 +145,11 @@ struct URLRouter: Sendable {
|
|||||||
let trimmed = raw.trimmingCharacters(in: .whitespaces)
|
let trimmed = raw.trimmingCharacters(in: .whitespaces)
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
|
||||||
// Plain numeric value (e.g. "90", "90.5")
|
// Plain numeric value (e.g. "90", "90.5"). Reject non-finite values
|
||||||
|
// ("inf"/"infinity"/"nan") that `TimeInterval(_:)` accepts — a `?t=inf`
|
||||||
|
// link would otherwise seek the player to `Double.infinity`.
|
||||||
if let value = TimeInterval(trimmed) {
|
if let value = TimeInterval(trimmed) {
|
||||||
return value >= 0 ? value : nil
|
return (value.isFinite && value >= 0) ? value : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compound form like "1h2m3s", "2m30s", "90s"
|
// Compound form like "1h2m3s", "2m30s", "90s"
|
||||||
|
|||||||
@@ -131,13 +131,26 @@ enum PlaybackRate: Double, CaseIterable, Identifiable, Sendable {
|
|||||||
if rawValue == 1.0 {
|
if rawValue == 1.0 {
|
||||||
return String(localized: "player.playbackRate.normal")
|
return String(localized: "player.playbackRate.normal")
|
||||||
}
|
}
|
||||||
return String(format: "%.2gx", rawValue)
|
return formattedRate
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compact display text that always shows numeric value (e.g., "1x", "1.5x").
|
/// Compact display text that always shows numeric value (e.g., "1x", "1.5x").
|
||||||
/// Use this in space-constrained UI like the player pill.
|
/// Use this in space-constrained UI like the player pill.
|
||||||
var compactDisplayText: String {
|
var compactDisplayText: String {
|
||||||
String(format: "%.2gx", rawValue)
|
formattedRate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rate with an `x` suffix rendered with a fixed two-decimal format and
|
||||||
|
/// the trailing `.0` stripped, so whole rates have no decimal
|
||||||
|
/// (2 → "2x", 1.5 → "1.5x", 1.25 → "1.25x"). Replaces `%.2g`, whose
|
||||||
|
/// significant-figure notation dropped meaningful digits (1.25 → "1.2") and
|
||||||
|
/// rounded others (1.75 → "1.8"). `String(format:)` keeps the period decimal
|
||||||
|
/// separator, matching the player's other speed labels.
|
||||||
|
private var formattedRate: String {
|
||||||
|
var value = String(format: "%.2f", rawValue)
|
||||||
|
while value.hasSuffix("0") { value.removeLast() }
|
||||||
|
if value.hasSuffix(".") { value.removeLast() }
|
||||||
|
return "\(value)x"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,12 +56,13 @@ enum DescriptionText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let timestampString = String(text[range])
|
let timestampString = String(text[range])
|
||||||
let seconds = parseTimestamp(timestampString)
|
guard let seconds = parseTimestamp(timestampString),
|
||||||
|
let url = URL(string: "yattee-seek://\(seconds)") else {
|
||||||
if let url = URL(string: "yattee-seek://\(seconds)") {
|
continue
|
||||||
attributedString[attributedRange].link = url
|
|
||||||
attributedString[attributedRange].foregroundColor = linkColor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
attributedString[attributedRange].link = url
|
||||||
|
attributedString[attributedRange].foregroundColor = linkColor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,15 +70,24 @@ enum DescriptionText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parses a timestamp string (MM:SS or H:MM:SS) into total seconds.
|
/// Parses a timestamp string (MM:SS or H:MM:SS) into total seconds.
|
||||||
static func parseTimestamp(_ timestamp: String) -> Int {
|
/// Returns nil when a component is out of its valid range (e.g. `1:99`,
|
||||||
|
/// `0:60`, `1:99:30`); those strings are matched by the link regex above
|
||||||
|
/// but are not valid clock positions, so they must not become seek links.
|
||||||
|
/// This mirrors the range validation in `ChapterParser`.
|
||||||
|
static func parseTimestamp(_ timestamp: String) -> Int? {
|
||||||
let components = timestamp.split(separator: ":").compactMap { Int($0) }
|
let components = timestamp.split(separator: ":").compactMap { Int($0) }
|
||||||
switch components.count {
|
switch components.count {
|
||||||
case 2: // MM:SS
|
case 2: // MM:SS
|
||||||
return components[0] * 60 + components[1]
|
let seconds = components[1]
|
||||||
|
guard seconds < 60 else { return nil }
|
||||||
|
return components[0] * 60 + seconds
|
||||||
case 3: // H:MM:SS
|
case 3: // H:MM:SS
|
||||||
return components[0] * 3600 + components[1] * 60 + components[2]
|
let minutes = components[1]
|
||||||
|
let seconds = components[2]
|
||||||
|
guard minutes < 60, seconds < 60 else { return nil }
|
||||||
|
return components[0] * 3600 + minutes * 60 + seconds
|
||||||
default:
|
default:
|
||||||
return 0
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
67
YatteeTests/DescriptionTextTests.swift
Normal file
67
YatteeTests/DescriptionTextTests.swift
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
//
|
||||||
|
// DescriptionTextTests.swift
|
||||||
|
// YatteeTests
|
||||||
|
//
|
||||||
|
// Unit tests for description-text timestamp parsing.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import Yattee
|
||||||
|
|
||||||
|
@Suite("DescriptionText Timestamp Parsing")
|
||||||
|
struct DescriptionTextTimestampTests {
|
||||||
|
|
||||||
|
// MARK: - Valid Timestamps
|
||||||
|
|
||||||
|
@Test("Parses M:SS format")
|
||||||
|
func parseMSS() {
|
||||||
|
#expect(DescriptionText.parseTimestamp("0:00") == 0)
|
||||||
|
#expect(DescriptionText.parseTimestamp("5:30") == 330)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Parses MM:SS format")
|
||||||
|
func parseMMSS() {
|
||||||
|
#expect(DescriptionText.parseTimestamp("00:00") == 0)
|
||||||
|
#expect(DescriptionText.parseTimestamp("12:45") == 765)
|
||||||
|
#expect(DescriptionText.parseTimestamp("59:59") == 3599)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Parses H:MM:SS format")
|
||||||
|
func parseHMMSS() {
|
||||||
|
#expect(DescriptionText.parseTimestamp("1:23:45") == 5025)
|
||||||
|
#expect(DescriptionText.parseTimestamp("01:23:45") == 5025)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Parses long minutes in MM:SS form")
|
||||||
|
func parseLongMinutes() {
|
||||||
|
// A 99-minute position is valid for a long video (2-digit minutes).
|
||||||
|
#expect(DescriptionText.parseTimestamp("99:59") == 5999)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Out-of-Range Rejection (regression)
|
||||||
|
|
||||||
|
@Test("Rejects seconds >= 60 in MM:SS form")
|
||||||
|
func rejectsInvalidSeconds() {
|
||||||
|
// Previously returned components[0]*60 + components[1] = 60 and 159,
|
||||||
|
// seeking the player to the wrong position.
|
||||||
|
#expect(DescriptionText.parseTimestamp("0:60") == nil)
|
||||||
|
#expect(DescriptionText.parseTimestamp("1:99") == nil)
|
||||||
|
#expect(DescriptionText.parseTimestamp("5:75") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Rejects minutes or seconds >= 60 in H:MM:SS form")
|
||||||
|
func rejectsInvalidHoursMinutesSeconds() {
|
||||||
|
#expect(DescriptionText.parseTimestamp("1:99:30") == nil)
|
||||||
|
#expect(DescriptionText.parseTimestamp("1:30:60") == nil)
|
||||||
|
#expect(DescriptionText.parseTimestamp("2:60:00") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Boundary
|
||||||
|
|
||||||
|
@Test("Accepts the 59 boundary in every field")
|
||||||
|
func acceptsBoundary() {
|
||||||
|
#expect(DescriptionText.parseTimestamp("59:59") == 3599)
|
||||||
|
#expect(DescriptionText.parseTimestamp("1:59:59") == 7199)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
YatteeTests/MPVTrackFormatTests.swift
Normal file
44
YatteeTests/MPVTrackFormatTests.swift
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
//
|
||||||
|
// MPVTrackFormatTests.swift
|
||||||
|
// YatteeTests
|
||||||
|
//
|
||||||
|
// Tests for MPVTrack sample-rate (kHz) label formatting.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import Yattee
|
||||||
|
|
||||||
|
@Suite("MPVTrack sample-rate formatting")
|
||||||
|
struct MPVTrackFormatTests {
|
||||||
|
@Test("Whole-kHz rates have no decimal")
|
||||||
|
func wholeKHz() {
|
||||||
|
#expect(MPVTrack.formatSampleRate(8000) == "8 kHz")
|
||||||
|
#expect(MPVTrack.formatSampleRate(16000) == "16 kHz")
|
||||||
|
#expect(MPVTrack.formatSampleRate(24000) == "24 kHz")
|
||||||
|
#expect(MPVTrack.formatSampleRate(48000) == "48 kHz")
|
||||||
|
#expect(MPVTrack.formatSampleRate(96000) == "96 kHz")
|
||||||
|
#expect(MPVTrack.formatSampleRate(192000) == "192 kHz")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("44.1 kHz family keeps its decimal (the bug)")
|
||||||
|
func fractionalKHz() {
|
||||||
|
#expect(MPVTrack.formatSampleRate(44100) == "44.1 kHz")
|
||||||
|
#expect(MPVTrack.formatSampleRate(88200) == "88.2 kHz")
|
||||||
|
#expect(MPVTrack.formatSampleRate(176400) == "176.4 kHz")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Half-decimal rates keep two digits")
|
||||||
|
func halfDecimal() {
|
||||||
|
#expect(MPVTrack.formatSampleRate(22050) == "22.05 kHz")
|
||||||
|
// 11025/1000 = 11.025; %.2f rounds the nearest double to "11.03" on macOS arm64.
|
||||||
|
#expect(MPVTrack.formatSampleRate(11025) == "11.03 kHz")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("detailText surfaces the formatted sample rate")
|
||||||
|
func detailTextIntegration() {
|
||||||
|
let track = MPVTrack(trackID: 1, type: .audio, codec: "aac",
|
||||||
|
channelCount: 2, sampleRate: 44100)
|
||||||
|
#expect(track.detailText == "aac · 2ch · 44.1 kHz")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,6 +123,20 @@ struct URLRouterTests {
|
|||||||
#expect(URLRouter.parseTimestampValue("90.5") == 90.5)
|
#expect(URLRouter.parseTimestampValue("90.5") == 90.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Reject non-finite and negative plain timestamp values")
|
||||||
|
func rejectInvalidPlainTimestampValues() {
|
||||||
|
// Plain numeric values that are accepted by TimeInterval(_:) but are not
|
||||||
|
// valid seek targets must return nil rather than poisoning the player.
|
||||||
|
#expect(URLRouter.parseTimestampValue("inf") == nil)
|
||||||
|
#expect(URLRouter.parseTimestampValue("infinity") == nil)
|
||||||
|
#expect(URLRouter.parseTimestampValue("nan") == nil)
|
||||||
|
#expect(URLRouter.parseTimestampValue("-5") == nil)
|
||||||
|
// Valid plain seconds still parse.
|
||||||
|
#expect(URLRouter.parseTimestampValue("0") == 0)
|
||||||
|
#expect(URLRouter.parseTimestampValue("90") == 90)
|
||||||
|
#expect(URLRouter.parseTimestampValue("90.5") == 90.5)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Share Extension Wrapper Tests
|
// MARK: - Share Extension Wrapper Tests
|
||||||
|
|
||||||
@Test("Unwrap yattee://open wrapper to inner URL with timestamp")
|
@Test("Unwrap yattee://open wrapper to inner URL with timestamp")
|
||||||
@@ -273,6 +287,26 @@ struct URLRouterTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - External Host Exclusion Tests
|
||||||
|
|
||||||
|
@Test("www.duckduckgo.com is excluded from external video routing")
|
||||||
|
func wwwDuckDuckGoExcluded() {
|
||||||
|
let url = URL(string: "https://www.duckduckgo.com/?q=cat+videos")!
|
||||||
|
let destination = router.route(url)
|
||||||
|
if case .externalVideo = destination {
|
||||||
|
Issue.record("www.duckduckgo.com must not route to externalVideo; yt-dlp extraction should not run for a search engine")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("duckduckgo.com bare host remains excluded from external video")
|
||||||
|
func duckDuckGoBareExcluded() {
|
||||||
|
let url = URL(string: "https://duckduckgo.com/?q=test")!
|
||||||
|
let destination = router.route(url)
|
||||||
|
if case .externalVideo = destination {
|
||||||
|
Issue.record("duckduckgo.com must not route to externalVideo; yt-dlp extraction should not run for a search engine")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - YouTube Channel URL Tests
|
// MARK: - YouTube Channel URL Tests
|
||||||
|
|
||||||
@Test("Parse YouTube channel URL")
|
@Test("Parse YouTube channel URL")
|
||||||
|
|||||||
51
YatteeTests/PlaybackRateTests.swift
Normal file
51
YatteeTests/PlaybackRateTests.swift
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
//
|
||||||
|
// PlaybackRateTests.swift
|
||||||
|
// YatteeTests
|
||||||
|
//
|
||||||
|
// Tests for PlaybackRate display-text formatting.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import Yattee
|
||||||
|
|
||||||
|
@Suite("PlaybackRate Display Tests")
|
||||||
|
struct PlaybackRateDisplayTests {
|
||||||
|
@Test("displayText renders every rate without losing precision")
|
||||||
|
func displayTextPrecision() {
|
||||||
|
#expect(PlaybackRate.x025.displayText == "0.25x")
|
||||||
|
#expect(PlaybackRate.x05.displayText == "0.5x")
|
||||||
|
#expect(PlaybackRate.x075.displayText == "0.75x")
|
||||||
|
#expect(PlaybackRate.x125.displayText == "1.25x")
|
||||||
|
#expect(PlaybackRate.x15.displayText == "1.5x")
|
||||||
|
#expect(PlaybackRate.x175.displayText == "1.75x")
|
||||||
|
#expect(PlaybackRate.x2.displayText == "2x")
|
||||||
|
#expect(PlaybackRate.x25.displayText == "2.5x")
|
||||||
|
#expect(PlaybackRate.x3.displayText == "3x")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("displayText for the normal rate uses the localized label, not the numeric form")
|
||||||
|
func displayTextNormal() {
|
||||||
|
let normal = PlaybackRate.x1.displayText
|
||||||
|
#expect(normal == String(localized: "player.playbackRate.normal"))
|
||||||
|
#expect(!normal.hasSuffix("x"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("compactDisplayText always shows the numeric value")
|
||||||
|
func compactDisplayText() {
|
||||||
|
#expect(PlaybackRate.x1.compactDisplayText == "1x")
|
||||||
|
#expect(PlaybackRate.x125.compactDisplayText == "1.25x")
|
||||||
|
#expect(PlaybackRate.x15.compactDisplayText == "1.5x")
|
||||||
|
#expect(PlaybackRate.x175.compactDisplayText == "1.75x")
|
||||||
|
#expect(PlaybackRate.x2.compactDisplayText == "2x")
|
||||||
|
#expect(PlaybackRate.x3.compactDisplayText == "3x")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("every case has a non-empty display string")
|
||||||
|
func allCasesNonEmpty() {
|
||||||
|
for rate in PlaybackRate.allCases {
|
||||||
|
#expect(!rate.displayText.isEmpty)
|
||||||
|
#expect(!rate.compactDisplayText.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
153
YatteeTests/YatteeServerFeedChunkingTests.swift
Normal file
153
YatteeTests/YatteeServerFeedChunkingTests.swift
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
//
|
||||||
|
// YatteeServerFeedChunkingTests.swift
|
||||||
|
// YatteeTests
|
||||||
|
//
|
||||||
|
// Tests for chunking and merging of stateless feed requests against Yattee Server,
|
||||||
|
// which rejects more than 500 channels per request.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import Yattee
|
||||||
|
|
||||||
|
@Suite("Yattee Server Feed Chunking Tests")
|
||||||
|
struct YatteeServerFeedChunkingTests {
|
||||||
|
|
||||||
|
private func video(id: String, published: Int64?) -> ServerFeedVideo {
|
||||||
|
ServerFeedVideo(
|
||||||
|
type: "video",
|
||||||
|
videoId: id,
|
||||||
|
title: "Video \(id)",
|
||||||
|
author: "Author",
|
||||||
|
authorId: "UC\(id)",
|
||||||
|
lengthSeconds: 60,
|
||||||
|
published: published,
|
||||||
|
publishedText: nil,
|
||||||
|
viewCount: nil,
|
||||||
|
videoThumbnails: nil,
|
||||||
|
extractor: "youtube",
|
||||||
|
videoUrl: nil,
|
||||||
|
isUpcoming: nil,
|
||||||
|
premiereTimestamp: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chunking
|
||||||
|
|
||||||
|
@Test("Chunked splits arrays at the requested size")
|
||||||
|
func chunkedSplitsAtSize() {
|
||||||
|
let channels = Array(0..<1018)
|
||||||
|
let chunks = channels.chunked(into: 500)
|
||||||
|
#expect(chunks.map(\.count) == [500, 500, 18])
|
||||||
|
#expect(chunks.flatMap { $0 } == channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Chunked keeps lists within the size as a single chunk")
|
||||||
|
func chunkedSingleChunk() {
|
||||||
|
#expect(Array(0..<500).chunked(into: 500).count == 1)
|
||||||
|
#expect(Array(0..<3).chunked(into: 500).map(\.count) == [3])
|
||||||
|
#expect([Int]().chunked(into: 500).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Feed Response Merging
|
||||||
|
|
||||||
|
@Test("Merging a single feed response returns it unchanged")
|
||||||
|
func mergedSingleFeedResponsePassthrough() {
|
||||||
|
// Deliberately unsorted: a single response must not be re-sorted or truncated.
|
||||||
|
let response = StatelessFeedResponse(
|
||||||
|
status: "pending",
|
||||||
|
videos: [video(id: "a", published: 100), video(id: "b", published: 300)],
|
||||||
|
total: 2,
|
||||||
|
hasMore: true,
|
||||||
|
readyCount: 1,
|
||||||
|
pendingCount: 2,
|
||||||
|
errorCount: nil,
|
||||||
|
etaSeconds: 30
|
||||||
|
)
|
||||||
|
let merged = StatelessFeedResponse.merged([response], limit: 1)
|
||||||
|
#expect(merged.videos.map(\.videoId) == ["a", "b"])
|
||||||
|
#expect(merged.status == "pending")
|
||||||
|
#expect(merged.total == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Merging feed responses sorts videos by published date and applies the limit")
|
||||||
|
func mergedFeedResponsesSortsAndLimits() {
|
||||||
|
let first = StatelessFeedResponse(
|
||||||
|
status: "ready",
|
||||||
|
videos: [video(id: "old", published: 100), video(id: "newest", published: 900)],
|
||||||
|
total: 2,
|
||||||
|
hasMore: false,
|
||||||
|
readyCount: 500,
|
||||||
|
pendingCount: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
etaSeconds: nil
|
||||||
|
)
|
||||||
|
let second = StatelessFeedResponse(
|
||||||
|
status: "ready",
|
||||||
|
videos: [video(id: "middle", published: 500), video(id: "undated", published: nil)],
|
||||||
|
total: 2,
|
||||||
|
hasMore: false,
|
||||||
|
readyCount: 18,
|
||||||
|
pendingCount: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
etaSeconds: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let merged = StatelessFeedResponse.merged([first, second], limit: 3)
|
||||||
|
#expect(merged.videos.map(\.videoId) == ["newest", "middle", "old"])
|
||||||
|
#expect(merged.total == 4)
|
||||||
|
#expect(merged.hasMore) // truncated from 4 to 3
|
||||||
|
#expect(merged.readyCount == 518)
|
||||||
|
#expect(merged.status == "ready")
|
||||||
|
#expect(merged.isReady)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Merged feed response is pending while any chunk is pending")
|
||||||
|
func mergedFeedResponsePendingWhileAnyChunkPending() {
|
||||||
|
let ready = StatelessFeedResponse(
|
||||||
|
status: "ready", videos: [], total: 0, hasMore: false,
|
||||||
|
readyCount: 500, pendingCount: 0, errorCount: 0, etaSeconds: nil
|
||||||
|
)
|
||||||
|
let pending = StatelessFeedResponse(
|
||||||
|
status: "pending", videos: [], total: 0, hasMore: false,
|
||||||
|
readyCount: 10, pendingCount: 8, errorCount: 0, etaSeconds: 45
|
||||||
|
)
|
||||||
|
|
||||||
|
let merged = StatelessFeedResponse.merged([ready, pending], limit: 100)
|
||||||
|
#expect(merged.status == "pending")
|
||||||
|
#expect(!merged.isReady)
|
||||||
|
#expect(merged.pendingCount == 8)
|
||||||
|
#expect(merged.etaSeconds == 45)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Merged feed response keeps counts nil when no chunk reports them")
|
||||||
|
func mergedFeedResponseKeepsNilCounts() {
|
||||||
|
let first = StatelessFeedResponse(
|
||||||
|
status: "ready", videos: [], total: 0, hasMore: false,
|
||||||
|
readyCount: nil, pendingCount: nil, errorCount: nil, etaSeconds: nil
|
||||||
|
)
|
||||||
|
let merged = StatelessFeedResponse.merged([first, first], limit: 100)
|
||||||
|
#expect(merged.readyCount == nil)
|
||||||
|
#expect(merged.pendingCount == nil)
|
||||||
|
#expect(merged.errorCount == nil)
|
||||||
|
#expect(merged.etaSeconds == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Feed Status Merging
|
||||||
|
|
||||||
|
@Test("Merging status responses sums counts and stays pending until all chunks are ready")
|
||||||
|
func mergedStatusResponses() {
|
||||||
|
let ready = StatelessFeedStatusResponse(status: "ready", readyCount: 500, pendingCount: 0, errorCount: 1)
|
||||||
|
let pending = StatelessFeedStatusResponse(status: "pending", readyCount: 10, pendingCount: 8, errorCount: 0)
|
||||||
|
|
||||||
|
let merged = StatelessFeedStatusResponse.merged([ready, pending])
|
||||||
|
#expect(merged.status == "pending")
|
||||||
|
#expect(merged.readyCount == 510)
|
||||||
|
#expect(merged.pendingCount == 8)
|
||||||
|
#expect(merged.errorCount == 1)
|
||||||
|
|
||||||
|
let allReady = StatelessFeedStatusResponse.merged([ready, ready])
|
||||||
|
#expect(allReady.isReady)
|
||||||
|
#expect(allReady.readyCount == 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,15 @@
|
|||||||
"category": "entertainment",
|
"category": "entertainment",
|
||||||
"screenshots": [],
|
"screenshots": [],
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"buildVersion": "269",
|
||||||
|
"date": "2026-08-03T21:54:39+00:00",
|
||||||
|
"localizedDescription": "",
|
||||||
|
"downloadURL": "https://github.com/yattee/yattee/releases/download/2.0.0-beta.269/Yattee-2.0.0-iOS.ipa",
|
||||||
|
"size": 34551605,
|
||||||
|
"minOSVersion": "18.0"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"buildVersion": "268",
|
"buildVersion": "268",
|
||||||
|
|||||||
Reference in New Issue
Block a user