Compare commits

..

10 Commits

Author SHA1 Message Date
github-actions[bot]
9306bafbea Bump build number to 270 2026-08-23 11:04:21 +00:00
Arkadiusz Fal
0a7c056595 Update CHANGELOG 2026-08-23 12:46:11 +02:00
Ray Cheung
3ebae6d216 Fix no streams reported from Piped (#974)
Piped currently is having an issue it would only return a muxed 360p
stream, and it is rejected here because the audioCodec being set to nil.
2026-08-23 12:22:10 +02:00
Phil-Bastian Berndt
d46002e99a Chunk stateless feed requests to support more than 500 subscriptions (#967) 2026-08-23 12:22:07 +02:00
Yuri Chukhlib
c41016185c Fix description timestamp links seeking to wrong position for out-of-range values (#966)
DescriptionText.parseTimestamp turned a matched timestamp string into a
clickable seek link by computing seconds from its colon-separated parts
with no range validation. The link regex (\d{1,2}:\d{2}(?::\d{2})?)
matches strings that are not valid clock positions, such as 1:99 or
0:60, and parseTimestamp happily computed 1*60+99 = 159 for 1:99 — so
tapping such a link seeked the player to 2:39 instead of being ignored.

This also diverged from ChapterParser, which rejects seconds/minutes
>= 60; the same timestamp string could be dropped as a chapter but
still seek as a description link.

- Validate seconds < 60 in the MM:SS branch and minutes < 60, seconds
  < 60 in the H:MM:SS branch; return nil otherwise
- Change parseTimestamp to return Int? and skip building the seek link
  for nil, so out-of-range matches are left as plain text
- Add DescriptionTextTests with valid, boundary, and out-of-range cases
2026-08-23 12:22:03 +02:00
Yuri Chukhlib
3d2544b22d Fix deep link timestamp parsing accepting infinity as seek position (#965)
URLRouter.parseTimestampValue used TimeInterval(_:) for the plain-numeric
branch ("90", "90.5"). That initializer also accepts the special tokens
"inf"/"infinity" and "nan", and the value infinity compares as >= 0, so a
deep link or share URL carrying ?t=inf parsed to Double.infinity and was
forwarded to the player as a seek target. Seeking to infinity is undefined
and breaks playback startup for the affected link.

Reject non-finite values explicitly alongside the existing negative
check, so only finite non-negative seconds are accepted.

- Add isFinite guard to the plain-numeric branch of parseTimestampValue
- Cover inf/infinity/nan/negative rejection in URLRouterTests
2026-08-23 12:22:00 +02:00
Yuri Chukhlib
6c5c9915fe Fix audio sample rate label dropping the kHz decimal (#964)
MPVTrack.detailText built its sample-rate label with integer division
(`sampleRate / 1000`), which truncates the fractional kHz. The
second-most-common audio rate — 44100 Hz (CD quality, music videos) —
showed as "44 kHz", a label that denotes 44000 Hz (a different rate),
instead of the conventional "44.1 kHz" used by VLC, mpv and every DAW.
The same defect hit 88200 -> "88 kHz", 176400 -> "176 kHz" and
22050 -> "22 kHz". Whole-kHz rates (48000, 96000, ...) were already
correct and stay unchanged. The label is live in the quality selector's
advanced details, where EmbeddedTrackRowView renders track.detailText.

- Factor the formatting into MPVTrack.formatSampleRate(_:), a pure
  static helper: divide as Double, drop the decimal for whole kHz,
  otherwise keep 1-2 meaningful digits with the trailing zero stripped
  (44.1, 88.2, 22.05).
- detailText now appends Self.formatSampleRate(sampleRate); its structure
  (codec / channelCount parts, separator, nil-when-empty) is unchanged.
- Add YatteeTests/MPVTrackFormatTests covering whole-kHz rates, the
  44.1 kHz family (the bug), half-decimal rates and the detailText
  integration.
2026-08-23 12:21:57 +02:00
Yuri Chukhlib
11d370b3b8 Fix playback rate display dropping meaningful digits (#963)
PlaybackRate.displayText/compactDisplayText used String(format: "%.2gx"), whose significant-figure notation dropped meaningful digits (1.25 -> "1.2x") and rounded others (1.75 -> "1.8x"). Replace with a fixed two-decimal format + trailing-zero strip so whole rates have no decimal (2 -> "2x"), halves show one (1.5 -> "1.5x"), and quarter-steps are preserved (1.25 -> "1.25x"). Adds a Swift Testing suite (PlaybackRateTests) proving red->green.
2026-08-23 12:21:54 +02:00
Yuri Chukhlib
63b625bcde Fix www.duckduckgo.com misrouting to yt-dlp external video extraction (#962) 2026-08-23 12:21:50 +02:00
github-actions[bot]
f4cf9e8b8d Update AltStore source for 2.0.0 (269) 2026-08-03 21:54:39 +00:00
16 changed files with 526 additions and 72 deletions

View File

@@ -1,29 +1,14 @@
## 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
* Fix timed links resuming at watch position instead of URL timestamp
* Fix autoplay countdown showing in repeat one queue mode
* Fix double-tap fullscreen gesture rotating on portrait videos
* Fix sending extracted videos (Twitch streams) to other devices
* Fix missing thumbnails for videos saved to library
* Fix #955: make subtitle appearance settings adjustable on tvOS
* Fix #960: scope subscription counts, import/export to active account
* Fix #958: switch MPVKit to yattee fork with AV1 VideoToolbox session recovery
* Fix startup crash in sideloaded builds without iCloud entitlements
* Allow screen sleep during audio-only playback
* 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
* Fix no streams reported from Piped (#974) - thanks @raycheung
* Fix description timestamp links seeking to wrong position for out-of-range values (#966) - thanks @YuriNachos
* Fix deep link timestamp parsing accepting infinity as seek position (#965) - thanks @YuriNachos
* Fix audio sample rate label dropping the kHz decimal (#964) - thanks @YuriNachos
* Fix playback rate display dropping meaningful digits (#963) - thanks @YuriNachos
* Fix www.duckduckgo.com misrouting to yt-dlp external video extraction (#962) - thanks @YuriNachos
### Other
* Chunk stateless feed requests to support more than 500 subscriptions (#967) - thanks @pehbehbeh

View File

@@ -570,7 +570,7 @@
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
CURRENT_PROJECT_VERSION = 270;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -656,7 +656,7 @@
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
CURRENT_PROJECT_VERSION = 270;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -800,7 +800,7 @@
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS-DeveloperID.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
CURRENT_PROJECT_VERSION = 270;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -881,7 +881,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
CURRENT_PROJECT_VERSION = 270;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
@@ -910,7 +910,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
CURRENT_PROJECT_VERSION = 270;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -942,7 +942,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
CURRENT_PROJECT_VERSION = 270;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
@@ -977,7 +977,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
CURRENT_PROJECT_VERSION = 270;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -1008,7 +1008,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
CURRENT_PROJECT_VERSION = 270;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -1040,7 +1040,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
CURRENT_PROJECT_VERSION = 270;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
@@ -1074,7 +1074,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
CURRENT_PROJECT_VERSION = 270;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
@@ -1109,7 +1109,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
CURRENT_PROJECT_VERSION = 270;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
@@ -1138,7 +1138,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 269;
CURRENT_PROJECT_VERSION = 270;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;

View 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)])
}
}
}

View File

@@ -148,7 +148,24 @@ struct MPVTrack: Equatable, Sendable, Identifiable, Decodable {
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 (12 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 parts: [String] = []
if let codec, !codec.isEmpty {
@@ -158,7 +175,7 @@ struct MPVTrack: Equatable, Sendable, Identifiable, Decodable {
parts.append("\(channelCount)ch")
}
if let sampleRate {
parts.append("\(sampleRate / 1000) kHz")
parts.append(Self.formatSampleRate(sampleRate))
}
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}

View File

@@ -672,12 +672,14 @@ private struct PipedVideoStream: Decodable, Sendable {
resolution = nil
}
let audioCodec: String? = (videoOnly ?? true) ? nil : "aac"
return Stream(
url: streamUrl,
resolution: resolution,
format: format ?? "unknown",
videoCodec: codec,
audioCodec: nil,
audioCodec: audioCodec,
bitrate: bitrate,
fileSize: contentLength,
isAudioOnly: false,

View File

@@ -349,18 +349,32 @@ actor YatteeServerAPI: InstanceAPI {
// 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 {
let body = StatelessFeedRequest(channels: channels, limit: limit, offset: offset)
let endpoint = GenericEndpoint.post("/api/v1/feed", body: body)
return try await httpClient.fetch(endpoint, baseURL: instance.url)
var responses: [StatelessFeedResponse] = []
for chunk in channels.chunked(into: Self.maxChannelsPerFeedRequest) {
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).
/// Chunked like `postFeed(channels:limit:offset:instance:)`.
func postFeedStatus(channels: [StatelessChannelStatusRequest], instance: Instance) async throws -> StatelessFeedStatusResponse {
let body = StatelessFeedStatusRequest(channels: channels)
let endpoint = GenericEndpoint.post("/api/v1/feed/status", body: body)
return try await httpClient.fetch(endpoint, baseURL: instance.url)
var responses: [StatelessFeedStatusResponse] = []
for chunk in channels.chunked(into: Self.maxChannelsPerFeedRequest) {
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
@@ -497,6 +511,34 @@ struct StatelessFeedResponse: Decodable, Sendable {
func toVideos() -> [Video] {
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.
@@ -523,6 +565,25 @@ struct StatelessFeedStatusResponse: Decodable, Sendable {
case pendingCount
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

View File

@@ -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)])
}
}
}

View File

@@ -84,7 +84,7 @@ struct URLRouter: Sendable {
let excludedHosts = [
"google.com", "www.google.com",
"bing.com", "www.bing.com",
"duckduckgo.com",
"duckduckgo.com", "www.duckduckgo.com",
"apple.com", "www.apple.com",
"github.com", "www.github.com"
]
@@ -145,9 +145,11 @@ struct URLRouter: Sendable {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
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) {
return value >= 0 ? value : nil
return (value.isFinite && value >= 0) ? value : nil
}
// Compound form like "1h2m3s", "2m30s", "90s"

View File

@@ -131,13 +131,26 @@ enum PlaybackRate: Double, CaseIterable, Identifiable, Sendable {
if rawValue == 1.0 {
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").
/// Use this in space-constrained UI like the player pill.
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"
}
}

View File

@@ -56,12 +56,13 @@ enum DescriptionText {
}
let timestampString = String(text[range])
let seconds = parseTimestamp(timestampString)
if let url = URL(string: "yattee-seek://\(seconds)") {
attributedString[attributedRange].link = url
attributedString[attributedRange].foregroundColor = linkColor
guard let seconds = parseTimestamp(timestampString),
let url = URL(string: "yattee-seek://\(seconds)") else {
continue
}
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.
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) }
switch components.count {
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
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:
return 0
return nil
}
}

View 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)
}
}

View 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")
}
}

View File

@@ -123,6 +123,20 @@ struct URLRouterTests {
#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
@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
@Test("Parse YouTube channel URL")

View 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)
}
}
}

View 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)
}
}

View File

@@ -18,6 +18,15 @@
"category": "entertainment",
"screenshots": [],
"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",
"buildVersion": "268",