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
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// 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 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: " · ")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
"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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@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")
|
||||
|
||||
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",
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user